feat: pause Today during mobile work detours (Closes #1080)
CI / lint (pull_request) Successful in 3m12s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-18 13:51:40 +00:00
1 parent 38efd4eb7c
commit 58cee5ea16
8 files changed
+338 -7

No files matched your search

+2
View File
@@ -1005,6 +1005,8 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.attention-interruption button { min-height:44px; flex:0 0 auto; }
.today-detour-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.today-detour-interruption button { min-height:44px; flex:0 0 auto; }
@media (max-width: 600px) {
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
+1 -1
View File
@@ -220,13 +220,13 @@
deadlineBadge: qs('#mobile-deadline-count'),
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() :
name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name),
detour:() => timerView,
overlays: mobileTaskOverlays,
actions: {
work: () => mobileWorkEntry.open(),
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
queues: () => {},
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
+12
View File
@@ -207,6 +207,10 @@
<span><strong>Today paused</strong> while handling Attention</span>
<button id="return-to-today" type="button">Return to Today</button>
</aside>
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button data-return-from-detour type="button">Return to Today</button>
</aside>
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
<div>
<strong id="device-readiness-heading">Make this phone work-ready</strong>
@@ -909,6 +913,10 @@
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
</div>
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button data-return-from-detour type="button">Return to Today</button>
</aside>
<p class="small">Choose available issues, review the batch, then fit it into Today.</p>
<nav class="mobile-find-work-nav" aria-label="Find Work steps">
<button type="button" data-find-work-stage="discover">Discover</button>
@@ -1671,6 +1679,10 @@
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
<section class="mobile-queue-panel">
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button data-return-from-detour type="button">Return to Today</button>
</aside>
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
<p id="mobile-update-outcome-summary"></p>
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
+7 -4
View File
@@ -33,8 +33,9 @@
options.queueSheet.showModal();
}
function closeQueues() {
function closeQueues(returnToToday = false) {
options.queueSheet?.open && options.queueSheet.close();
if (returnToToday) options.detour?.()?.finishDetour();
}
function start() {
@@ -42,14 +43,16 @@
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
if (name === 'queues') options.detour?.()?.beginDetour(name);
options.actions?.[name]?.();
if (name === 'queues' && options.queueSheet) openQueues();
});
});
if (options.queueSheet) {
options.queueClose?.addEventListener('click', closeQueues);
options.queueClose?.addEventListener('click', () => closeQueues(true));
options.queueSheet.addEventListener('cancel', event => {
event.preventDefault();
closeQueues();
closeQueues(true);
});
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
+86 -1
View File
@@ -5,7 +5,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
};
const empty = () => ({
version:1, active_identity:'', entries:{}, away_at:null,
pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, timed_break:null,
pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, detour_interruption:null, timed_break:null,
});
const read = () => {
const ownerKey = key();
@@ -54,6 +54,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
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 &&
@@ -101,6 +107,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
@@ -118,6 +125,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
@@ -170,6 +178,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
@@ -279,6 +288,36 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
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];
@@ -368,6 +407,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
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;
@@ -477,6 +520,8 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
},
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(); },
@@ -503,6 +548,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
}));
if (timer.captureInterruption) view.restoreCapture();
searchView.restore();
detourView.restore();
return view;
}
@@ -638,11 +684,50 @@ function createTodayCaptureInterruption({ timer, banner, label, getItem, getItem
};
}
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;
}