Keep retained mobile planning available through refresh outages #290

Merged
timmy merged 1 commits from timmy/289-retained-planning into main 2026-08-08 10:22:07 +00:00
5 changed files with 121 additions and 20 deletions

View File

@ -11,7 +11,11 @@ function createDetailDefer({
defer(item, preset) { defer(item, preset) {
if (!item) return false; if (!item) return false;
const until = laterWork.presetUntil(preset); const until = laterWork.presetUntil(preset);
if (!laterWork.defer(item, until)) return false; const result = laterWork.defer(item, until);
if (result !== 'deferred') {
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
return false;
}
const inSession = session.active(); const inSession = session.active();
if (!inSession) close(); if (!inSession) close();
refresh(); refresh();

View File

@ -540,7 +540,7 @@ textarea { resize: vertical; min-height: 120px; }
<button id="release-issue" type="button">Release assignment</button> <button id="release-issue" type="button">Release assignment</button>
<button id="close-issue" type="button">Close issue</button> <button id="close-issue" type="button">Close issue</button>
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a> <a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details> <details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div> </div>
<nav class="work-session-nav" aria-label="Work session" hidden> <nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span> <span class="small" aria-live="polite" data-work-session-progress></span>
@ -644,7 +644,7 @@ textarea { resize: vertical; min-height: 120px; }
<button class="share-work-route" type="button">Share</button> <button class="share-work-route" type="button">Share</button>
<button id="mark-update-read-next" type="button">Mark read &amp; next</button> <button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a> <a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details> <details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div> </div>
<nav class="work-session-nav" aria-label="Work session" hidden> <nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span> <span class="small" aria-live="polite" data-work-session-progress></span>
@ -684,7 +684,7 @@ textarea { resize: vertical; min-height: 120px; }
<div class="pull-sheet-actions"> <div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button> <button class="share-work-route" type="button">Share</button>
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a> <a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details> <details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div> </div>
<nav class="work-session-nav" aria-label="Work session" hidden> <nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span> <span class="small" aria-live="polite" data-work-session-progress></span>
@ -749,7 +749,7 @@ textarea { resize: vertical; min-height: 120px; }
</section> </section>
<h2>Review history</h2> <h2>Review history</h2>
<div id="review-history" class="muted"></div> <div id="review-history" class="muted"></div>
<div class="review-sheet-actions"><details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details></div> <div class="review-sheet-actions"><details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details></div>
<nav class="work-session-nav" aria-label="Work session" hidden> <nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span> <span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button> <button type="button" data-work-session-previous>Previous</button>
@ -902,17 +902,18 @@ textarea { resize: vertical; min-height: 120px; }
let reviewHandoffPending = false; let reviewHandoffPending = false;
let editingOutboxId = null; let editingOutboxId = null;
let confirmedOwnerLogin = ''; let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let activeFlushLogin = ''; let activeFlushLogin = '';
let activeMyWork = []; let activeMyWork = [];
let laterMyWork = []; let laterMyWork = [];
let todayMyWork = []; let todayMyWork = [];
const todayWork = createTodayWork({ const todayWork = createTodayWork({
storage: localStorage, storage: localStorage,
getLogin: () => confirmedOwnerLogin, getLogin: () => planningOwnerLogin,
}); });
const laterWork = createLaterWork({ const laterWork = createLaterWork({
storage: localStorage, storage: localStorage,
getLogin: () => confirmedOwnerLogin, getLogin: () => planningOwnerLogin,
onWake: () => { onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.'; qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView(); refreshMyWorkView();
@ -1325,6 +1326,13 @@ textarea { resize: vertical; min-height: 120px; }
}); });
}); });
function updatePlanningAvailability() {
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
button.disabled = !planningOwnerLogin;
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
});
}
function renderContextSnapshot(data) { function renderContextSnapshot(data) {
liveMode = true; liveMode = true;
hasContextSnapshot = true; hasContextSnapshot = true;
@ -1573,14 +1581,15 @@ textarea { resize: vertical; min-height: 120px; }
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : ''; '<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ? const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : ''; '<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
const laterActions = selectedWorkFilter === 'later' ? const laterActions = selectedWorkFilter === 'later' ?
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' : '<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '">Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '">Tomorrow</button></div>'; '<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button></div>';
const alreadyToday = todayWork.contains(item); const alreadyToday = todayWork.contains(item);
const todayPosition = todayWork.position(item); const todayPosition = todayWork.position(item);
const todayActions = selectedWorkFilter === 'today' ? const todayActions = selectedWorkFilter === 'today' ?
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' : '<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : '') + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>'; '<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
const planningActions = todayActions + laterActions; const planningActions = todayActions + laterActions;
if (item.is_review) { if (item.is_review) {
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>'; return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
@ -1629,8 +1638,17 @@ textarea { resize: vertical; min-height: 120px; }
document.querySelectorAll('[data-later-preset]').forEach(button => { document.querySelectorAll('[data-later-preset]').forEach(button => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)]; const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item || !laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))) return; if (!item) return;
qs('#my-work-action-status').textContent = 'Deferred work stays unread and unchanged in Gitea.'; if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
const until = laterWork.presetUntil(button.dataset.laterPreset);
const result = laterWork.defer(item, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
if (result !== 'deferred') return;
refreshMyWorkView(); refreshMyWorkView();
}); });
}); });
@ -1644,10 +1662,15 @@ textarea { resize: vertical; min-height: 120px; }
}); });
document.querySelectorAll('[data-today-add]').forEach(button => { document.querySelectorAll('[data-today-add]').forEach(button => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]); const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]);
qs('#my-work-action-status').textContent = result === 'full' ? qs('#my-work-action-status').textContent = result === 'full' ?
'Today is limited to 5 items. Remove one before adding more.' : 'Today is limited to 5 items. Remove one before adding more.' :
(result === 'added' ? 'Added to Today without changing Gitea.' : 'This item is already in Today.'); (result === 'added' ? 'Added to Today without changing Gitea.' :
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
refreshMyWorkView(); refreshMyWorkView();
}); });
}); });
@ -2486,6 +2509,10 @@ textarea { resize: vertical; min-height: 120px; }
} }
if (snapshot.context && workChanged) { if (snapshot.context && workChanged) {
setOfflineWorkMode(false); setOfflineWorkMode(false);
const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
updatePlanningAvailability();
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale && const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating; !contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';

View File

@ -42,11 +42,11 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const key = storageKey(); const key = storageKey();
const id = identity(item); const id = identity(item);
const wake = new Date(until); const wake = new Date(until);
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false; if (!key) return 'unavailable';
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read(); const records = read();
records[id] = wake.toISOString(); records[id] = wake.toISOString();
write(records); return write(records) ? 'deferred' : 'unavailable';
return true;
} }
function presetUntil(preset) { function presetUntil(preset) {

View File

@ -825,7 +825,7 @@ process.stdout.write(JSON.stringify({{
) )
assert json.loads(result.stdout) == { assert json.loads(result.stdout) == {
"deferred": True, "deferred": "deferred",
"timmy": { "timmy": {
"active": 0, "active": 0,
"later": [{ "later": [{
@ -838,6 +838,33 @@ process.stdout.write(JSON.stringify({{
} }
def test_later_queue_reports_invalid_identity_and_failed_storage_without_false_success():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
let login = '';
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
const unavailable = createLaterWork({{
storage: {{getItem:() => null,setItem:() => {{ throw new Error('quota'); }},removeItem:() => {{}}}},
getLogin:() => login, now:() => new Date('2026-08-08T12:00:00Z'),
}});
const noIdentity = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
login = 'timmy';
const invalid = unavailable.defer(item, new Date('2026-08-08T11:00:00Z'));
const failedWrite = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"noIdentity": "unavailable",
"invalid": "invalid",
"failedWrite": "unavailable",
}
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload(): def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
script = f""" script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))}); const createLaterWork = require({json.dumps(str(LATER_WORK))});
@ -964,7 +991,7 @@ let sessionActive = false;
const controller = createDetailDefer({{ const controller = createDetailDefer({{
laterWork: {{ laterWork: {{
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'), presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return true; }}, defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return 'deferred'; }},
}}, }},
session: {{ active:() => sessionActive }}, session: {{ active:() => sessionActive }},
close:() => calls.push(['close']), close:() => calls.push(['close']),
@ -1000,6 +1027,32 @@ process.stdout.write(JSON.stringify({{outside,session,calls}}));
} }
def test_detail_defer_reports_storage_failure_without_closing_or_refreshing():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
const controller = createDetailDefer({{
laterWork: {{
presetUntil:() => new Date('2026-08-08T16:00:00Z'),
defer:() => 'unavailable',
}},
session: {{active:() => false}},
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
focus:() => calls.push('focus'), announce:message => calls.push(message),
}});
const saved = controller.defer({{kind:'issue',repository:'stackchain/api',number:17}}, 'today');
process.stdout.write(JSON.stringify({{saved,calls}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"saved": False,
"calls": ["Could not save Later on this device."],
}
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation(): async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
html = await dashboard() html = await dashboard()
@ -1023,16 +1076,16 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
assert 'data-work-filter="later"' in html assert 'data-work-filter="later"' in html
assert 'data-work-count="later"' in html assert 'data-work-count="later"' in html
assert 'const laterWork = createLaterWork({' in html assert 'const laterWork = createLaterWork({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html assert 'getLogin: () => planningOwnerLogin' in html
assert 'laterWork.partition(lastMyWork,' in html assert 'laterWork.partition(lastMyWork,' in html
assert 'data-later-preset="today"' in html assert 'data-later-preset="today"' in html
assert 'data-later-preset="tomorrow"' in html assert 'data-later-preset="tomorrow"' in html
assert 'data-later-restore' in html assert 'data-later-restore' in html
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
assert "laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))" in html assert "const result = laterWork.defer(item, until)" in html
assert 'laterWork.restore(item)' in html assert 'laterWork.restore(item)' in html
assert '.later-actions button { min-height:44px;' in html assert '.later-actions button { min-height:44px;' in html
assert 'Deferred work stays unread and unchanged in Gitea.' in html assert "'Deferred until ' + fmt(until)" in html
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot(): def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():

View File

@ -94,3 +94,20 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert 'data-today-move="down"' in html assert 'data-today-move="down"' in html
assert 'Today is limited to 5 items' in html assert 'Today is limited to 5 items' in html
assert '.today-actions button' in html and 'min-height:44px' in html assert '.today-actions button' in html and 'min-height:44px' in html
@pytest.mark.anyio
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
html = await dashboard()
assert "let planningOwnerLogin = '';" in html
assert html.count("getLogin: () => planningOwnerLogin") == 2
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
assert "planningOwnerLogin = retainedPlanningLogin;" in html
assert "button.disabled = !planningOwnerLogin;" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
assert "Planning is unavailable until your operator identity is restored." in html
assert "Could not save Today on this device." in html
assert "Could not save Later on this device." in html
assert "data-planning-disabled" in html