Open saved Today details and queue replies offline #346

Merged
timmy merged 1 commits from timmy/345-offline-today-details into main 2026-08-08 22:04:57 +00:00
10 changed files with 214 additions and 21 deletions

View File

@ -209,11 +209,15 @@ dashboard keeps polling until it can restore its live snapshot automatically.
Users can explicitly enable **Keep My Work available offline**. Each healthy live Users can explicitly enable **Keep My Work available offline**. Each healthy live
refresh then stores a seven-day, versioned snapshot containing only the signed-in refresh then stores a seven-day, versioned snapshot containing only the signed-in
user identity and queue-card metadata for issues, pull requests, unread updates, user identity and queue-card metadata for issues, pull requests, unread updates,
and pagination totals. Bodies, comments, diffs, credentials, repository catalogs, and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
events, and complete API responses are excluded. A cold offline launch labels the newest 20 comments. This account-bound cache is limited to ten records; diffs,
saved time and renders this snapshot read-only; opening live details, pagination, credentials, repository catalogs, events, and complete API responses are excluded.
and server mutations remain disabled until reconnection. **Clear offline work A cold offline launch labels the saved time. Cached Today details open in the existing
data** deletes the snapshot, and opting out deletes it automatically. phone sheet, where comments can enter the account-bound durable outbox; planning,
assignment, review, merge, and close controls remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline
work data** deletes both stores, and opting out or seven-day expiry deletes them
automatically.
API responses and mutations are never cached by the service worker. New issue captures, API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local issue comments, pull-request comments, and unread-update replies use bounded local

View File

