Preserve Today recap drafts across offline reloads #582

Merged
rockachopa merged 1 commits from timmy/581-offline-recap-draft into main 2026-08-11 18:01:34 +00:00
4 changed files with 185 additions and 7 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 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. 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. 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 resulting 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); 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 **Comment & next** on that current issue or pull request posts the handoff online or admits it

View File

@ -936,7 +936,8 @@
formatEstimate: formatPlanMinutes, formatEstimate: formatPlanMinutes,
}); });
const todayRecapView = setupTodayRecap( const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin
); );
function updateDetailDeferLabels(active) { function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => { document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {

View File

@ -1,35 +1,88 @@
function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID() }) { function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
let draft = null;
const totals = items => { const totals = items => {
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0); const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0); const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated }; return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
}; };
const snapshot = () => draft ? { ...draft, items:draft.items.map(item => ({...item})) } : null; const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-recap-draft.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;
return saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
item.identity.length <= 500 && (item.estimate_minutes === null ||
(Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
};
const load = () => {
const key = storageKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!validStoredDraft(saved)) {
if (saved !== null) storage.removeItem(key);
return null;
}
return { ...saved, ...totals(saved.items) };
}
catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
let draftKey = storageKey();
let draft = load();
const persist = () => {
const key = storageKey();
if (!storage || !key || !draft) return;
storage.setItem(key, JSON.stringify({ session_id:draft.session_id, items:draft.items }));
};
const restore = () => {
const key = storageKey();
if (key !== draftKey) {
draft = null;
draftKey = key;
}
if (!draft) draft = load();
return Boolean(draft);
};
const snapshot = () => {
restore();
return draft ? { ...draft, items:draft.items.map(item => ({...item})) } : null;
};
return { return {
restore,
begin(entries, estimates = {}) { begin(entries, estimates = {}) {
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({ const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
identity:String(entry.identity), identity:String(entry.identity),
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null, estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))), actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
})); }));
draftKey = storageKey();
draft = { session_id:makeId(), items, ...totals(items) }; draft = { session_id:makeId(), items, ...totals(items) };
persist();
return snapshot(); return snapshot();
}, },
correct(identity, minutes) { correct(identity, minutes) {
restore();
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false; if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
const item = draft.items.find(candidate => candidate.identity === identity); const item = draft.items.find(candidate => candidate.identity === identity);
if (!item) return false; if (!item) return false;
item.actual_minutes = minutes; item.actual_minutes = minutes;
Object.assign(draft, totals(draft.items)); Object.assign(draft, totals(draft.items));
persist();
return true; return true;
}, },
snapshot, snapshot,
async save() { async save() {
restore();
if (!draft?.items.length) throw new Error('No timed work to save.'); if (!draft?.items.length) throw new Error('No timed work to save.');
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) }; const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
const result = await save(payload); const result = await save(payload);
clear(); clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null; draft = null;
return result; return result;
}, },
@ -83,6 +136,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
}; };
const open = ({ begin = false } = {}) => { const open = ({ begin = false } = {}) => {
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates); if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
else recap.restore();
render(); render();
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length; qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#today-recap-sheet').hidden = false; qs('#today-recap-sheet').hidden = false;
@ -128,9 +182,11 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
else qs('#start-work-session').focus(); else qs('#start-work-session').focus();
} }
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions) { function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml }; const options = { timer, timerView, todayWork, api, qs, escapeHtml };
const recap = createTodayRecap({ save:saveTodayRecap, clear:() => timer.clearRecap() }); const recap = createTodayRecap({
save:saveTodayRecap, clear:() => timer.clearRecap(), storage:localStorage, getLogin,
});
const view = createTodayRecapView({ ...options, recap }); const view = createTodayRecapView({ ...options, recap });
view.bind(); view.bind();
setInterval(timerView.render, 1000); setInterval(timerView.render, 1000);

View File

@ -119,6 +119,125 @@ recap.save().then(saved=>process.stdout.write(JSON.stringify({{
assert output["cleared"] == 1 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 @pytest.mark.anyio
async def test_dashboard_renders_mobile_today_recap_flow(): async def test_dashboard_renders_mobile_today_recap_flow():
html = main.FRONTEND_BUILD.dashboard_html 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 "isActive: () => workSession.checkpointed()" in dashboard
assert "openTodayRecapAfterSession(view" in recap_source assert "openTodayRecapAfterSession(view" in recap_source
assert "api/v1/today/recaps" 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 "recapEntries()" in timer and "clearRecap()" in timer
assert ".today-recap-header button { min-height:44px;" in css assert ".today-recap-header button { min-height:44px;" in css
assert ".today-recap-actions button { min-height:44px;" in css assert ".today-recap-actions button { min-height:44px;" in css