feat: preserve offline Today recap drafts (Closes #581)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 17:58:50 +00:00
1 parent cab230e3a2
commit 6a3c6c7a9e
4 files changed
+185 -7

No files matched your search

+121
View File
@@ -119,6 +119,125 @@ recap.save().then(saved=>process.stdout.write(JSON.stringify({{
assert output["cleared"] == 1
def test_recap_controller_restores_corrected_draft_with_stable_retry_identity():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const options = {{
save:payload => Promise.resolve(payload), clear:() => {{}}, storage,
getLogin:() => ' Timmy ', makeId:() => 'stable-session',
}};
const first = createRecap(options);
first.begin([{{identity:'issue:r:1:',elapsed_ms:12*60000}}], {{'issue:r:1:':20}});
first.correct('issue:r:1:', 17);
const restored = createRecap({{...options, makeId:() => 'different-session'}}).snapshot();
process.stdout.write(JSON.stringify({{restored, keys:[...values.keys()]}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["restored"] == {
"session_id": "stable-session",
"items": [{"identity": "issue:r:1:", "estimate_minutes": 20, "actual_minutes": 17}],
"estimated_minutes": 20,
"actual_minutes": 17,
"variance_minutes": -3,
}
assert output["keys"] == ["stackchain.today-recap-draft.v1.timmy"]
def test_recap_controller_removes_corrupt_persisted_draft():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-draft.v1.timmy';
const values=new Map([[key, '{{not-json']]);
const storage={{
getItem:key => values.has(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({{draft:recap.snapshot(),hasKey:values.has(key)}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"draft": None, "hasKey": False}
def test_recap_controller_restores_after_account_confirmation():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map([['stackchain.today-recap-draft.v1.timmy', JSON.stringify({{
session_id:'offline-session', items:[{{identity:'issue:r:2:',estimate_minutes:null,actual_minutes:9}}]
}})]]);
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let login='';
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login}});
const before=recap.snapshot(); login='timmy'; const didRestore=recap.restore(); const after=recap.snapshot();
process.stdout.write(JSON.stringify({{before,didRestore,after}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": None,
"didRestore": True,
"after": {
"session_id": "offline-session",
"items": [{"identity": "issue:r:2:", "estimate_minutes": None, "actual_minutes": 9}],
"estimated_minutes": 0,
"actual_minutes": 9,
"variance_minutes": 9,
},
}
def test_recap_controller_never_exposes_draft_after_account_switch():
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 recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login,makeId:()=> 'timmy-session'}});
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
login='alexander';
process.stdout.write(JSON.stringify({{visible:recap.snapshot(),timmyDraft:values.has('stackchain.today-recap-draft.v1.timmy')}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"visible": None, "timmyDraft": True}
def test_recap_controller_keeps_failed_save_and_clears_only_after_confirmation():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-draft.v1.timmy'; const values=new Map(); let cleared=0; let fail=true;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const recap=createRecap({{
save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve(payload),
clear:()=>{{cleared += 1;}}, storage, getLogin:()=> 'timmy', makeId:()=> 'retry-session'
}});
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
(async()=>{{
try {{ await recap.save(); }} catch (_error) {{}}
const afterFailure={{draft:recap.snapshot(),stored:values.has(key),cleared}};
fail=false; await recap.save();
process.stdout.write(JSON.stringify({{afterFailure,afterSuccess:{{draft:recap.snapshot(),stored:values.has(key),cleared}}}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["afterFailure"]["draft"]["session_id"] == "retry-session"
assert output["afterFailure"]["stored"] is True
assert output["afterFailure"]["cleared"] == 0
assert output["afterSuccess"] == {"draft": None, "stored": False, "cleared": 1}
@pytest.mark.anyio
async def test_dashboard_renders_mobile_today_recap_flow():
html = main.FRONTEND_BUILD.dashboard_html
@@ -134,6 +253,8 @@ async def test_dashboard_renders_mobile_today_recap_flow():
assert "isActive: () => workSession.checkpointed()" in dashboard
assert "openTodayRecapAfterSession(view" in recap_source
assert "api/v1/today/recaps" in recap_source
assert "storage:localStorage" in recap_source
assert "() => planningOwnerLogin" in dashboard
assert "recapEntries()" in timer and "clearRecap()" in timer
assert ".today-recap-header button { min-height:44px;" in css
assert ".today-recap-actions button { min-height:44px;" in css