fix: keep retained mobile planning available (#289)
All checks were successful
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy 2026-08-08 10:20:42 +00:00
parent 80417abd5b
commit 8c4e729c4a
5 changed files with 121 additions and 20 deletions

View File

@ -11,7 +11,11 @@ function createDetailDefer({
defer(item, preset) {
if (!item) return false;
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();
if (!inSession) close();
refresh();

View File

@ -540,7 +540,7 @@ textarea { resize: vertical; min-height: 120px; }
<button id="release-issue" type="button">Release assignment</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>
<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>
<nav class="work-session-nav" aria-label="Work session" hidden>
<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 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>
<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>
<nav class="work-session-nav" aria-label="Work session" hidden>
<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">
<button class="share-work-route" type="button">Share</button>
<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>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
@ -749,7 +749,7 @@ textarea { resize: vertical; min-height: 120px; }
</section>
<h2>Review history</h2>
<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>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
@ -902,17 +902,18 @@ textarea { resize: vertical; min-height: 120px; }
let reviewHandoffPending = false;
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let activeFlushLogin = '';
let activeMyWork = [];
let laterMyWork = [];
let todayMyWork = [];
const todayWork = createTodayWork({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
getLogin: () => planningOwnerLogin,
});
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
getLogin: () => planningOwnerLogin,
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
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) {
liveMode = 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>' : '';
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>' : '';
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
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" 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 todayPosition = todayWork.position(item);
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"><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;
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>';
@ -1629,8 +1638,17 @@ textarea { resize: vertical; min-height: 120px; }
document.querySelectorAll('[data-later-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item || !laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))) return;
qs('#my-work-action-status').textContent = 'Deferred work stays unread and unchanged in Gitea.';
if (!item) return;
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();
});
});
@ -1644,10 +1662,15 @@ textarea { resize: vertical; min-height: 120px; }
});
document.querySelectorAll('[data-today-add]').forEach(button => {
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)]);
qs('#my-work-action-status').textContent = result === 'full' ?
'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();
});
});
@ -2486,6 +2509,10 @@ textarea { resize: vertical; min-height: 120px; }
}
if (snapshot.context && workChanged) {
setOfflineWorkMode(false);
const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
updatePlanningAvailability();
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
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 id = identity(item);
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();
records[id] = wake.toISOString();
write(records);
return true;
return write(records) ? 'deferred' : 'unavailable';
}
function presetUntil(preset) {

View File

@ -825,7 +825,7 @@ process.stdout.write(JSON.stringify({{
)
assert json.loads(result.stdout) == {
"deferred": True,
"deferred": "deferred",
"timmy": {
"active": 0,
"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():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
@ -964,7 +991,7 @@ let sessionActive = false;
const controller = createDetailDefer({{
laterWork: {{
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 }},
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
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
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-count="later"' 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 'data-later-preset="today"' in html
assert 'data-later-preset="tomorrow"' in html
assert 'data-later-restore' 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 '.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():

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 'Today is limited to 5 items' 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