Resume recap-to-plan feedback after interruptions #586

Merged
timmy merged 1 commits from timmy/585-resume-recap-replan into main 2026-08-11 18:59:25 +00:00
17 changed files with 148 additions and 23 deletions

View File

@ -67,7 +67,7 @@ and an active Today session shows the current estimate plus estimated remaining
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
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. 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. Actual time appears there as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. 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. 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. Actual time appears there 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);
**Comment & next** on that current issue or pull request posts the handoff online or admits it

View File

@ -127,6 +127,7 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
.plan-today-error { min-height:1.4em; color:#fca5a5; }
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }

View File

@ -1375,6 +1375,7 @@
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
if (!todayWork.replace(ids)) return false;
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
todayRecapView.completeReplan();
refreshMyWorkView();
todaySync.flush();
warmTodayOffline();
@ -1640,9 +1641,10 @@
taskOverlayHistory.open('plan-today');
return;
}
const recommendations = actualMinutes || pendingPlanActualMinutes;
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
pendingPlanActualMinutes = null;
planToday.open(todayMyWork, activeMyWork, todayWork.planning(), recommendations);
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
@ -1650,6 +1652,14 @@
qs('#cancel-plan-today').focus();
}
qs('#discard-recap-replan').addEventListener('click', () => {
todayRecapView.discardReplan();
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
qs('#discard-recap-replan').hidden = true;
qs('#plan-today-error').textContent = 'Recap feedback discarded. Your saved recap is unchanged.';
renderPlanToday();
});
const detailDefer = createDetailDefer({
laterWork,
session: workSession,

View File

@ -251,6 +251,7 @@
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
</label>
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
<section aria-labelledby="today-plan-heading">
<h3 id="today-plan-heading">Today, in order</h3>
<div class="plan-today-list" id="plan-today-list"></div>

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v88';
const CACHE = 'stackchain-dashboard-shell-v89';
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

@ -23,6 +23,10 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-recap-draft.v1.' + login : '';
};
const handoffKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
};
const validStoredDraft = saved => {
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
!Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
@ -47,6 +51,33 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
return null;
}
};
const validHandoff = saved => {
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
!saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
const entries = Object.entries(saved.actual_minutes);
return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
);
};
const pendingReplan = () => {
const key = handoffKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!validHandoff(saved)) {
if (saved !== null) storage.removeItem(key);
return null;
}
return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
} catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
const completeReplan = () => {
const key = handoffKey();
if (storage && key) storage.removeItem(key);
};
let draftKey = storageKey();
let draft = load();
const persist = () => {
@ -75,6 +106,9 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
payload.items.map(item => [item.identity, item.actual_minutes])
) : null;
const result = await save(payload);
if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
session_id:payload.session_id, actual_minutes:actualMinutes,
}));
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
@ -106,6 +140,9 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
snapshot,
save:() => saveConfirmed(false),
saveForReplan:() => saveConfirmed(true),
pendingReplan,
completeReplan,
discardReplan:completeReplan,
};
}
@ -193,7 +230,12 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#discard-today-recap').addEventListener('click', close);
qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
};
return { open, close, saveDraft, loadHistory, render, bind };
return {
open, close, saveDraft, loadHistory, render, bind,
pendingReplan:recap.pendingReplan,
completeReplan:recap.completeReplan,
discardReplan:recap.discardReplan,
};
}
function endTodaySession(workSession, qs) {

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-v88" in worker
assert "stackchain-dashboard-shell-v89" in worker

View File

@ -162,7 +162,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v88';",
"const CACHE = 'stackchain-dashboard-shell-v89';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)

View File

@ -347,5 +347,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-v88" in source
assert "stackchain-dashboard-shell-v89" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,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-v88" in worker
assert "stackchain-dashboard-shell-v89" in worker

View File

@ -41,7 +41,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-v88" in worker
assert "stackchain-dashboard-shell-v89" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -186,7 +186,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-v88" in worker
assert "stackchain-dashboard-shell-v89" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

View File

@ -332,6 +332,6 @@ 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-v88" in source
assert "stackchain-dashboard-shell-v89" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -135,7 +135,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v88" in source
assert "stackchain-dashboard-shell-v89" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -144,14 +144,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v88" in source
assert "stackchain-dashboard-shell-v89" 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-v88" in source
assert "stackchain-dashboard-shell-v89" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -159,7 +159,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-v88" in source
assert "stackchain-dashboard-shell-v89" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -167,14 +167,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v88" in source
assert "stackchain-dashboard-shell-v89" 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-v88" in source
assert "stackchain-dashboard-shell-v89" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -183,21 +183,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-v88" in source
assert "stackchain-dashboard-shell-v89" 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-v88" in source
assert "stackchain-dashboard-shell-v89" 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-v88" in source
assert "stackchain-dashboard-shell-v89" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -638,7 +638,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-v88" in source
assert "stackchain-dashboard-shell-v89" 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-v88';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v89';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -306,6 +306,61 @@ recap.begin([
assert output["after"] is None
def test_recap_controller_restores_account_scoped_pending_replan_until_completed():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let login='timmy';
const options={{save:payload=>Promise.resolve({{session_id:payload.session_id}}),clear:()=>{{}},storage,getLogin:()=>login}};
const first=createRecap({{...options,makeId:()=> 'saved-session'}});
first.begin([{{identity:'issue:r:1:',elapsed_ms:52*60000}}], {{'issue:r:1:':30}});
(async()=>{{
await first.saveForReplan();
const afterSave=first.pendingReplan();
const restored=createRecap(options).pendingReplan();
login='alexander';
const otherAccount=createRecap(options).pendingReplan();
login='timmy';
const retainedAfterCancel=createRecap(options).pendingReplan();
createRecap(options).completeReplan();
const afterComplete=createRecap(options).pendingReplan();
process.stdout.write(JSON.stringify({{afterSave,restored,otherAccount,retainedAfterCancel,afterComplete,keys:[...values.keys()]}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
pending = {
"session_id": "saved-session",
"actual_minutes": {"issue:r:1:": 52},
}
assert output == {
"afterSave": pending,
"restored": pending,
"otherAccount": None,
"retainedAfterCancel": pending,
"afterComplete": None,
"keys": [],
}
def test_recap_controller_removes_malformed_or_unbounded_pending_replan():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-handoff.v1.timmy';
const values=new Map([[key, JSON.stringify({{session_id:'saved',actual_minutes:Object.fromEntries(
Array.from({{length:21}}, (_,index)=>['issue:r:' + index + ':', 10])
)}})]]);
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{pending:recap.pendingReplan(),hasKey:values.has(key)}}));
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
assert json.loads(run.stdout) == {"pending": None, "hasKey": False}
@pytest.mark.anyio
async def test_dashboard_renders_mobile_today_recap_flow():
html = main.FRONTEND_BUILD.dashboard_html
@ -335,3 +390,19 @@ async def test_dashboard_renders_mobile_today_recap_flow():
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard
assert ".today-recap-row { grid-template-columns:1fr; }" in css
@pytest.mark.anyio
async def test_dashboard_resumes_and_explicitly_discards_pending_recap_feedback():
html = main.FRONTEND_BUILD.dashboard_html
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
recap_source = TODAY_RECAP.read_text()
assert 'id="discard-recap-replan"' in html
assert "todayRecapView.pendingReplan()?.actual_minutes" in dashboard
assert "todayRecapView.completeReplan();" in dashboard
assert "todayRecapView.discardReplan();" in dashboard
assert "if (capacityAware && !todayWork.replacePlanning(plan)) return false;" in dashboard
assert dashboard.index("todayWork.replacePlanning(plan)") < dashboard.index("todayRecapView.completeReplan();")
assert "pendingReplan:recap.pendingReplan" in recap_source
assert "discardReplan:recap.discardReplan" in recap_source

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-v88" in source
assert "stackchain-dashboard-shell-v89" in source
assert "BASE + 'static/today-sync.js'" in source