@ -467,7 +467,22 @@
function openRoutedWork(item, trigger, options = {}) { function openRoutedWork(item, trigger, options = {}) {
if (!item) return; if (!item) return;
if (offlineWorkMode) { if (offlineWorkMode) {
qs('#my-work-action-status').textContent = 'This saved item is read-only. Reconnect to open live details.'; const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
if (!todayWork.contains(item) || !savedDetail) {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
return;
}
if (item.kind === 'issue') {
issueTrigger = trigger;
openIssueSheet(item, trigger, savedDetail);
} else if (item.kind === 'pull' && !item.is_review) {
pullTrigger = trigger;
openPullSheet(item, trigger, savedDetail);
} else {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
}
return; return;
} }
if (item.has_update && item.kind === 'update') updateTrigger = trigger; if (item.has_update && item.kind === 'update') updateTrigger = trigger;
@ -598,6 +613,27 @@
}); });
} }
function setOfflineDetailControls(kind) {
const selectors = kind === 'issue' ? [
'#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff',
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
] : [
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
];
selectors.forEach(selector => {
const control = qs(selector);
if (control) control.disabled = true;
});
if (kind === 'issue') {
qs('#issue-planning').inert = true;
qs('#issue-handoff').inert = true;
} else {
qs('#pull-review').inert = true;
}
}
function renderContextSnapshot(data) { function renderContextSnapshot(data) {
liveMode = true; liveMode = true;
hasContextSnapshot = true; hasContextSnapshot = true;
@ -1135,8 +1171,10 @@
} }
} }
async function openIssueSheet(item, trigger) { async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return; if (!item) return;
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item; selectedIssue = item;
selectedIssueDetail = null; selectedIssueDetail = null;
issueConversation = null; issueConversation = null;
@ -1182,7 +1220,7 @@
qs('#close-issue').disabled = false; qs('#close-issue').disabled = false;
qs('#close-issue-sheet').focus(); qs('#close-issue-sheet').focus();
try { try {
const detail = await issueController.load(item); const detail = offlineDetail || await issueController.load(item);
if (selectedIssue !== item) return; if (selectedIssue !== item) return;
selectedIssueDetail = detail; selectedIssueDetail = detail;
issueConversation = issueController.conversation(item, detail.conversation); issueConversation = issueController.conversation(item, detail.conversation);
@ -1204,7 +1242,14 @@
qs('#clear-issue-due-date').disabled = !detail.due_date; qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = detail.due_date ? qs('#issue-due-status').textContent = detail.due_date ?
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.'; 'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
if (qs('#issue-planning').open) loadIssuePlanning(); if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();
if (offlineDetail) {
setOfflineDetailControls('issue');
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) { } catch (error) {
if (selectedIssue !== item) return; if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.'; qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
@ -1297,8 +1342,9 @@
} }
} }
async function openPullSheet(item, trigger) { async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return; if (!item) return;
qs('#pull-review').inert = false;
selectedPull = item; selectedPull = item;
pullTrigger = trigger; pullTrigger = trigger;
selectedPullDetail = null; selectedPullDetail = null;
@ -1327,7 +1373,7 @@
qs('#open-pull-gitea').href = item.url || '#'; qs('#open-pull-gitea').href = item.url || '#';
qs('#close-pull-sheet').focus(); qs('#close-pull-sheet').focus();
try { try {
const detail = await pullController.load(item); const detail = offlineDetail || await pullController.load(item);
if (selectedPull !== item) return; if (selectedPull !== item) return;
selectedPullDetail = detail; selectedPullDetail = detail;
pullConversation = pullController.conversation(item, detail.conversation); pullConversation = pullController.conversation(item, detail.conversation);
@ -1336,6 +1382,13 @@
renderPullConversation(pullConversation.snapshot()); renderPullConversation(pullConversation.snapshot());
qs('#open-pull-gitea').href = detail.url || item.url || '#'; qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author'); qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
if (offlineDetail) {
setOfflineDetailControls('pull');
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) { } catch (error) {
if (selectedPull !== item) return; if (selectedPull !== item) return;
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.'; qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
@ -2970,6 +3023,9 @@
const saved = offlineWorkStore.load(); const saved = offlineWorkStore.load();
if (!saved) return false; if (!saved) return false;
const outage = mode === 'outage'; const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim();
planningOwnerLogin = confirmedOwnerLogin;
updatePlanningAvailability();
lastNotifications = saved.notifications || []; lastNotifications = saved.notifications || [];
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false }; notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
workPagination = saved.work_pagination || {}; workPagination = saved.work_pagination || {};

View File

@ -7,6 +7,7 @@
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1'; const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1'; const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
const DETAILS_KEY = 'stackchain.offline-work.details.v1';
const VERSION = 1; const VERSION = 1;
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const ITEM_FIELDS = [ const ITEM_FIELDS = [
@ -16,6 +17,10 @@
const NOTIFICATION_FIELDS = [ const NOTIFICATION_FIELDS = [
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url', 'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
]; ];
const DETAIL_FIELDS = [
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
];
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
function pick(source, fields) { function pick(source, fields) {
const output = {}; const output = {};
@ -25,18 +30,86 @@
return output; return output;
} }
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) { function createOfflineWorkStore({
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
}) {
function enabled() { function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; } try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; } catch (_) { return false; }
} }
function clear() { function clear() {
try { storage.removeItem(SNAPSHOT_KEY); } try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
}
catch (_) { return false; } catch (_) { return false; }
return true; return true;
} }
function detailKey(item) {
const kind = item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
const repository = String(item?.repository || '');
const number = Number(item?.number || 0);
return kind && repository && number > 0 ? [kind, repository, number].join(':') : '';
}
function readDetails() {
try {
const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
return Array.isArray(value) ? value : [];
} catch (_) {
try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ }
return [];
}
}
function saveDetail(login, item, detail) {
const key = detailKey(item);
login = String(login || '').trim();
if (!enabled() || !login || !key || !detail) return false;
const conversation = detail.conversation || {};
const record = {
key,
user_login: login,
saved_at: now().toISOString(),
data: {
...pick(detail, DETAIL_FIELDS),
conversation: {
comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)),
page: Number(conversation.page || 1),
older_page: conversation.older_page ?? null,
total: Number(conversation.total || 0),
},
},
};
const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login)
);
records.push(record);
try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); }
catch (_) { return false; }
return true;
}
function loadDetail(login, item) {
const key = detailKey(item);
login = String(login || '').trim();
if (!login || !key) return null;
const records = readDetails();
const currentTime = now().getTime();
const valid = records.filter(record => {
const savedAt = Date.parse(record?.saved_at || '');
return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) &&
currentTime - savedAt <= maxAgeMs;
});
if (valid.length !== records.length) {
try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ }
}
const record = valid.find(candidate => candidate.key === key && candidate.user_login === login);
return record ? { ...record.data, saved_at: record.saved_at } : null;
}
function setEnabled(value) { function setEnabled(value) {
try { try {
storage.setItem(ENABLED_KEY, value ? 'true' : 'false'); storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
@ -81,7 +154,7 @@
return { ...record.data, saved_at: record.saved_at }; return { ...record.data, saved_at: record.saved_at };
} }
return { enabled, setEnabled, save, load, clear }; return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
} }
return createOfflineWorkStore; return createOfflineWorkStore;

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v38'; const CACHE = 'stackchain-dashboard-shell-v39';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v38" in worker assert "stackchain-dashboard-shell-v39" in worker

