Merge pull request 'Undo accidental Today completion on mobile' (#1035) from timmy/1034-undo-today-completion into main
All checks were successful
CI / lint (push) Successful in 2m52s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m14s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
rockachopa 2026-08-17 18:12:56 +00:00
commit ca671f0d00
19 changed files with 250 additions and 34 deletions

View File

@ -117,7 +117,10 @@ time. An over-capacity plan requires a second explicit save, legacy plans migrat
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
as unknown rather than unblocked. Completing any Today item now exposes a 10-second, touch-safe
**Undo** receipt. Undo restores the item's original order and estimate, queues the inverse cross-device
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
concurrently changed plan is reported without overwriting newer work. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open

View File

@ -28,9 +28,11 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
#open-insights { display:none; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
.notification-undo { position:fixed; }
.notification-undo button { min-height:44px; }
.notification-undo, .today-completion-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden], .today-completion-undo[hidden] { display:none; }
.notification-undo button, .today-completion-undo button { min-height:44px; min-width:64px; flex:none; }
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
@ -1071,6 +1073,7 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
.today-completion-undo { bottom:calc(168px + env(safe-area-inset-bottom)); }
}
@media (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) {

View File

@ -1308,6 +1308,11 @@
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
</div>
<div class="today-completion-undo" id="today-completion-undo" role="status" aria-live="assertive" hidden>
<span>Removed from Today. Undo?</span>
<button id="undo-today-completion" type="button" aria-label="Undo removing the completed item from Today">Undo</button>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v119';
const CACHE = 'stackchain-dashboard-shell-v120';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -1,6 +1,25 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
undo = [globalThis.document?.getElementById('today-completion-undo'),
globalThis.document?.getElementById('undo-today-completion')] }) {
const [undoReceipt, undoControl] = undo;
let pending = null;
let expiryTimer = null;
function clear() {
if (expiryTimer !== null) clearTimer?.(expiryTimer);
expiryTimer = null;
pending = null;
if (undoReceipt) undoReceipt.hidden = true;
onClear?.();
}
function completeTodayItem(item, options = {}) {
const identity = item && todayWork.identity(item);
const snapshot = item && (todayWork.capture?.(item) ||
(identity ? { id: identity, index: 0, ids: [identity], plan: { capacity_minutes: null, estimates: {} } } : null));
if (!snapshot || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false;
}
@ -10,8 +29,51 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
warm();
(advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
pending = { snapshot, expires_at: now() + ttlMs };
if (undoReceipt) undoReceipt.hidden = false;
if (undoControl) undoControl.disabled = false;
onOffer?.({ identity: snapshot.id, expires_at: pending.expires_at });
expiryTimer = setTimer?.(clear, ttlMs) ?? null;
expiryTimer?.unref?.();
return true;
}
completeTodayItem.undo = () => {
if (!pending) return 'missing';
if (now() >= pending.expires_at) {
clear();
announce('Undo expired. Add the item back to Today from My Work.');
return 'expired';
}
const snapshot = pending.snapshot;
const result = todayWork.restore?.(snapshot) || 'unavailable';
if (result !== 'restored') {
const messages = {
full: 'Today is full. Remove another item before adding this work back.',
changed: 'Today changed on this device. Add the item back from My Work.',
unavailable: 'Could not restore Today on this device. Add the item back from My Work.',
};
clear();
announce(messages[result] || messages.unavailable);
return result;
}
todaySync.enqueue('add', snapshot.id);
for (let index = snapshot.ids.length - 1; index > snapshot.index; index -= 1) {
todaySync.enqueue('move', snapshot.id, 'up');
}
todaySync.flush();
refresh();
warm();
clear();
announce('Restored to Today. Your current work session is unchanged.');
return 'restored';
};
completeTodayItem.clear = clear;
undoControl?.addEventListener?.('click', () => {
undoControl.disabled = true;
if (completeTodayItem.undo() !== 'restored') undoControl.disabled = false;
});
return completeTodayItem;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;

View File

@ -127,6 +127,29 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return saved;
}
function capture(item) {
const id = identity(item);
const ids = read();
const index = ids.indexOf(id);
if (index < 0) return null;
return { id, index, ids: ids.slice(), plan: planning() };
}
function restore(snapshot) {
if (!snapshot || typeof snapshot.id !== 'string' || !Array.isArray(snapshot.ids)) return 'unavailable';
const current = read();
const expected = snapshot.ids.filter(id => id !== snapshot.id);
if (current.includes(snapshot.id)) return 'changed';
if (current.length >= limit) return 'full';
if (current.length !== expected.length || current.some((id, index) => id !== expected[index])) return 'changed';
if (!write(snapshot.ids)) return 'unavailable';
if (!replacePlanning(snapshot.plan)) {
write(current);
return 'unavailable';
}
return 'restored';
}
function move(item, direction) {
const ids = read();
const index = ids.indexOf(identity(item));
@ -177,7 +200,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, capture, restore, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;

View File

@ -89,8 +89,28 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
receipt = page.locator("#today-completion-undo")
expect(receipt).to_be_visible()
expect(receipt).to_contain_text("Removed from Today. Undo?")
undo = page.locator("#undo-today-completion")
bounds = undo.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#close-issue-sheet").click()
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
expect(page.locator(".mobile-task-dock")).to_be_visible()
layout = page.evaluate("""() => {
const receipt = document.querySelector('#today-completion-undo').getBoundingClientRect();
const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect();
const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect();
return {receiptBottom:receipt.bottom, hudTop:hud.top, dockTop:dock.top};
}""")
assert layout["receiptBottom"] <= min(layout["hudTop"], layout["dockTop"])
undo.click()
expect(receipt).to_be_hidden()
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
assert today == ["issue:acme/mobile:42:"]
assert today == ["issue:acme/mobile:41:", "issue:acme/mobile:42:"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
assert failed_responses == []

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v119" in worker
assert "stackchain-dashboard-shell-v120" in worker

View File

@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v119" in worker
assert "stackchain-dashboard-shell-v120" in worker

View File

@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v119" in worker
assert "stackchain-dashboard-shell-v120" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -223,7 +223,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v119" in worker
assert "stackchain-dashboard-shell-v120" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

View File

@ -174,5 +174,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v119" in worker
assert "stackchain-dashboard-shell-v120" in worker
assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v119" in service_worker
assert "stackchain-dashboard-shell-v120" in service_worker
@pytest.mark.anyio

View File

@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
def test_offline_activation_migration_rolls_the_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -183,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@ -192,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@ -200,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -215,7 +215,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -223,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -233,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -249,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -1039,7 +1039,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v119';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v120';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v119" in source
assert "stackchain-dashboard-shell-v120" in source
assert "BASE + 'static/today-sync.js'" in source

View File

@ -209,6 +209,94 @@ process.stdout.write(JSON.stringify({{completed, calls}}));
}
def test_done_for_today_can_restore_original_order_and_estimate_with_inverse_sync():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const todayWork = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue', repository:'r', number}});
todayWork.replace(['issue:r:1:', 'issue:r:2:', 'issue:r:3:']);
todayWork.replacePlanning({{capacity_minutes:180, estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}});
const calls = [];
const offers = [];
const complete = createTodayCompletion({{
todayWork,
todaySync: {{enqueue:(action,id,direction)=>{{calls.push([action,id,direction]);return true;}},flush:()=>calls.push(['flush'])}},
workSession: {{complete:()=>calls.push(['advance'])}},
refresh:()=>calls.push(['refresh']), warm:()=>calls.push(['warm']), announce:message=>calls.push(['announce',message]),
now:()=>1000, onOffer:offer=>offers.push(offer),
}});
const completed = complete(item(2));
const restored = complete.undo();
process.stdout.write(JSON.stringify({{
completed, restored, ids:todayWork.read(), planning:todayWork.planning(), calls,
offer:offers[0] && {{identity:offers[0].identity, expires_at:offers[0].expires_at}},
}}));
"""
assert json.loads(run_node(script)) == {
"completed": True,
"restored": "restored",
"ids": ["issue:r:1:", "issue:r:2:", "issue:r:3:"],
"planning": {
"capacity_minutes": 180,
"estimates": {
"issue:r:1:": 30,
"issue:r:2:": 60,
"issue:r:3:": 45,
},
},
"calls": [
["remove", "issue:r:2:", None], ["flush"], ["refresh"], ["warm"],
["advance"], ["announce", "Done for Today. The Gitea item is unchanged."],
["add", "issue:r:2:", None], ["move", "issue:r:2:", "up"],
["flush"], ["refresh"], ["warm"],
["announce", "Restored to Today. Your current work session is unchanged."],
],
"offer": {"identity": "issue:r:2:", "expires_at": 11000},
}
def test_today_completion_undo_expires_and_rejects_a_changed_plan_without_overwrite():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const todayWork = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue', repository:'r', number}});
let clock = 1000;
const messages = [];
const complete = createTodayCompletion({{
todayWork, todaySync:{{enqueue:()=>true,flush:()=>undefined}}, workSession:{{complete:()=>undefined}},
refresh:()=>undefined, warm:()=>undefined, announce:message=>messages.push(message), now:()=>clock,
}});
todayWork.replace(['issue:r:1:', 'issue:r:2:']);
complete(item(1));
todayWork.replace(['issue:r:2:', 'issue:r:3:']);
const changed = complete.undo();
todayWork.replace(['issue:r:1:', 'issue:r:2:']);
complete(item(1));
clock = 11000;
const expired = complete.undo();
process.stdout.write(JSON.stringify({{changed, expired, ids:todayWork.read(), messages}}));
"""
assert json.loads(run_node(script)) == {
"changed": "changed",
"expired": "expired",
"ids": ["issue:r:2:"],
"messages": [
"Done for Today. The Gitea item is unchanged.",
"Today changed on this device. Add the item back from My Work.",
"Done for Today. The Gitea item is unchanged.",
"Undo expired. Add the item back to Today from My Work.",
],
}
def test_done_for_today_does_not_advance_when_local_plan_persistence_fails():
script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
@ -381,6 +469,18 @@ async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_acti
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in html
@pytest.mark.anyio
async def test_today_completion_surfaces_a_touch_safe_accessible_undo_receipt():
html = await dashboard()
assert 'id="today-completion-undo" role="status" aria-live="assertive" hidden' in html
assert 'id="undo-today-completion"' in html
assert 'Removed from Today. Undo?' in html
assert "getElementById('today-completion-undo')" in TODAY_COMPLETION.read_text()
assert ".today-completion-undo button { min-height:44px" in html
assert "bottom:calc(168px + env(safe-area-inset-bottom))" in html
@pytest.mark.anyio
async def test_dashboard_activates_today_lifecycle_convergence():
html = await dashboard()