feat: pause Today during quick capture (Closes #1076)
This commit is contained in:
parent
d9749e3a6f
commit
f8c2541d30
|
|
@ -841,6 +841,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.create-issue-sheet.open { display:flex; }
|
.create-issue-sheet.open { display:flex; }
|
||||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
|
.today-capture-interruption { display:grid; gap:2px; padding:10px 12px; border:1px solid #3f6f9f; border-radius:10px; background:#10243b; }
|
||||||
|
.today-capture-interruption[hidden] { display:none; }
|
||||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||||
.mobile-create-issue-nav { display:none; }
|
.mobile-create-issue-nav { display:none; }
|
||||||
.create-issue-form { display:grid; gap:12px; }
|
.create-issue-form { display:grid; gap:12px; }
|
||||||
|
|
|
||||||
|
|
@ -1851,6 +1851,7 @@
|
||||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
||||||
return completeTodayItem(item);
|
return completeTodayItem(item);
|
||||||
},
|
},
|
||||||
|
onCapture:() => openCreateIssueSheet(),
|
||||||
});
|
});
|
||||||
todaySessionSync = attachTodaySessionHandoff({
|
todaySessionSync = attachTodaySessionHandoff({
|
||||||
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
|
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
|
||||||
|
|
@ -4871,6 +4872,8 @@
|
||||||
creatingIssue = false;
|
creatingIssue = false;
|
||||||
issueCaptureModal().close({restore:!followUpSourceUpdate});
|
issueCaptureModal().close({restore:!followUpSourceUpdate});
|
||||||
createIssueLauncher = null;
|
createIssueLauncher = null;
|
||||||
|
if (createAndStartRequested) timerView.transferCapture();
|
||||||
|
else timerView.finishCapture();
|
||||||
if (followUpSourceUpdate) {
|
if (followUpSourceUpdate) {
|
||||||
const source = followUpSourceUpdate;
|
const source = followUpSourceUpdate;
|
||||||
followUpSourceUpdate = null;
|
followUpSourceUpdate = null;
|
||||||
|
|
@ -5941,7 +5944,6 @@
|
||||||
event.currentTarget.disabled = false;
|
event.currentTarget.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
|
||||||
qs('#create-update-follow-up').addEventListener('click', async () => {
|
qs('#create-update-follow-up').addEventListener('click', async () => {
|
||||||
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
|
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
|
||||||
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
|
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
|
||||||
|
|
@ -5982,16 +5984,19 @@
|
||||||
await unfiledCaptures.completeResume(rUC);
|
await unfiledCaptures.completeResume(rUC);
|
||||||
rUC = '';
|
rUC = '';
|
||||||
}
|
}
|
||||||
|
const capture = timerView.finishCapture();
|
||||||
issueCapture.clearDraft();
|
issueCapture.clearDraft();
|
||||||
qs('#create-issue-title').value = '';
|
qs('#create-issue-title').value = '';
|
||||||
qs('#create-issue-body').value = '';
|
qs('#create-issue-body').value = '';
|
||||||
createIssueAttachmentController.clear();
|
createIssueAttachmentController.clear();
|
||||||
closeCreateIssueSheet(true, false);
|
closeCreateIssueSheet(true, false);
|
||||||
|
if (!capture) {
|
||||||
qs('[data-work-filter="draft"]').click();
|
qs('[data-work-filter="draft"]').click();
|
||||||
mobileTaskDock.select('queues');
|
mobileTaskDock.select('queues');
|
||||||
|
}
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
|
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
|
||||||
requestAnimationFrame(() => {
|
if (!capture) requestAnimationFrame(() => {
|
||||||
savedCard?.scrollIntoView({block:'nearest'});
|
savedCard?.scrollIntoView({block:'nearest'});
|
||||||
savedCard?.focus({preventScroll:true});
|
savedCard?.focus({preventScroll:true});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -964,6 +964,10 @@
|
||||||
<h3 id="create-issue-heading">Capture work</h3>
|
<h3 id="create-issue-heading">Capture work</h3>
|
||||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden>
|
||||||
|
<strong id="today-capture-interruption-label">Today paused</strong>
|
||||||
|
<span class="small">Capture this thought, then return without counting interruption time.</span>
|
||||||
|
</aside>
|
||||||
<nav class="mobile-create-issue-nav" aria-label="New issue sections">
|
<nav class="mobile-create-issue-nav" aria-label="New issue sections">
|
||||||
<button type="button" data-create-issue-section="describe">Describe</button>
|
<button type="button" data-create-issue-section="describe">Describe</button>
|
||||||
<button type="button" data-create-issue-section="evidence">Evidence</button>
|
<button type="button" data-create-issue-section="evidence">Evidence</button>
|
||||||
|
|
|
||||||
|
|
@ -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, timed_break:null,
|
pending_interruption:null, attention_interruption:null, capture_interruption:null, timed_break:null,
|
||||||
});
|
});
|
||||||
const read = () => {
|
const read = () => {
|
||||||
const ownerKey = key();
|
const ownerKey = key();
|
||||||
|
|
@ -42,6 +42,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 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 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 &&
|
||||||
|
|
@ -188,6 +194,44 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
state.away_at = null;
|
state.away_at = null;
|
||||||
return write(state) ? { identity:pending.identity, resumed:pending.resume } : 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;
|
||||||
|
},
|
||||||
markAway() {
|
markAway() {
|
||||||
const state = read();
|
const state = read();
|
||||||
const entry = state.entries[state.active_identity];
|
const entry = state.entries[state.active_identity];
|
||||||
|
|
@ -259,13 +303,20 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onResume, onComplete }) {
|
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onResume, onComplete, onCapture }) {
|
||||||
let progress = null;
|
let progress = null;
|
||||||
let runway = null;
|
let runway = null;
|
||||||
let breakView = null;
|
let breakView = null;
|
||||||
if (typeof createTodayBreak === 'function') {
|
if (typeof createTodayBreak === 'function') {
|
||||||
breakView = createTodayBreak({timer, qs:selector => queryAll(selector)[0], queryAll, onChange:() => render(), onResume:onResume || onReopen});
|
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(),
|
||||||
|
});
|
||||||
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;
|
||||||
|
|
@ -366,7 +417,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
button.setAttribute('aria-pressed', String(!snapshot.running));
|
button.setAttribute('aria-pressed', String(!snapshot.running));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return {
|
const view = {
|
||||||
open(identity, active) {
|
open(identity, active) {
|
||||||
const current = timer.snapshot();
|
const current = timer.snapshot();
|
||||||
if (active && current.identity !== identity) timer.activate(identity);
|
if (active && current.identity !== identity) timer.activate(identity);
|
||||||
|
|
@ -377,8 +428,28 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
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(); },
|
||||||
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); 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,
|
render,
|
||||||
};
|
};
|
||||||
|
queryAll('#new-issue').forEach(button => button.addEventListener('click', () => {
|
||||||
|
view.beginCapture();
|
||||||
|
onCapture?.();
|
||||||
|
}));
|
||||||
|
if (timer.captureInterruption) view.restoreCapture();
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTodayBudgetReplan({ timer, openPlan }) {
|
function createTodayBudgetReplan({ timer, openPlan }) {
|
||||||
|
|
@ -430,9 +501,44 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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.createCaptureInterruption = createTodayCaptureInterruption;
|
||||||
module.exports = createTodayTimer;
|
module.exports = createTodayTimer;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,32 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
if page.locator("#plan-today-sheet").is_visible():
|
if page.locator("#plan-today-sheet").is_visible():
|
||||||
page.locator("#cancel-plan-today").click()
|
page.locator("#cancel-plan-today").click()
|
||||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||||
|
|
||||||
|
page.locator('[data-mobile-task="new"]').click()
|
||||||
|
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
||||||
|
expect(page.locator("#today-capture-interruption")).to_be_visible()
|
||||||
|
expect(page.locator("#today-capture-interruption-label")).to_contain_text("Ship mobile capture")
|
||||||
|
expect(page.locator("#save-unfiled-issue")).to_have_text("Save & return to Today")
|
||||||
|
paused_timer = page.evaluate("""() => {
|
||||||
|
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||||
|
const state = JSON.parse(localStorage.getItem(key));
|
||||||
|
return state.entries[state.active_identity];
|
||||||
|
}""")
|
||||||
|
assert paused_timer["running"] is False
|
||||||
|
page.locator("#create-issue-title").fill("Remember release note")
|
||||||
|
page.locator("#save-unfiled-issue").click()
|
||||||
|
expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open")
|
||||||
|
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||||
|
resumed = page.evaluate("""() => {
|
||||||
|
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||||
|
const timer = JSON.parse(localStorage.getItem(key));
|
||||||
|
const captures = JSON.parse(localStorage.getItem('stackchain.unfiled-issues.v1') || 'null');
|
||||||
|
return {running:timer.entries[timer.active_identity].running,
|
||||||
|
saved:JSON.stringify(captures).includes('Remember release note')};
|
||||||
|
}""")
|
||||||
|
assert resumed == {"running": True, "saved": True}
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
||||||
page.locator("[data-mobile-today-more]").click()
|
page.locator("[data-mobile-today-more]").click()
|
||||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||||
blocked = page.locator("[data-mobile-today-blocked]")
|
blocked = page.locator("[data-mobile-today-blocked]")
|
||||||
|
|
|
||||||
30
tests/test_today_capture_flow.py
Normal file
30
tests/test_today_capture_flow.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||||
|
INDEX = ROOT / "frontend" / "index.html"
|
||||||
|
TIMER = ROOT / "frontend" / "today-timer.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_capture_wires_timer_interruption_to_all_exit_paths():
|
||||||
|
source = DASHBOARD.read_text()
|
||||||
|
timer_source = TIMER.read_text()
|
||||||
|
html = INDEX.read_text()
|
||||||
|
|
||||||
|
assert 'id="today-capture-interruption"' in html
|
||||||
|
assert 'id="today-capture-interruption-label"' in html
|
||||||
|
assert "view.beginCapture();" in timer_source
|
||||||
|
assert "timerView.transferCapture()" in source
|
||||||
|
assert "timerView.finishCapture()" in source
|
||||||
|
assert "result ? 'Save & return to Today' : 'Save to Drafts'" in timer_source
|
||||||
|
assert "if (!capture)" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_save_returns_only_after_durable_admission():
|
||||||
|
source = DASHBOARD.read_text()
|
||||||
|
|
||||||
|
saved = source.index("const savedCapture = await unfiledCaptures.save(captureDraft);")
|
||||||
|
returned = source.index("timerView.finishCapture()", saved)
|
||||||
|
failure = source.index("} catch (error) {", saved)
|
||||||
|
assert saved < returned < failure
|
||||||
|
|
@ -49,3 +49,127 @@ process.stdout.write(JSON.stringify(changes));
|
||||||
{"identity": "issue:r:42:", "elapsed_ms": 0, "running": True},
|
{"identity": "issue:r:42:", "elapsed_ms": 0, "running": True},
|
||||||
{"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
{"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_capture_pauses_only_its_running_item_and_restores_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.beginCapture();
|
||||||
|
now = 16000;
|
||||||
|
const paused = timer.snapshot();
|
||||||
|
const returned = timer.returnFromCapture();
|
||||||
|
now = 18000;
|
||||||
|
const restored = timer.snapshot();
|
||||||
|
const repeated = timer.returnFromCapture();
|
||||||
|
process.stdout.write(JSON.stringify({interruption, paused, 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},
|
||||||
|
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
||||||
|
"returned": {"identity": "issue:r:42:", "resumed": True},
|
||||||
|
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
||||||
|
"repeated": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_capture_does_not_resume_an_already_paused_or_replaced_item():
|
||||||
|
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 = 0;
|
||||||
|
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
||||||
|
timer.adopt('issue:r:1:', 3000, false);
|
||||||
|
const pausedInterruption = timer.beginCapture();
|
||||||
|
const pausedReturn = timer.returnFromCapture();
|
||||||
|
const stayedPaused = timer.snapshot();
|
||||||
|
timer.activate('issue:r:1:');
|
||||||
|
const runningInterruption = timer.beginCapture();
|
||||||
|
timer.activate('issue:r:2:');
|
||||||
|
const replacedReturn = timer.returnFromCapture();
|
||||||
|
const replacement = timer.snapshot();
|
||||||
|
process.stdout.write(JSON.stringify({pausedInterruption, pausedReturn, stayedPaused, runningInterruption, replacedReturn, replacement}));
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"pausedInterruption": {"identity": "issue:r:1:", "resume": False},
|
||||||
|
"pausedReturn": {"identity": "issue:r:1:", "resumed": False},
|
||||||
|
"stayedPaused": {"identity": "issue:r:1:", "elapsed_ms": 3000, "running": False},
|
||||||
|
"runningInterruption": {"identity": "issue:r:1:", "resume": True},
|
||||||
|
"replacedReturn": {"identity": "issue:r:1:", "resumed": False},
|
||||||
|
"replacement": {"identity": "issue:r:2:", "elapsed_ms": 0, "running": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_start_abandons_the_interrupted_timer():
|
||||||
|
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:()=>5000});
|
||||||
|
timer.activate('issue:r:1:');
|
||||||
|
timer.beginCapture();
|
||||||
|
const abandoned = timer.abandonCapture();
|
||||||
|
const snapshot = timer.snapshot();
|
||||||
|
const repeated = timer.returnFromCapture();
|
||||||
|
process.stdout.write(JSON.stringify({abandoned, snapshot, repeated}));
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"abandoned": {"identity": "issue:r:1:", "resumed": False},
|
||||||
|
"snapshot": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
|
||||||
|
"repeated": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_interruption_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 returns = [];
|
||||||
|
const view = createTodayTimer.createCaptureInterruption({
|
||||||
|
timer, banner, label,
|
||||||
|
getItemLabel:identity=>identity === 'issue:r:1:' ? 'Ship release' : '',
|
||||||
|
onReturn:result=>returns.push(result),
|
||||||
|
});
|
||||||
|
timer.activate('issue:r:1:');
|
||||||
|
const opened = view.open();
|
||||||
|
const shown = {hidden:banner.hidden, text:label.textContent, timer:timer.snapshot()};
|
||||||
|
const finished = view.finish();
|
||||||
|
const closed = {hidden:banner.hidden, timer:timer.snapshot(), returns};
|
||||||
|
timer.beginCapture();
|
||||||
|
const transferred = view.transfer();
|
||||||
|
process.stdout.write(JSON.stringify({opened, shown, finished, closed, transferred, final:timer.snapshot()}));
|
||||||
|
"""
|
||||||
|
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},
|
||||||
|
"shown": {
|
||||||
|
"hidden": False,
|
||||||
|
"text": "Today paused · Ship release",
|
||||||
|
"timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
|
||||||
|
},
|
||||||
|
"finished": {"identity": "issue:r:1:", "resumed": True},
|
||||||
|
"closed": {
|
||||||
|
"hidden": True,
|
||||||
|
"timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": True},
|
||||||
|
"returns": [{"identity": "issue:r:1:", "resumed": True}],
|
||||||
|
},
|
||||||
|
"transferred": {"identity": "issue:r:1:", "resumed": False},
|
||||||
|
"final": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user