View File

@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v38" in worker assert "stackchain-dashboard-shell-v39" in worker

View File

@ -829,7 +829,7 @@ async def test_opening_issue_defers_planning_requests_until_disclosure_expands()
assert 'loadIssueLabelEditor(' not in open_handler assert 'loadIssueLabelEditor(' not in open_handler
assert 'loadIssueMilestoneEditor(' not in open_handler assert 'loadIssueMilestoneEditor(' not in open_handler
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler assert "if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();" in open_handler
assert "qs('#issue-planning').addEventListener('toggle'" in html assert "qs('#issue-planning').addEventListener('toggle'" in html
assert 'planningLoader.open(selectedIssue)' in html assert 'planningLoader.open(selectedIssue)' in html
assert 'id="retry-issue-planning"' in html assert 'id="retry-issue-planning"' in html

View File

@ -87,6 +87,36 @@ process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load(
assert result == {"enabled": False, "loaded": None} assert result == {"enabled": False, "loaded": None}
def test_opted_in_detail_cache_is_allowlisted_account_bound_and_bounded():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z'), maxDetails:2});
store.setEnabled(true);
const issue = {kind:'issue', repository:'stackchain/dashboard', number:8};
store.saveDetail('timmy', issue, {
title:'Ship offline work', body:'Safe description', state:'open', labels:['P0'], assignees:['timmy'],
url:'https://forge.example/issues/8', token:'secret', files:[{patch:'private diff'}],
conversation:{comments:[{id:1, author:'alexander', body:'Newest message', created_at:'2026-08-07T11:00:00Z',
token:'comment-secret'}], page:1, older_page:null, total:1},
});
store.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}, {title:'PR 9', body:'Body 9'});
store.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:10}, {title:'Issue 10', body:'Body 10'});
const raw = [...values.values()].join(' ');
process.stdout.write(JSON.stringify({
evicted:store.loadDetail('timmy', issue),
loaded:store.loadDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}),
wrongUser:store.loadDetail('alexander', {kind:'pull', repository:'stackchain/dashboard', number:9}),
raw,
}));
""")
assert result["evicted"] is None
assert result["wrongUser"] is None
assert result["loaded"]["title"] == "PR 9"
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "secret" not in result["raw"]
assert "private diff" not in result["raw"]
@pytest.mark.anyio @pytest.mark.anyio
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration(): async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
html = await dashboard() html = await dashboard()
@ -114,3 +144,24 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap
assert "Live details and actions will return automatically." in html assert "Live details and actions will return automatically." in html
assert "function renderLiveSnapshot(snapshot, changedSections" in html assert "function renderLiveSnapshot(snapshot, changedSections" in html
assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
@pytest.mark.anyio
async def test_saved_today_details_open_offline_without_enabling_server_state_actions():
html = await dashboard()
assert "offlineWorkStore.loadDetail(offlineLogin, item)" in html
assert "Details not saved—reconnect to open this item." in html
assert "openIssueSheet(item, trigger, savedDetail)" in html
assert "openPullSheet(item, trigger, savedDetail)" in html
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail)" in html
assert "todayWork.contains(item)" in html
assert "Offline copy · saved " in html
assert "setOfflineDetailControls('issue')" in html
assert "setOfflineDetailControls('pull')" in html
assert "qs('#issue-planning').inert = true;" in html
assert "qs('#issue-handoff').inert = true;" in html
assert "qs('#pull-review').inert = true;" in html
assert "qs('#issue-planning').inert = false;" in html
assert "qs('#pull-review').inert = false;" in html
assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html

View File

@ -32,3 +32,12 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
assert "`/readyz`" in text assert "`/readyz`" in text
assert "does not contact Gitea" in text assert "does not contact Gitea" in text
assert "HTTP 503" in text assert "HTTP 503" in text
def test_readme_documents_bounded_offline_today_details_and_safe_actions():
text = " ".join(README.read_text().split())
assert "opened Today issue or pull request" in text
assert "newest 20 comments" in text
assert "comments can enter the account-bound durable outbox" in text
assert "planning, assignment, review, merge, and close controls remain disabled" in text

View File

@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v38" in source assert "stackchain-dashboard-shell-v39" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source
@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v38" in source assert "stackchain-dashboard-shell-v39" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v38" in source assert "stackchain-dashboard-shell-v39" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source