feat: queue offline Today-to-Week moves (Closes #1233)
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 committed 2026-08-21 19:02:30 +00:00
1 parent c87d56c939
commit d63765a9b1
3 files changed
+130 -33

No files matched your search

+64 -30
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;
}