Merge pull request 'Preserve Today recap drafts across offline reloads' (#582)
Closes #581
This commit is contained in:
commit
be7bfcaa5a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user