Release lane
@@ -746,6 +749,7 @@ textarea { resize: vertical; min-height: 120px; }
+
@@ -785,7 +789,7 @@ textarea { resize: vertical; min-height: 120px; }
);
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
function openMobileWork() {
- const counts = countMyWork(lastMyWork);
+ const counts = countMyWork(activeMyWork);
const filter = counts.attention ? 'attention' : 'all';
qs('[data-work-filter="' + filter + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
@@ -826,7 +830,7 @@ textarea { resize: vertical; min-height: 120px; }
let selectedWorkMilestone = 'all';
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
- if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
+ if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
if (savedMilestone) selectedWorkMilestone = savedMilestone;
} catch (e) {
@@ -866,6 +870,19 @@ textarea { resize: vertical; min-height: 120px; }
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let activeFlushLogin = '';
+ let activeMyWork = [];
+ let laterMyWork = [];
+ const laterWork = createLaterWork({
+ storage: localStorage,
+ getLogin: () => confirmedOwnerLogin,
+ onWake: () => {
+ qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
+ refreshMyWorkView();
+ },
+ });
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) refreshMyWorkView();
+ });
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
@@ -1214,7 +1231,7 @@ textarea { resize: vertical; min-height: 120px; }
}
const workSession = createWorkSession({
- getItems: () => lastMyWork,
+ getItems: () => activeMyWork,
getFilter: () => selectedWorkFilter,
getMilestone: () => selectedWorkMilestone,
onOpen: openWorkSessionItem,
@@ -1309,7 +1326,13 @@ textarea { resize: vertical; min-height: 120px; }
function refreshMyWorkView() {
lastDrafts = draftInbox.list();
- const counts = countMyWork(lastMyWork);
+ const partitioned = laterWork.partition(lastMyWork, {
+ pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
+ });
+ activeMyWork = partitioned.active;
+ laterMyWork = partitioned.later;
+ const counts = countMyWork(activeMyWork);
+ counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
Object.entries(counts).forEach(([filter, count]) => {
const element = qs('[data-work-count="' + filter + '"]');
@@ -1332,7 +1355,8 @@ textarea { resize: vertical; min-height: 120px; }
}
qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ?
- summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
+ summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') :
+ 'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
if (workSession.active()) workSession.reconcile();
@@ -1452,7 +1476,9 @@ textarea { resize: vertical; min-height: 120px; }
qs('#load-more-notifications').hidden = true;
return;
}
- const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
+ const visible = selectedWorkFilter === 'later' ?
+ filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
+ filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
@@ -1465,24 +1491,28 @@ textarea { resize: vertical; min-height: 120px; }
(item.milestone?.title ? ' ' + escapeHtml(item.milestone.title) + ' ' : '') +
(item.due_label ? ' ' + escapeHtml(item.due_label) + ' ' : '') +
(item.has_update ? ' Unread update ' : '') +
+ (item.deferred_until ? 'Deferred until ' + escapeHtml(fmt(item.deferred_until)) + ' ' : '') +
(item.updated_at ? ' · Updated ' + escapeHtml(fmt(item.updated_at)) + ' ' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
'Mark read ' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
'Read update ' : '';
+ const laterActions = selectedWorkFilter === 'later' ?
+ 'Bring back now
' :
+ 'Later today Tomorrow
';
if (item.is_review) {
- return '' + contents + ' ' + readUpdate + markRead + ' ';
+ return '' + contents + ' ' + readUpdate + markRead + laterActions + ' ';
}
if (item.kind === 'issue') {
- return '' + contents + ' ' + readUpdate + markRead + ' ';
+ return '' + contents + ' ' + readUpdate + markRead + laterActions + ' ';
}
if (item.kind === 'pull') {
- return '' + contents + ' ' + readUpdate + markRead + ' ';
+ return '' + contents + ' ' + readUpdate + markRead + laterActions + ' ';
}
- return '' + contents + ' ' + markRead + ' ';
+ return '' + contents + ' ' + markRead + laterActions + ' ';
}).join('') : '' + (incomplete ?
'More work is available. Load the next page.' :
- 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')))) + '.') + '
';
+ 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '';
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
@@ -1514,6 +1544,22 @@ 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.';
+ refreshMyWorkView();
+ });
+ });
+ document.querySelectorAll('[data-later-restore]').forEach(button => {
+ button.addEventListener('click', () => {
+ const item = lastMyWork[Number(button.dataset.workIndex)];
+ if (!item || !laterWork.restore(item)) return;
+ qs('#my-work-action-status').textContent = 'Work returned to its priority position.';
+ refreshMyWorkView();
+ });
+ });
const allIds = notificationIds(visible);
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
const bulkBar = qs('#bulk-mark-read-bar');
diff --git a/frontend/later-work.js b/frontend/later-work.js
new file mode 100644
index 0000000..71c95a2
--- /dev/null
+++ b/frontend/later-work.js
@@ -0,0 +1,113 @@
+function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
+ const prefix = 'stackchain.later-work.v1.';
+ let timer = null;
+
+ function identity(item) {
+ if (!item) return '';
+ const kind = item.is_review ? 'review' : (item.kind || 'work');
+ const number = Number.isInteger(item.number) ? item.number : '';
+ const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
+ return [kind, item.repository || '', number, notification].join(':');
+ }
+
+ function storageKey() {
+ const login = String(getLogin?.() || '').trim().toLowerCase();
+ return login ? prefix + encodeURIComponent(login) : '';
+ }
+
+ function read() {
+ const key = storageKey();
+ if (!key || !storage) return {};
+ try {
+ const value = JSON.parse(storage.getItem(key) || '{}');
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ } catch (_error) {
+ return {};
+ }
+ }
+
+ function write(records) {
+ const key = storageKey();
+ if (!key || !storage) return false;
+ try {
+ if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
+ else storage.removeItem(key);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+ }
+
+ function defer(item, until) {
+ const key = storageKey();
+ const id = identity(item);
+ const wake = new Date(until);
+ if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
+ const records = read();
+ records[id] = wake.toISOString();
+ write(records);
+ return true;
+ }
+
+ function presetUntil(preset) {
+ const current = new Date(now());
+ if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
+ if (preset === 'tomorrow') {
+ const wake = new Date(current);
+ wake.setDate(wake.getDate() + 1);
+ wake.setHours(9, 0, 0, 0);
+ return wake;
+ }
+ return new Date(NaN);
+ }
+
+ function restore(item) {
+ const id = identity(item);
+ const records = read();
+ if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
+ delete records[id];
+ write(records);
+ return true;
+ }
+
+ function schedule(wakeTimes, current) {
+ if (timer !== null) clearTimer(timer);
+ timer = null;
+ if (!wakeTimes.length) return;
+ const delay = Math.max(0, Math.min(...wakeTimes) - current);
+ timer = setTimer(() => {
+ timer = null;
+ onWake();
+ }, Math.min(delay, 2147483647));
+ }
+
+ function partition(items, { pruneMissing = true } = {}) {
+ const current = now().getTime();
+ const records = read();
+ const available = new Map((items || []).map(item => [identity(item), item]));
+ const retained = {};
+ const wakeTimes = [];
+
+ Object.entries(records).forEach(([id, wake]) => {
+ const wakeTime = new Date(wake).getTime();
+ if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
+ if (pruneMissing && !available.has(id)) return;
+ retained[id] = new Date(wakeTime).toISOString();
+ wakeTimes.push(wakeTime);
+ });
+
+ if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
+ schedule(wakeTimes, current);
+ const deferredIds = new Set(Object.keys(retained));
+ const active = (items || []).filter(item => !deferredIds.has(identity(item)));
+ const later = (items || []).flatMap(item => {
+ const wake = retained[identity(item)];
+ return wake ? [{ ...item, deferred_until: wake }] : [];
+ });
+ return { active, later };
+ }
+
+ return { identity, defer, restore, presetUntil, partition };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 2d0124b..703b0f9 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v13';
+const CACHE = 'stackchain-dashboard-shell-v14';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -17,6 +17,7 @@ const SHELL = [
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
+ BASE + 'static/later-work.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-sheet.js',
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 6392468..eb5d70a 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -1,4 +1,5 @@
import json
+import os
import subprocess
from pathlib import Path
@@ -8,6 +9,7 @@ from src.views import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
+LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
@@ -677,6 +679,185 @@ process.stdout.write(JSON.stringify({{
}
+def test_later_queue_defers_work_locally_and_scopes_it_to_confirmed_login():
+ script = f"""
+const createLaterWork = require({json.dumps(str(LATER_WORK))});
+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 login = 'timmy';
+const item = {{kind:'pull',is_review:true,repository:'stackchain/api',number:17,title:'Review me'}};
+const store = createLaterWork({{storage,getLogin:() => login,now:() => new Date('2026-08-08T12:00:00Z')}});
+const deferred = store.defer(item, new Date('2026-08-08T16:00:00Z'));
+const timmy = store.partition([item]);
+login = 'alexander';
+const alexander = store.partition([item]);
+process.stdout.write(JSON.stringify({{
+ deferred,
+ timmy:{{active:timmy.active.length,later:timmy.later}},
+ alexander:{{active:alexander.active.length,later:alexander.later.length}},
+ keys:Array.from(values.keys()),
+}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+
+ assert json.loads(result.stdout) == {
+ "deferred": True,
+ "timmy": {
+ "active": 0,
+ "later": [{
+ "kind": "pull", "is_review": True, "repository": "stackchain/api",
+ "number": 17, "title": "Review me", "deferred_until": "2026-08-08T16:00:00.000Z",
+ }],
+ },
+ "alexander": {"active": 1, "later": 0},
+ "keys": ["stackchain.later-work.v1.timmy"],
+ }
+
+
+def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
+ script = f"""
+const createLaterWork = require({json.dumps(str(LATER_WORK))});
+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 clock = new Date('2026-08-08T12:00:00Z');
+let scheduled = null;
+let wakes = 0;
+const store = createLaterWork({{
+ storage, getLogin:() => 'timmy', now:() => clock,
+ setTimer:(callback, delay) => {{ scheduled = {{callback,delay}}; return 7; }},
+ clearTimer:() => {{}}, onWake:() => {{ wakes += 1; }},
+}});
+const kept = {{kind:'issue',repository:'stackchain/api',number:17,title:'Kept'}};
+const gone = {{kind:'issue',repository:'stackchain/api',number:18,title:'Gone'}};
+store.defer(kept, new Date('2026-08-08T13:00:00Z'));
+store.defer(gone, new Date('2026-08-09T13:00:00Z'));
+const before = store.partition([kept]);
+const persisted = JSON.parse(values.get('stackchain.later-work.v1.timmy'));
+clock = new Date('2026-08-08T13:00:01Z');
+scheduled.callback();
+const after = store.partition([kept]);
+process.stdout.write(JSON.stringify({{
+ before:{{active:before.active.length,later:before.later.length}},
+ persisted:Object.keys(persisted), delay:scheduled.delay, wakes,
+ after:{{active:after.active.map(item => item.title),later:after.later.length}},
+ storageEmpty:!values.has('stackchain.later-work.v1.timmy'),
+}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+
+ assert json.loads(result.stdout) == {
+ "before": {"active": 0, "later": 1},
+ "persisted": ["issue:stackchain/api:17:"],
+ "delay": 3600000,
+ "wakes": 1,
+ "after": {"active": ["Kept"], "later": 0},
+ "storageEmpty": True,
+ }
+
+
+def test_later_queue_preserves_rank_and_retains_unloaded_paginated_work():
+ script = f"""
+const createLaterWork = require({json.dumps(str(LATER_WORK))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.get(key) || null,
+ setItem:(key,value) => values.set(key,value),
+ removeItem:key => values.delete(key),
+}};
+const store = createLaterWork({{
+ storage,getLogin:() => 'timmy',now:() => new Date('2026-08-08T12:00:00Z'),
+ setTimer:() => 1, clearTimer:() => {{}},
+}});
+const first = {{kind:'issue',repository:'stackchain/api',number:1,title:'Higher priority'}};
+const second = {{kind:'pull',repository:'stackchain/api',number:2,title:'Lower priority'}};
+store.defer(second, new Date('2026-08-09T12:00:00Z'));
+store.defer(first, new Date('2026-08-09T12:00:00Z'));
+store.partition([first], {{pruneMissing:false}});
+const visible = store.partition([first, second], {{pruneMissing:false}});
+process.stdout.write(JSON.stringify({{
+ titles:visible.later.map(item => item.title),
+ records:Object.keys(JSON.parse(values.get('stackchain.later-work.v1.timmy'))).length,
+}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+
+ assert json.loads(result.stdout) == {
+ "titles": ["Higher priority", "Lower priority"],
+ "records": 2,
+ }
+
+
+def test_later_queue_presets_and_bring_back_now_preserve_work_identity():
+ script = f"""
+const createLaterWork = require({json.dumps(str(LATER_WORK))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.get(key) || null,
+ setItem:(key,value) => values.set(key,value),
+ removeItem:key => values.delete(key),
+}};
+const now = new Date('2026-08-08T12:00:00Z');
+const store = createLaterWork({{storage,getLogin:() => 'timmy',now:() => now}});
+const item = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
+const today = store.presetUntil('today');
+const tomorrow = store.presetUntil('tomorrow');
+store.defer(item, tomorrow);
+const removed = store.restore(item);
+process.stdout.write(JSON.stringify({{
+ today:today.toISOString(), tomorrow:tomorrow.toISOString(), removed,
+ partition:store.partition([item]),
+}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True,
+ env={**os.environ, "TZ": "UTC"},
+ )
+
+ assert json.loads(result.stdout) == {
+ "today": "2026-08-08T16:00:00.000Z",
+ "tomorrow": "2026-08-09T09:00:00.000Z",
+ "removed": True,
+ "partition": {"active": [{
+ "kind": "update", "repository": "stackchain/api", "number": 17,
+ "notification_id": 91,
+ }], "later": []},
+ }
+
+
+@pytest.mark.anyio
+async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
+ html = await dashboard()
+
+ assert '' in html
+ 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 '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 '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
+
+
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
payload = {
"issues": [
@@ -2676,7 +2857,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
- assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
+ assert 'data-work-count="later"' in html
+ assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 814da15..b4289dc 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -73,7 +73,7 @@ async function dispatchSync(tag) {{
def test_background_authored_sync_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v13" in source
+ assert "stackchain-dashboard-shell-v14" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
@@ -113,6 +113,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
+ "/dashboard/static/later-work.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/issue-sheet.js",