Pause active Today timing across mobile Find Work and Queues (#1081)
Closes #1080
This commit is contained in:
commit
d7b8cd5e71
|
|
@ -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; }
|
.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: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; }
|
.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) {
|
@media (max-width: 600px) {
|
||||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
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)); }
|
body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
|
||||||
|
|
|
||||||
|
|
@ -220,13 +220,13 @@
|
||||||
deadlineBadge: qs('#mobile-deadline-count'),
|
deadlineBadge: qs('#mobile-deadline-count'),
|
||||||
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() :
|
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() :
|
||||||
name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name),
|
name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name),
|
||||||
|
detour:() => timerView,
|
||||||
overlays: mobileTaskOverlays,
|
overlays: mobileTaskOverlays,
|
||||||
actions: {
|
actions: {
|
||||||
work: () => mobileWorkEntry.open(),
|
work: () => mobileWorkEntry.open(),
|
||||||
find: () => qs('#find-work').click(),
|
find: () => qs('#find-work').click(),
|
||||||
new: () => qs('#new-issue').click(),
|
new: () => qs('#new-issue').click(),
|
||||||
search: () => qs('#open-palette').click(),
|
search: () => qs('#open-palette').click(),
|
||||||
queues: () => {},
|
|
||||||
},
|
},
|
||||||
observe(callback, overlays) {
|
observe(callback, overlays) {
|
||||||
const observer = new MutationObserver(callback);
|
const observer = new MutationObserver(callback);
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,10 @@
|
||||||
<span><strong>Today paused</strong> while handling Attention</span>
|
<span><strong>Today paused</strong> while handling Attention</span>
|
||||||
<button id="return-to-today" type="button">Return to Today</button>
|
<button id="return-to-today" type="button">Return to Today</button>
|
||||||
</aside>
|
</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>
|
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
|
||||||
<div>
|
<div>
|
||||||
<strong id="device-readiness-heading">Make this phone work-ready</strong>
|
<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><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 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>
|
</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>
|
<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">
|
<nav class="mobile-find-work-nav" aria-label="Find Work steps">
|
||||||
<button type="button" data-find-work-stage="discover">Discover</button>
|
<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">
|
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||||
<section class="mobile-queue-panel">
|
<section class="mobile-queue-panel">
|
||||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
<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>
|
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
|
||||||
<p id="mobile-update-outcome-summary"></p>
|
<p id="mobile-update-outcome-summary"></p>
|
||||||
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
|
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,9 @@
|
||||||
options.queueSheet.showModal();
|
options.queueSheet.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeQueues() {
|
function closeQueues(returnToToday = false) {
|
||||||
options.queueSheet?.open && options.queueSheet.close();
|
options.queueSheet?.open && options.queueSheet.close();
|
||||||
|
if (returnToToday) options.detour?.()?.finishDetour();
|
||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
|
|
@ -42,14 +43,16 @@
|
||||||
button.addEventListener('click', event => {
|
button.addEventListener('click', event => {
|
||||||
launcher = event.currentTarget;
|
launcher = event.currentTarget;
|
||||||
select(name);
|
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) {
|
if (options.queueSheet) {
|
||||||
options.queueClose?.addEventListener('click', closeQueues);
|
options.queueClose?.addEventListener('click', () => closeQueues(true));
|
||||||
options.queueSheet.addEventListener('cancel', event => {
|
options.queueSheet.addEventListener('cancel', event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
closeQueues();
|
closeQueues(true);
|
||||||
});
|
});
|
||||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
};
|
};
|
||||||
const empty = () => ({
|
const empty = () => ({
|
||||||
version:1, active_identity:'', entries:{}, away_at:null,
|
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 read = () => {
|
||||||
const ownerKey = key();
|
const ownerKey = key();
|
||||||
|
|
@ -54,6 +54,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
typeof pending.resume === 'boolean' ?
|
typeof pending.resume === 'boolean' ?
|
||||||
{ identity:pending.identity, resume:pending.resume } : null;
|
{ 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 validBreak = state => {
|
||||||
const value = state.timed_break;
|
const value = state.timed_break;
|
||||||
return value && typeof value.identity === 'string' && value.identity &&
|
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.pending_interruption = null;
|
||||||
state.attention_interruption = null;
|
state.attention_interruption = null;
|
||||||
state.search_interruption = null;
|
state.search_interruption = null;
|
||||||
|
state.detour_interruption = null;
|
||||||
state.timed_break = null;
|
state.timed_break = null;
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
|
|
@ -118,6 +125,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
state.pending_interruption = null;
|
state.pending_interruption = null;
|
||||||
state.attention_interruption = null;
|
state.attention_interruption = null;
|
||||||
state.search_interruption = null;
|
state.search_interruption = null;
|
||||||
|
state.detour_interruption = null;
|
||||||
state.timed_break = null;
|
state.timed_break = null;
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
|
|
@ -170,6 +178,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
state.pending_interruption = null;
|
state.pending_interruption = null;
|
||||||
state.attention_interruption = null;
|
state.attention_interruption = null;
|
||||||
state.search_interruption = null;
|
state.search_interruption = null;
|
||||||
|
state.detour_interruption = null;
|
||||||
state.timed_break = null;
|
state.timed_break = null;
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
|
|
@ -279,6 +288,36 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
state.away_at = null;
|
state.away_at = null;
|
||||||
return write(state) ? { identity:pending.identity, resumed } : 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() {
|
markAway() {
|
||||||
const state = read();
|
const state = read();
|
||||||
const entry = state.entries[state.active_identity];
|
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(),
|
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
|
||||||
onReturn:identity => onReopen?.(identity),
|
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 =>
|
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const identity = timer.snapshot().identity;
|
const identity = timer.snapshot().identity;
|
||||||
|
|
@ -477,6 +520,8 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
},
|
},
|
||||||
reopen(identity) { onReopen?.(identity); },
|
reopen(identity) { onReopen?.(identity); },
|
||||||
search(action, ...args) { return searchView[action]?.(...args); },
|
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(); },
|
finish() { timer.stop(); progress = null; runway = null; render(); },
|
||||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||||
reset() { progress = null; runway = null; render(); },
|
reset() { progress = null; runway = null; render(); },
|
||||||
|
|
@ -503,6 +548,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
}));
|
}));
|
||||||
if (timer.captureInterruption) view.restoreCapture();
|
if (timer.captureInterruption) view.restoreCapture();
|
||||||
searchView.restore();
|
searchView.restore();
|
||||||
|
detourView.restore();
|
||||||
return view;
|
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) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
createTodayTimer.createView = createTodayTimerView;
|
createTodayTimer.createView = createTodayTimerView;
|
||||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||||
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
||||||
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
|
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
|
||||||
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
|
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
|
||||||
|
createTodayTimer.createDetourInterruption = createTodayDetourInterruption;
|
||||||
module.exports = createTodayTimer;
|
module.exports = createTodayTimer;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,83 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
fake_thread.join(timeout=5)
|
fake_thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_artifact_pauses_today_across_find_and_queue_detours(tmp_path: Path):
|
||||||
|
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||||
|
assert len(archives) == 1
|
||||||
|
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||||
|
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||||
|
page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||||
|
page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
page.locator('input[name="device_label"]').fill("Today detour phone")
|
||||||
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
|
page.locator("#submit-sign-in").click()
|
||||||
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
|
||||||
|
page.locator('[data-mobile-task="work"]').click()
|
||||||
|
page.locator("#plan-today-available").fill("90")
|
||||||
|
page.locator("#plan-today-available").press("Tab")
|
||||||
|
page.locator("#build-today-plan").click()
|
||||||
|
missing = page.locator("[data-plan-missing-estimate]")
|
||||||
|
expect(missing).to_have_count(2)
|
||||||
|
for _ in range(2):
|
||||||
|
missing.nth(0).fill("30")
|
||||||
|
missing.nth(0).press("Tab")
|
||||||
|
page.locator("#save-and-start-today").click()
|
||||||
|
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||||
|
page.locator("#close-issue-sheet").click()
|
||||||
|
if page.locator("#plan-today-sheet").is_visible():
|
||||||
|
page.locator("#cancel-plan-today").click()
|
||||||
|
|
||||||
|
page.locator('[data-mobile-task="find"]').click()
|
||||||
|
find_pause = page.locator("#find-work-sheet [data-today-detour]")
|
||||||
|
expect(find_pause).to_be_visible()
|
||||||
|
expect(find_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||||
|
bounds = find_pause.locator("[data-return-from-detour]").bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert page.evaluate("""() => {
|
||||||
|
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||||
|
const timer = JSON.parse(localStorage.getItem(key));
|
||||||
|
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'find';
|
||||||
|
}""")
|
||||||
|
page.locator("#close-find-work").click()
|
||||||
|
assert page.evaluate("""() => {
|
||||||
|
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||||
|
const timer = JSON.parse(localStorage.getItem(key));
|
||||||
|
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||||
|
}""")
|
||||||
|
|
||||||
|
page.locator('[data-mobile-task="queues"]').click()
|
||||||
|
queue_pause = page.locator("#mobile-queue-sheet [data-today-detour]")
|
||||||
|
expect(queue_pause).to_be_visible()
|
||||||
|
expect(queue_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||||
|
page.locator('[data-mobile-queue="later"]').click()
|
||||||
|
persistent_pause = page.locator("#my-work > [data-today-detour]")
|
||||||
|
expect(persistent_pause).to_be_visible()
|
||||||
|
return_button = persistent_pause.locator("[data-return-from-detour]")
|
||||||
|
bounds = return_button.bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
return_button.click()
|
||||||
|
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||||
|
assert page.evaluate("""() => {
|
||||||
|
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||||
|
const timer = JSON.parse(localStorage.getItem(key));
|
||||||
|
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
|
||||||
|
}""")
|
||||||
|
browser.close()
|
||||||
|
finally:
|
||||||
|
fake.shutdown()
|
||||||
|
fake.server_close()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path):
|
def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path):
|
||||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||||
assert len(archives) == 1
|
assert len(archives) == 1
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,60 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_task_dock_pauses_before_queues_and_distinguishes_close_from_selection():
|
||||||
|
script = f"""
|
||||||
|
const createDock = require({json.dumps(str(DOCK))});
|
||||||
|
class FakeElement {{
|
||||||
|
constructor() {{ this.listeners = {{}}; this.open = false; this.attributes = {{}}; }}
|
||||||
|
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||||
|
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||||
|
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||||
|
showModal() {{ this.open = true; }}
|
||||||
|
close() {{ this.open = false; this.listeners.close?.(); }}
|
||||||
|
focus() {{}}
|
||||||
|
}}
|
||||||
|
const nav = new FakeElement();
|
||||||
|
nav.hidden = false;
|
||||||
|
const queues = new FakeElement();
|
||||||
|
const close = new FakeElement();
|
||||||
|
const row = new FakeElement();
|
||||||
|
const button = new FakeElement();
|
||||||
|
const calls = [];
|
||||||
|
const dock = createDock({{
|
||||||
|
nav, buttons:{{queues:button}}, queueSheet:queues, queueClose:close, queueRows:{{update:row}}, overlays:[],
|
||||||
|
detour:()=>({{beginDetour:reason=>calls.push('pause:' + reason), finishDetour:()=>calls.push('return')}}),
|
||||||
|
onSelectQueue:name=>calls.push('select:' + name),
|
||||||
|
observe:()=>({{disconnect(){{}}}}),
|
||||||
|
}});
|
||||||
|
dock.start();
|
||||||
|
button.listeners.click({{currentTarget:button}});
|
||||||
|
close.listeners.click();
|
||||||
|
button.listeners.click({{currentTarget:button}});
|
||||||
|
row.listeners.click();
|
||||||
|
process.stdout.write(JSON.stringify({{calls, open:queues.open}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"calls": ["pause:queues", "return", "pause:queues", "select:update"],
|
||||||
|
"open": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert html.count('data-today-detour role="status"') == 3
|
||||||
|
assert html.count('data-return-from-detour type="button"') == 3
|
||||||
|
assert "detour:() => timerView" in html
|
||||||
|
timer_source = TIMER.read_text()
|
||||||
|
assert "createTodayDetourInterruption" in timer_source
|
||||||
|
assert "queryAll('#find-work-sheet')" in timer_source
|
||||||
|
assert '.today-detour-interruption button { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
|
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createDock = require({json.dumps(str(DOCK))});
|
const createDock = require({json.dumps(str(DOCK))});
|
||||||
|
|
@ -815,7 +869,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||||
assert "find: () => qs('#find-work').click()" in html
|
assert "find: () => qs('#find-work').click()" in html
|
||||||
assert "new: () => qs('#new-issue').click()" in html
|
assert "new: () => qs('#new-issue').click()" in html
|
||||||
assert "search: () => qs('#open-palette').click()" in html
|
assert "search: () => qs('#open-palette').click()" in html
|
||||||
assert "queues: () => {}" in html
|
assert "detour:() => timerView" in html
|
||||||
assert "mobileTaskDock.updateQueues(counts)" in html
|
assert "mobileTaskDock.updateQueues(counts)" in html
|
||||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||||
|
|
|
||||||
|
|
@ -227,3 +227,101 @@ process.stdout.write(JSON.stringify({interruption, paused, returned, restored, r
|
||||||
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
||||||
"repeated": None,
|
"repeated": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_detour_pauses_running_today_time_and_restores_exactly_once():
|
||||||
|
script = SOURCE.read_text() + r"""
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||||
|
let now = 1000;
|
||||||
|
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
||||||
|
timer.activate('issue:r:42:');
|
||||||
|
now = 6000;
|
||||||
|
const interruption = timer.beginDetour('find');
|
||||||
|
now = 26000;
|
||||||
|
const paused = timer.snapshot();
|
||||||
|
const durable = timer.detourInterruption();
|
||||||
|
const returned = timer.returnFromDetour();
|
||||||
|
now = 28000;
|
||||||
|
const restored = timer.snapshot();
|
||||||
|
const repeated = timer.returnFromDetour();
|
||||||
|
process.stdout.write(JSON.stringify({interruption, paused, durable, returned, restored, repeated}));
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"interruption": {"identity": "issue:r:42:", "resume": True, "reason": "find"},
|
||||||
|
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
||||||
|
"durable": {"identity": "issue:r:42:", "resume": True, "reason": "find"},
|
||||||
|
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "find"},
|
||||||
|
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
||||||
|
"repeated": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_detour_never_resumes_manually_paused_or_replaced_work():
|
||||||
|
script = SOURCE.read_text() + r"""
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||||
|
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>1000});
|
||||||
|
timer.adopt('issue:r:1:', 3000, false);
|
||||||
|
const pausedDetour = timer.beginDetour('queues');
|
||||||
|
timer.activate('issue:r:1:');
|
||||||
|
const runningDetour = timer.beginDetour('queues');
|
||||||
|
timer.activate('issue:r:2:');
|
||||||
|
const replacedReturn = timer.returnFromDetour();
|
||||||
|
process.stdout.write(JSON.stringify({pausedDetour, runningDetour, replacedReturn, snapshot:timer.snapshot()}));
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"pausedDetour": None,
|
||||||
|
"runningDetour": {"identity": "issue:r:1:", "resume": True, "reason": "queues"},
|
||||||
|
"replacedReturn": None,
|
||||||
|
"snapshot": {"identity": "issue:r:2:", "elapsed_ms": 0, "running": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_detour_view_names_work_and_returns_to_today():
|
||||||
|
script = SOURCE.read_text() + r"""
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||||
|
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>1000});
|
||||||
|
const banner = {hidden:true};
|
||||||
|
const label = {textContent:''};
|
||||||
|
const button = {listeners:{}, addEventListener(name, callback) { this.listeners[name] = callback; }, click() { this.listeners.click(); }};
|
||||||
|
const returns = [];
|
||||||
|
const view = createTodayTimer.createDetourInterruption({
|
||||||
|
timer,
|
||||||
|
queryAll:selector => ({
|
||||||
|
'[data-today-detour]':[banner],
|
||||||
|
'[data-today-detour-label]':[label],
|
||||||
|
'[data-return-from-detour]':[button],
|
||||||
|
}[selector] || []),
|
||||||
|
getItemLabel:identity=>identity === 'issue:r:1:' ? 'Ship release' : '',
|
||||||
|
onReturn:identity=>returns.push(identity),
|
||||||
|
});
|
||||||
|
timer.activate('issue:r:1:');
|
||||||
|
const opened = view.open('find');
|
||||||
|
const shown = {hidden:banner.hidden, text:label.textContent, timer:timer.snapshot()};
|
||||||
|
button.click();
|
||||||
|
process.stdout.write(JSON.stringify({opened, shown, closed:{hidden:banner.hidden, timer:timer.snapshot(), returns}}));
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"opened": {"identity": "issue:r:1:", "resume": True, "reason": "find"},
|
||||||
|
"shown": {
|
||||||
|
"hidden": False,
|
||||||
|
"text": "Today paused · Ship release",
|
||||||
|
"timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
|
||||||
|
},
|
||||||
|
"closed": {
|
||||||
|
"hidden": True,
|
||||||
|
"timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": True},
|
||||||
|
"returns": ["issue:r:1:"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user