Merge pull request 'Open saved Today details and queue replies offline' (#346) from timmy/345-offline-today-details into main
This commit is contained in:
commit
0539b0a035
14
README.md
14
README.md
|
|
@ -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
|
||||
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,
|
||||
and pagination totals. Bodies, comments, diffs, credentials, repository catalogs,
|
||||
events, and complete API responses are excluded. A cold offline launch labels the
|
||||
saved time and renders this snapshot read-only; opening live details, pagination,
|
||||
and server mutations remain disabled until reconnection. **Clear offline work
|
||||
data** deletes the snapshot, and opting out deletes it automatically.
|
||||
and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
|
||||
newest 20 comments. This account-bound cache is limited to ten records; diffs,
|
||||
credentials, repository catalogs, events, and complete API responses are excluded.
|
||||
A cold offline launch labels the saved time. Cached Today details open in the existing
|
||||
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,
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
|
|
|
|||
|
|
@ -467,7 +467,22 @@
|
|||
function openRoutedWork(item, trigger, options = {}) {
|
||||
if (!item) return;
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
liveMode = true;
|
||||
hasContextSnapshot = true;
|
||||
|
|
@ -1135,8 +1171,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function openIssueSheet(item, trigger) {
|
||||
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
||||
if (!item) return;
|
||||
qs('#issue-planning').inert = false;
|
||||
qs('#issue-handoff').inert = false;
|
||||
selectedIssue = item;
|
||||
selectedIssueDetail = null;
|
||||
issueConversation = null;
|
||||
|
|
@ -1182,7 +1220,7 @@
|
|||
qs('#close-issue').disabled = false;
|
||||
qs('#close-issue-sheet').focus();
|
||||
try {
|
||||
const detail = await issueController.load(item);
|
||||
const detail = offlineDetail || await issueController.load(item);
|
||||
if (selectedIssue !== item) return;
|
||||
selectedIssueDetail = detail;
|
||||
issueConversation = issueController.conversation(item, detail.conversation);
|
||||
|
|
@ -1204,7 +1242,14 @@
|
|||
qs('#clear-issue-due-date').disabled = !detail.due_date;
|
||||
qs('#issue-due-status').textContent = detail.due_date ?
|
||||
'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) {
|
||||
if (selectedIssue !== item) return;
|
||||
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;
|
||||
qs('#pull-review').inert = false;
|
||||
selectedPull = item;
|
||||
pullTrigger = trigger;
|
||||
selectedPullDetail = null;
|
||||
|
|
@ -1327,7 +1373,7 @@
|
|||
qs('#open-pull-gitea').href = item.url || '#';
|
||||
qs('#close-pull-sheet').focus();
|
||||
try {
|
||||
const detail = await pullController.load(item);
|
||||
const detail = offlineDetail || await pullController.load(item);
|
||||
if (selectedPull !== item) return;
|
||||
selectedPullDetail = detail;
|
||||
pullConversation = pullController.conversation(item, detail.conversation);
|
||||
|
|
@ -1336,6 +1382,13 @@
|
|||
renderPullConversation(pullConversation.snapshot());
|
||||
qs('#open-pull-gitea').href = detail.url || item.url || '#';
|
||||
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) {
|
||||
if (selectedPull !== item) return;
|
||||
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||||
|
|
@ -2970,6 +3023,9 @@
|
|||
const saved = offlineWorkStore.load();
|
||||
if (!saved) return false;
|
||||
const outage = mode === 'outage';
|
||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||
planningOwnerLogin = confirmedOwnerLogin;
|
||||
updatePlanningAvailability();
|
||||
lastNotifications = saved.notifications || [];
|
||||
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
|
||||
workPagination = saved.work_pagination || {};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
|
||||
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
|
||||
const DETAILS_KEY = 'stackchain.offline-work.details.v1';
|
||||
const VERSION = 1;
|
||||
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ITEM_FIELDS = [
|
||||
|
|
@ -16,6 +17,10 @@
|
|||
const NOTIFICATION_FIELDS = [
|
||||
'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) {
|
||||
const output = {};
|
||||
|
|
@ -25,18 +30,86 @@
|
|||
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() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try { storage.removeItem(SNAPSHOT_KEY); }
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
}
|
||||
catch (_) { return false; }
|
||||
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) {
|
||||
try {
|
||||
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
|
||||
|
|
@ -81,7 +154,7 @@
|
|||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, save, load, clear };
|
||||
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
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 SHELL = [
|
||||
BASE,
|
||||
|
|
|
|||
|
|
@ -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 pre { max-width:100%; overflow-x:auto;" 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
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -829,7 +829,7 @@ async def test_opening_issue_defers_planning_requests_until_disclosure_expands()
|
|||
|
||||
assert 'loadIssueLabelEditor(' 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 'planningLoader.open(selectedIssue)' in html
|
||||
assert 'id="retry-issue-planning"' in html
|
||||
|
|
|
|||
|
|
@ -87,6 +87,36 @@ process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load(
|
|||
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
|
||||
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
|
||||
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 "function renderLiveSnapshot(snapshot, changedSections" 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
|
||||
|
|
|
|||
|
|
@ -32,3 +32,12 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
|
|||
assert "`/readyz`" in text
|
||||
assert "does not contact Gitea" 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
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
|
|||
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||
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.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():
|
||||
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
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user