feat: continue unread updates offline (#347)
This commit is contained in:
parent
0539b0a035
commit
ae6cde29c2
|
|
@ -210,11 +210,15 @@ Users can explicitly enable **Keep My Work available offline**. Each healthy liv
|
|||
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. 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,
|
||||
newest 20 comments. A previously opened unread update is retained by notification
|
||||
identity with allowlisted subject context and its newest 20 conversation messages.
|
||||
This account-bound cache is limited to ten records across all detail kinds; 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.
|
||||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading 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.
|
||||
|
|
|
|||
|
|
@ -429,6 +429,7 @@
|
|||
qs('#send-update-reply').disabled = false;
|
||||
qs('#update-ownership-action').hidden = true;
|
||||
qs('#retry-update-load').hidden = true;
|
||||
setOfflineUpdateControls(false);
|
||||
qs('#keep-update-unread').focus();
|
||||
},
|
||||
onDetail: detail => {
|
||||
|
|
@ -437,7 +438,14 @@
|
|||
qs('#update-subject-state').textContent = detail.state || '';
|
||||
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
||||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||||
updateOwnership.open(detail, selectedUpdate);
|
||||
if (offlineWorkMode) {
|
||||
setOfflineUpdateControls(true);
|
||||
} else {
|
||||
updateOwnership.open(detail, selectedUpdate);
|
||||
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
|
||||
offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
|
||||
}
|
||||
}
|
||||
qs('#retry-update-load').hidden = true;
|
||||
},
|
||||
onConversation: renderUpdateConversation,
|
||||
|
|
@ -470,7 +478,8 @@
|
|||
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
|
||||
String(offlineWorkStore.load()?.user?.login || '').trim();
|
||||
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
|
||||
if (!todayWork.contains(item) || !savedDetail) {
|
||||
const savedUpdate = item.kind === 'update' && item.has_update;
|
||||
if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) {
|
||||
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
|
||||
return;
|
||||
}
|
||||
|
|
@ -480,6 +489,14 @@
|
|||
} else if (item.kind === 'pull' && !item.is_review) {
|
||||
pullTrigger = trigger;
|
||||
openPullSheet(item, trigger, savedDetail);
|
||||
} else if (savedUpdate) {
|
||||
updateTrigger = trigger;
|
||||
notificationReader.open(item, savedDetail).then(opened => {
|
||||
if (opened && selectedUpdate === item) {
|
||||
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
|
||||
' · replies queue for sync. Reconnect to mark read, take ownership, defer, or load older messages.';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
|
||||
}
|
||||
|
|
@ -634,6 +651,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
function setOfflineUpdateControls(offline) {
|
||||
qs('#mark-update-read-next').disabled = offline;
|
||||
qs('#update-ownership-action').disabled = offline;
|
||||
qs('#load-older-update-comments').disabled = offline;
|
||||
qs('#update-sheet .detail-defer').inert = offline;
|
||||
}
|
||||
|
||||
function renderContextSnapshot(data) {
|
||||
liveMode = true;
|
||||
hasContextSnapshot = true;
|
||||
|
|
@ -959,7 +983,7 @@
|
|||
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
||||
if (!item) return;
|
||||
updateTrigger = button;
|
||||
workRoute.open({ ...item, kind:'update' });
|
||||
openRoutedWork(item, button);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-notification-id]').forEach(button => {
|
||||
|
|
@ -3018,6 +3042,10 @@
|
|||
offlineWorkMode = value;
|
||||
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
||||
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
|
||||
if (value) {
|
||||
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
|
||||
.forEach(button => { button.disabled = true; });
|
||||
}
|
||||
}
|
||||
function hydrateOfflineWork(mode = 'offline') {
|
||||
const saved = offlineWorkStore.load();
|
||||
|
|
|
|||
|
|
@ -288,14 +288,16 @@ function createNotificationReader({
|
|||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function open(item) {
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
const version = ++loadVersion;
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
try {
|
||||
const detail = await load(item.notification_id);
|
||||
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
if (createPager && loadConversation && detail.conversation) {
|
||||
|
|
@ -324,7 +326,7 @@ function createNotificationReader({
|
|||
return true;
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected) return false;
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
const version = loadVersion;
|
||||
onStatus('Loading older messages…');
|
||||
|
|
@ -342,7 +344,7 @@ function createNotificationReader({
|
|||
}
|
||||
},
|
||||
async markReadAndNext(items) {
|
||||
if (!selected || marking) return false;
|
||||
if (!selected || marking || offlineHydrated) return false;
|
||||
const current = selected;
|
||||
marking = true;
|
||||
onStatus('Marking update read…');
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
];
|
||||
const DETAIL_FIELDS = [
|
||||
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
|
||||
'id', 'repository', 'subject_type', 'subject_body',
|
||||
];
|
||||
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
|
||||
|
||||
|
|
@ -48,6 +49,10 @@
|
|||
}
|
||||
|
||||
function detailKey(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = Number(item?.notification_id || 0);
|
||||
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
|
||||
}
|
||||
const kind = item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
|
||||
const repository = String(item?.repository || '');
|
||||
const number = Number(item?.number || 0);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v39';
|
||||
const CACHE = 'stackchain-dashboard-shell-v40';
|
||||
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-v39" in worker
|
||||
assert "stackchain-dashboard-shell-v40" 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-v39" in worker
|
||||
assert "stackchain-dashboard-shell-v40" in worker
|
||||
|
|
|
|||
|
|
@ -2359,6 +2359,53 @@ reader.open(firstItem).then(async () => {{
|
|||
assert output["states"] == [[41], [90]]
|
||||
|
||||
|
||||
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
const createPager = require({json.dumps(str(CONVERSATION))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const saved = {{
|
||||
id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
|
||||
conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
|
||||
}};
|
||||
let detailLoads = 0;
|
||||
let conversationLoads = 0;
|
||||
let markReads = 0;
|
||||
const details = [];
|
||||
const conversations = [];
|
||||
const reader = build.createNotificationReader({{
|
||||
load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
|
||||
loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
|
||||
markRead: async () => {{ markReads += 1; }}, createPager,
|
||||
onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
|
||||
onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
|
||||
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
const opened = await reader.open(item, saved);
|
||||
const older = await reader.loadOlder();
|
||||
const marked = await reader.markReadAndNext([item]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"older": False,
|
||||
"marked": False,
|
||||
"detailLoads": 0,
|
||||
"conversationLoads": 0,
|
||||
"markReads": 0,
|
||||
"details": ["Saved update"],
|
||||
"conversations": [[41]],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
|
|
|
|||
|
|
@ -117,6 +117,43 @@ process.stdout.write(JSON.stringify({
|
|||
assert "private diff" not in result["raw"]
|
||||
|
||||
|
||||
def test_unread_update_conversation_detail_is_private_bounded_and_account_bound():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
store.setEnabled(true);
|
||||
const update = {kind:'update', notification_id:42};
|
||||
store.saveDetail('timmy', update, {
|
||||
id:42, repository:'stackchain/dashboard', title:'Deployment blocked',
|
||||
subject_type:'Issue', state:'open', subject_body:'Safe subject context',
|
||||
url:'https://forge.example/issues/8#issuecomment-9', token:'secret',
|
||||
issue:{number:8, assignees:[], claimable:true}, files:[{patch:'private diff'}],
|
||||
conversation:{
|
||||
comments:Array.from({length:25}, (_, index) => ({
|
||||
id:index + 1, author:'alexander', body:'Message ' + (index + 1),
|
||||
created_at:'2026-08-07T11:00:00Z', token:'comment-secret',
|
||||
})),
|
||||
page:1, older_page:2, total:40,
|
||||
},
|
||||
});
|
||||
const raw = [...values.values()].join(' ');
|
||||
process.stdout.write(JSON.stringify({
|
||||
loaded:store.loadDetail('timmy', update),
|
||||
wrongUser:store.loadDetail('alexander', update),
|
||||
raw,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["wrongUser"] is None
|
||||
assert result["loaded"]["title"] == "Deployment blocked"
|
||||
assert result["loaded"]["subject_body"] == "Safe subject context"
|
||||
assert result["loaded"]["subject_type"] == "Issue"
|
||||
assert len(result["loaded"]["conversation"]["comments"]) == 20
|
||||
assert result["loaded"]["conversation"]["comments"][0]["body"] == "Message 6"
|
||||
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()
|
||||
|
|
@ -165,3 +202,19 @@ async def test_saved_today_details_open_offline_without_enabling_server_state_ac
|
|||
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
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_unread_update_opens_offline_with_reply_only_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert "notificationReader.open(item, savedDetail)" in html
|
||||
assert "openRoutedWork(item, button);" in html
|
||||
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html
|
||||
assert "Offline update · saved " in html
|
||||
assert "setOfflineUpdateControls(true)" in html
|
||||
assert "qs('#mark-update-read-next').disabled = offline;" in html
|
||||
assert "qs('#update-ownership-action').disabled = offline;" in html
|
||||
assert "qs('#load-older-update-comments').disabled = offline;" in html
|
||||
assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html
|
||||
assert "Reconnect to mark read, take ownership, defer, or load older messages." in html
|
||||
|
|
|
|||
|
|
@ -41,3 +41,11 @@ def test_readme_documents_bounded_offline_today_details_and_safe_actions():
|
|||
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
|
||||
|
||||
|
||||
def test_readme_documents_offline_unread_update_conversations_and_boundaries():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "previously opened unread update" in text
|
||||
assert "replies enter the account-bound durable outbox" in text
|
||||
assert "mark read, ownership, deferral, and older-message loading 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-v39" in source
|
||||
assert "stackchain-dashboard-shell-v40" 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-v39" in source
|
||||
assert "stackchain-dashboard-shell-v40" 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-v39" in source
|
||||
assert "stackchain-dashboard-shell-v40" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user