feat: undo accidental Today completion (Closes #1034)
All checks were successful
CI / lint (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m13s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-17 18:06:52 +00:00
parent e47e6329cc
commit 6e677f8857
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** 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 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 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. 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 After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open 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; } .app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
#open-insights { display:none; } #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; } 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 { position:fixed; }
.notification-undo[hidden] { display:none; } .notification-undo button { min-height:44px; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; } .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 { 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-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; } .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 [data-mobile-today-toggle] { grid-area:toggle; }
.mobile-today-hud button { min-height:44px; max-width:100%; } .mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; } .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 (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) { @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> <button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
</div> </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"> <div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel"> <section class="pull-sheet-panel">
<div class="pull-sheet-header"> <div class="pull-sheet-header">

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; 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 }) { function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
return function completeTodayItem(item, options = {}) { now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
if (!item || !todayWork.remove(item)) { 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.'); announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false; return false;
} }
@ -10,8 +29,51 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
warm(); warm();
(advance || (() => workSession.complete()))(); (advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.'); 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; 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; if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;

View File

@ -127,6 +127,29 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return saved; 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) { function move(item, direction) {
const ids = read(); const ids = read();
const index = ids.indexOf(identity(item)); 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; 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_have_attribute("data-overlay-hidden", "true")
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters") 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.")] 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') || '[]')") 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 page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == [] assert browser_errors == []
assert failed_responses == [] 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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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])) 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 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(): 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 "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker 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-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" 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(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() 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 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 ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker 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 @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(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() 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.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.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(): def test_offline_activation_migration_rolls_the_shell_cache():
source = WORKER.read_text() 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(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() 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/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.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(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() 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-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.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(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() 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/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() 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/today-completion.js'" in source
assert "BASE + 'static/dashboard.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(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() 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/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() 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 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(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 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(): def test_done_for_today_does_not_advance_when_local_plan_persistence_fails():
script = f""" script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))}); 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 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 @pytest.mark.anyio
async def test_dashboard_activates_today_lifecycle_convergence(): async def test_dashboard_activates_today_lifecycle_convergence():
html = await dashboard() html = await dashboard()