feat: queue offline Today-to-Week moves (Closes #1233)
All checks were successful
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m10s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-21 19:02:30 +00:00
parent c87d56c939
commit d63765a9b1
3 changed files with 130 additions and 33 deletions

View File

@ -132,7 +132,10 @@ explicit **Add blocked item anyway** override, while an unavailable dependency l
as unknown rather than unblocked. Completing any Today item now exposes a 10-second, touch-safe
**Undo** receipt. Undo restores the item's original order and estimate, queues the inverse cross-device
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
concurrently changed plan is reported without overwriting newer work. Starting a Today work session also
concurrently changed plan is reported without overwriting newer work. Rescheduling Today work into
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
without concurrent requests. 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. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. 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 without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning 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 wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.

View File

@ -17,29 +17,47 @@ function createTodayWeekReschedule({
return login ? storagePrefix + encodeURIComponent(login) : '';
}
function persist(record) {
function validRecord(value) {
return typeof value?.body?.operation_id === 'string' &&
Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days);
}
function readQueue() {
const key = storageKey();
if (!key || !storage) return [];
try {
const value = JSON.parse(storage.getItem(key) || 'null');
if (Array.isArray(value?.records)) return value.records.filter(validRecord);
return validRecord(value) ? [value] : [];
} catch (_error) { return []; }
}
function saveQueue(records) {
const key = storageKey();
if (!key || !storage) return false;
try { storage.setItem(key, JSON.stringify(record)); return true; }
try {
if (!records.length) storage.removeItem(key);
else storage.setItem(key, JSON.stringify({version:2, records}));
return true;
}
catch (_error) { return false; }
}
function persist(record) { return saveQueue([...readQueue(), record]); }
function pending() {
const key = storageKey();
if (!key || !storage) return null;
try {
const value = JSON.parse(storage.getItem(key) || 'null');
return typeof value?.body?.operation_id === 'string' &&
Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days) ? value : null;
} catch (_error) { return null; }
return readQueue()[0] || null;
}
function resume() {
const record = pending();
const records = readQueue();
const record = records[records.length - 1];
if (!record) return null;
adoptToday(record.today);
week.adopt(record.week);
return {...record, sync_pending:true};
return {...record, sync_pending:true, pending_count:records.length};
}
async function restoreConflict(error) {
@ -54,18 +72,29 @@ function createTodayWeekReschedule({
function flush() {
if (flushing) return flushing;
const record = pending();
if (!record) return Promise.resolve(false);
const key = storageKey();
flushing = api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
}).then(result => {
adoptToday(result.today);
week.adopt(result.week);
const latest = pending();
if (latest?.body?.operation_id === record.body.operation_id) storage.removeItem(key);
return {...result, sync_pending:false};
}).catch(restoreConflict).finally(() => { flushing = null; });
if (!pending()) return Promise.resolve(false);
flushing = (async () => {
let result = null;
while (true) {
const records = readQueue();
const record = records[0];
if (!record) return result ? {...result, sync_pending:false, pending_count:0} : false;
try {
result = await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
});
} catch (error) { await restoreConflict(error); }
adoptToday(result.today);
week.adopt(result.week);
const latest = readQueue();
if (latest[0]?.body?.operation_id !== record.body.operation_id) continue;
const remaining = latest.slice(1).map((entry, index) => index ? entry : ({
...entry,
body:{...entry.body, today_revision:result.today.revision, week_revision:result.week.revision},
}));
if (!saveQueue(remaining)) throw new Error('Move synced, but its local queue could not be updated.');
}
})().finally(() => { flushing = null; });
return flushing;
}
@ -92,16 +121,22 @@ function createTodayWeekReschedule({
async function open(identity) {
let today;
let offline = false;
try { today = await api('api/v1/today'); }
catch (error) {
if (error?.status === 401 || error?.status === 403) throw error;
today = getToday?.(); offline = true;
const queued = readQueue().length;
let offline = Boolean(queued);
if (queued) {
today = getToday?.();
} else {
try { today = await api('api/v1/today'); }
catch (error) {
if (error?.status === 401 || error?.status === 403) throw error;
today = getToday?.(); offline = true;
}
}
if (!identity || !today?.ids?.includes(identity)) {
throw new Error('The active Today item changed. Reopen rescheduling.');
}
const loaded = await week.load();
const loaded = queued ? week.state?.() : await week.load();
if (!loaded) throw new Error('Reload Today before rescheduling another saved move.');
if (loaded?.sync_pending) {
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
}
@ -168,11 +203,11 @@ function createTodayWeekReschedule({
}
if (current.offline) {
current = null;
return {...optimistic, sync_pending:true};
return {...optimistic, sync_pending:true, pending_count:readQueue().length};
}
let result;
try {
result = await api('api/v1/week/reschedule', {
result = admitted ? await flush() : await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
});
} catch(error) {
@ -185,7 +220,6 @@ function createTodayWeekReschedule({
}
adoptToday(result.today);
week.adopt(result.week);
if (admitted && pending()?.body?.operation_id === body.operation_id) storage.removeItem(storageKey());
current = null;
return result;
}

View File

@ -122,8 +122,9 @@ console.log(JSON.stringify({opened,confirmed,events,keys:[...values.keys()],reco
assert result["events"][2][0] == "week"
assert result["events"][2][1]["days"][0]["ids"] == ["active"]
assert result["keys"] == ["stackchain.today-week-reschedule.v1.timmy"]
assert result["record"]["body"]["operation_id"] == "move-active"
assert result["record"]["queued_at"] == 123
assert result["record"]["version"] == 2
assert result["record"]["records"][0]["body"]["operation_id"] == "move-active"
assert result["record"]["records"][0]["queued_at"] == 123
def test_reschedule_controller_restores_and_delivers_same_operation_after_reload():
@ -175,6 +176,65 @@ console.log(JSON.stringify({result,events,calls,pending:controller.pending()}));
assert result["pending"]["body"]["operation_id"] == "stable-operation"
def test_reschedule_controller_queues_two_offline_moves_without_replacing_the_first():
result = run_controller("""
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 today={revision:4,ids:['active','other'],capacity_minutes:120,estimates:{active:45,other:20}};
let weekState={revision:7,timezone:'UTC',offline_snapshot:true,days:[
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
]};
const confirmedWeek=JSON.parse(JSON.stringify(weekState));let loads=0;
const week={load:async()=>{loads++;weekState=JSON.parse(JSON.stringify(confirmedWeek));return weekState;},state:()=>weekState,review:()=>({days:[
{...weekState.days[0],label:'Tue',planned_minutes:Object.values(weekState.days[0].estimates).reduce((a,b)=>a+b,0)}
]}),adopt:value=>{weekState={...value,offline_snapshot:true};}};
const api=async()=>{const error=new Error('offline');error.status=0;throw error;};
let n=0;
const controller=createReschedule({week,api,getToday:()=>today,adoptToday:value=>{today=value;},
storage,getLogin:()=> 'timmy',operationId:()=> `move-${++n}`,now:()=>100+n});
await controller.open('active');
await controller.confirm('2026-08-25',45);
await controller.open('other');
const confirmed=await controller.confirm('2026-08-25',20);
const stored=JSON.parse([...values.values()][0]);
console.log(JSON.stringify({confirmed,stored,today,weekState,loads}));
""")
assert [entry["body"]["operation_id"] for entry in result["stored"]["records"]] == [
"move-1",
"move-2",
]
assert result["confirmed"]["pending_count"] == 2
assert result["today"]["ids"] == []
assert result["weekState"]["days"][0]["ids"] == ["active", "other"]
assert result["loads"] == 1
def test_reschedule_controller_drains_fifo_and_rebases_each_next_move():
result = run_controller("""
const key='stackchain.today-week-reschedule.v1.timmy';
const make=(id,identity,today,week)=>({queued_at:1,body:{operation_id:id,identity,estimate_minutes:20,
plan_date:'2026-08-25',today_revision:today,week_revision:week,allow_over_capacity:false},
today:{revision:today,ids:[]},week:{revision:week,days:[]}});
const values=new Map([[key,JSON.stringify({version:2,records:[make('move-1','one',4,7),make('move-2','two',4,7)]})]]);
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const requests=[];let active=0,maxActive=0;
const api=async(_url,options)=>{active++;maxActive=Math.max(maxActive,active);
const body=JSON.parse(options.body);requests.push(body);await new Promise(resolve=>setTimeout(resolve,1));active--;
const step=requests.length;return {today:{revision:4+step,ids:[]},week:{revision:7+step,days:[]}};};
const controller=createReschedule({week:{adopt:()=>{}},api,adoptToday:()=>{},storage,getLogin:()=> 'timmy'});
const delivered=await controller.flush();
console.log(JSON.stringify({requests,maxActive,delivered,remaining:values.size}));
""")
assert [request["operation_id"] for request in result["requests"]] == ["move-1", "move-2"]
assert result["requests"][1]["today_revision"] == 5
assert result["requests"][1]["week_revision"] == 8
assert result["maxActive"] == 1
assert result["delivered"]["pending_count"] == 0
assert result["remaining"] == 0
def test_reschedule_controller_restores_server_truth_and_keeps_intent_on_conflict():
result = run_controller("""
const key='stackchain.today-week-reschedule.v1.timmy';