diff --git a/frontend/detail-defer.js b/frontend/detail-defer.js index 943da0c..7712370 100644 --- a/frontend/detail-defer.js +++ b/frontend/detail-defer.js @@ -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(); diff --git a/frontend/index.html b/frontend/index.html index 8b08650..1ff3e70 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -540,7 +540,7 @@ textarea { resize: vertical; min-height: 120px; } Release assignment Close issue Open in Gitea - DeferLater todayTomorrowCancel + DeferLater todayTomorrowCancel @@ -644,7 +644,7 @@ textarea { resize: vertical; min-height: 120px; } Share Mark read & next Open in Gitea - DeferLater todayTomorrowCancel + DeferLater todayTomorrowCancel @@ -684,7 +684,7 @@ textarea { resize: vertical; min-height: 120px; } Share Open in Gitea - DeferLater todayTomorrowCancel + DeferLater todayTomorrowCancel @@ -749,7 +749,7 @@ textarea { resize: vertical; min-height: 120px; } Review history - DeferLater todayTomorrowCancel + DeferLater todayTomorrowCancel Previous @@ -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; } 'Mark read' : ''; const readUpdate = item.has_update && Number.isInteger(item.notification_id) ? 'Read update' : ''; + const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled'; const laterActions = selectedWorkFilter === 'later' ? 'Bring back now' : - 'Later todayTomorrow'; + 'Later todayTomorrow'; const alreadyToday = todayWork.contains(item); const todayPosition = todayWork.position(item); const todayActions = selectedWorkFilter === 'today' ? 'Move upMove downRemove from Today' : - '' + (alreadyToday ? 'Added to Today' : 'Add to Today') + ''; + '' + (alreadyToday ? 'Added to Today' : 'Add to Today') + ''; const planningActions = todayActions + laterActions; if (item.is_review) { return '' + contents + '' + readUpdate + markRead + planningActions + ''; @@ -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() : ''; diff --git a/frontend/later-work.js b/frontend/later-work.js index 71c95a2..ebaacb5 100644 --- a/frontend/later-work.js +++ b/frontend/later-work.js @@ -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) { diff --git a/tests/test_my_work.py b/tests/test_my_work.py index e1e69ae..2a48b47 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -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(): diff --git a/tests/test_today_work.py b/tests/test_today_work.py index 4c5b4da..a4c882f 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -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