Merge pull request 'Preserve Today recap drafts across offline reloads' (#582)
All checks were successful
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s

Closes #581
This commit is contained in:
rockachopa 2026-08-11 18:01:33 +00:00
commit be7bfcaa5a
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
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.
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
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

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

View File

@ -1,35 +1,88 @@
function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID() }) {
let draft = null;
function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
const totals = items => {
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
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 {
restore,
begin(entries, estimates = {}) {
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
identity:String(entry.identity),
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))),
}));
draftKey = storageKey();
draft = { session_id:makeId(), items, ...totals(items) };
persist();
return snapshot();
},
correct(identity, minutes) {
restore();
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
const item = draft.items.find(candidate => candidate.identity === identity);
if (!item) return false;
item.actual_minutes = minutes;
Object.assign(draft, totals(draft.items));
persist();
return true;
},
snapshot,
async save() {
restore();
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 result = await save(payload);
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
return result;
},
@ -83,6 +136,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
};
const open = ({ begin = false } = {}) => {
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
else recap.restore();
render();
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#today-recap-sheet').hidden = false;
@ -128,9 +182,11 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
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 recap = createTodayRecap({ save:saveTodayRecap, clear:() => timer.clearRecap() });
const recap = createTodayRecap({
save:saveTodayRecap, clear:() => timer.clearRecap(), storage:localStorage, getLogin,
});
const view = createTodayRecapView({ ...options, recap });
view.bind();
setInterval(timerView.render, 1000);

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