734 lines
29 KiB
JavaScript
734 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'].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'].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() {
|
|
const state = read();
|
|
const pending = validDetour(state);
|
|
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() { return detourView.finish(); },
|
|
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() {
|
|
const result = timer.returnFromDetour?.();
|
|
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;
|
|
}
|