stackchain-dashboard/frontend/today-timer.js
timmy 473f592615
All checks were successful
CI / lint (pull_request) Successful in 3m27s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m59s
CI / release-candidate (pull_request) Has been skipped
feat: pause Today during mobile Security Center (Closes #1086)
2026-08-18 16:41:59 +00:00

735 lines
29 KiB
JavaScript

function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange = () => {} }) {
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, capture_interruption:null, search_interruption:null, detour_interruption:null, timed_break: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));
onChange(snapshot());
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 validCapture = state => {
const pending = state.capture_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validSearch = state => {
const pending = state.search_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validDetour = state => {
const pending = state.detour_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(pending.reason) ?
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
};
const validBreak = state => {
const value = state.timed_break;
return value && typeof value.identity === 'string' && value.identity &&
Number.isFinite(value.deadline_at) && value.deadline_at >= 0 ?
{ identity:value.identity, deadline_at:value.deadline_at, expired:now() >= value.deadline_at } : 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];
const result = !selected || !entry ?
{ identity:selected, elapsed_ms:0, running:false } :
{
identity:selected,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
running:Boolean(entry.running),
};
const timedBreak = validBreak(state);
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
return result;
};
return {
adopt(identity, elapsedMs, running) {
if (!key() || typeof identity !== 'string' || !identity ||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
const state = read();
settle(state);
state.active_identity = identity;
state.entries[identity] = {
elapsed_ms:Math.floor(Number(elapsedMs)),
started_at:running ? now() : null,
running:Boolean(running),
};
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
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;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = 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;
}
state.timed_break = null;
return write(state);
},
startBreak(minutes) {
const duration = Number(minutes);
if (!Number.isInteger(duration) || duration < 1 || duration > 120) return false;
const state = read();
const identity = state.active_identity;
if (!identity || !state.entries[identity]) return false;
settle(state);
state.away_at = null;
state.pending_interruption = null;
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
return write(state) ? validBreak(state) : false;
},
breakSnapshot() {
return validBreak(read());
},
resumeBreak() {
const state = read();
const pending = validBreak(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry || state.active_identity !== pending.identity || entry.running) return false;
state.timed_break = null;
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;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = 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;
},
beginCapture() {
const state = read();
const existing = validCapture(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.capture_interruption = { identity, resume };
return write(state) ? { ...state.capture_interruption } : null;
},
captureInterruption() {
return validCapture(read());
},
abandonCapture() {
const state = read();
const pending = validCapture(state);
if (!pending) return null;
state.capture_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:false } : null;
},
returnFromCapture() {
const state = read();
const pending = validCapture(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
const resumed = pending.resume && state.active_identity === pending.identity;
if (resumed && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.capture_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null;
},
beginSearch() {
const state = read();
const existing = validSearch(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry?.running) return null;
const resume = true;
settle(state);
state.away_at = null;
state.search_interruption = { identity, resume };
return write(state) ? { ...state.search_interruption } : null;
},
searchInterruption() {
return validSearch(read());
},
abandonSearch() {
const state = read();
const pending = validSearch(state);
if (!pending) return null;
state.search_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:false } : null;
},
returnFromSearch() {
const state = read();
const pending = validSearch(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
const resumed = pending.resume && state.active_identity === pending.identity;
if (resumed && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.search_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null;
},
beginDetour(reason) {
if (!['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(reason)) return null;
const state = read();
const existing = validDetour(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry?.running) return null;
settle(state);
state.away_at = null;
state.detour_interruption = { identity, resume:true, reason };
return write(state) ? { ...state.detour_interruption } : null;
},
detourInterruption() {
return validDetour(read());
},
returnFromDetour(reason = '') {
const state = read();
const pending = validDetour(state);
if (reason && pending?.reason !== reason) return null;
const entry = pending && state.entries[pending.identity];
if (!pending || !entry || state.active_identity !== pending.identity) return null;
const resumed = pending.resume && !entry.running;
if (resumed) {
entry.started_at = now();
entry.running = true;
}
state.detour_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed, reason:pending.reason } : 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, onResume, onComplete, onCapture }) {
let progress = null;
let runway = null;
let breakView = null;
if (typeof createTodayBreak === 'function') {
breakView = createTodayBreak({timer, qs:selector => queryAll(selector)[0], queryAll, onChange:() => render(), onResume:onResume || onReopen});
}
const captureView = createTodayCaptureInterruption({
timer,
banner:queryAll('#today-capture-interruption')[0],
label:queryAll('#today-capture-interruption-label')[0],
getItem,
onReturn:() => render(),
});
const searchView = createTodaySearchInterruption({
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
onReturn:identity => onReopen?.(identity),
});
const detourView = createTodayDetourInterruption({
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
onReturn:identity => onReopen?.(identity),
});
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();
breakView?.render();
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));
});
};
const view = {
open(identity, active) {
const current = timer.snapshot();
if (active && current.identity !== identity) timer.activate(identity);
else if (!active) timer.stop();
render();
},
reopen(identity) { onReopen?.(identity); },
search(action, ...args) { return searchView[action]?.(...args); },
beginDetour(reason) { return detourView.open(reason); },
finishDetour(reason) { return detourView.finish(reason); },
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(); },
beginCapture() {
const result = captureView.open();
this.restoreCapture();
render();
return result;
},
restoreCapture() {
const result = captureView.restore();
const button = queryAll('#save-unfiled-issue')[0];
if (button) button.textContent = result ? 'Save & return to Today' : 'Save to Drafts';
return result;
},
finishCapture() { return captureView.finish(); },
transferCapture() { return captureView.transfer(); },
render,
};
queryAll('#new-issue').forEach(button => button.addEventListener('click', () => {
view.beginCapture();
onCapture?.();
}));
if (timer.captureInterruption) view.restoreCapture();
searchView.restore();
detourView.restore();
return view;
}
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;
},
};
}
function createTodaySearchInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
const render = (pending = timer.searchInterruption?.()) => {
queryAll('[data-search-today-interruption]').forEach(element => { element.hidden = !pending; });
queryAll('[data-search-today-label]').forEach(element => {
element.textContent = pending ? 'Today paused · ' +
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
});
onChange?.();
return pending;
};
const view = {
open() {
const wasRunning = timer.snapshot().running;
const pending = timer.beginSearch?.();
render(pending || timer.searchInterruption?.());
if (wasRunning && !pending) {
const status = queryAll('#cmd-search-action-status')[0];
if (status) status.textContent = 'Search is open, but Today timing could not be paused on this device.';
}
return pending;
},
restore() { return render(); },
finish() {
const result = timer.returnFromSearch?.();
render(null);
return result;
},
transfer() {
const result = timer.abandonSearch?.();
render(null);
return result;
},
};
queryAll('#open-palette').forEach(button => button.addEventListener('click', () => view.open()));
queryAll('[data-return-from-search]').forEach(button => button.addEventListener('click', () => {
const pending = timer.searchInterruption?.();
view.finish();
if (pending) onReturn?.(pending.identity);
}));
if (typeof MutationObserver !== 'undefined') {
const overlays = [...queryAll('#cmd-palette'), ...queryAll('#search-preview')];
const observer = new MutationObserver(() => {
if (overlays.every(element => !element.classList.contains('open'))) view.finish();
});
overlays.forEach(element => observer.observe(element, {attributes:true, attributeFilter:['class']}));
}
return view;
}
function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) {
banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption');
label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label');
const render = pending => {
if (!banner || !label) return;
banner.hidden = !pending;
label.textContent = pending ? 'Today paused · ' +
String(getItem?.(pending.identity)?.title || getItemLabel?.(pending.identity) || 'Current Today item') : '';
};
return {
open() {
const pending = timer.beginCapture();
render(pending);
return pending;
},
restore() {
const pending = timer.captureInterruption();
render(pending);
return pending;
},
finish() {
const result = timer.returnFromCapture();
render(null);
if (result) onReturn?.(result);
return result;
},
transfer() {
const result = timer.abandonCapture();
render(null);
return result;
},
};
}
function createTodayDetourInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
const render = (pending = timer.detourInterruption?.()) => {
queryAll('[data-today-detour]').forEach(element => { element.hidden = !pending; });
queryAll('[data-today-detour-label]').forEach(element => {
element.textContent = pending ? 'Today paused · ' +
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
});
onChange?.();
return pending;
};
const view = {
open(reason) {
const pending = timer.beginDetour?.(reason);
render(pending || timer.detourInterruption?.());
return pending;
},
restore() { return render(); },
finish(reason) {
const result = timer.returnFromDetour?.(reason);
if (result || !reason) render(null);
return result;
},
};
queryAll('[data-return-from-detour]').forEach(button => button.addEventListener('click', () => {
const pending = timer.detourInterruption?.();
view.finish();
if (pending) onReturn?.(pending.identity);
}));
if (typeof MutationObserver !== 'undefined') {
queryAll('#find-work-sheet').forEach(sheet => new MutationObserver(() => {
const pending = timer.detourInterruption?.();
if (sheet.classList.contains('open')) view.open('find');
else if (pending?.reason === 'find') view.finish();
}).observe(sheet, {attributes:true, attributeFilter:['class']}));
}
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
createTodayTimer.createDetourInterruption = createTodayDetourInterruption;
module.exports = createTodayTimer;
}