feat: open saved Today details offline (#345)
CI / lint (pull_request) Successful in 36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-08 22:03:28 +00:00
1 parent e78ceb1bf7
commit a2ba624dc2
10 files changed
+214 -21

No files matched your search

+62 -6
View File
@@ -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 || {};
+76 -3
View File
@@ -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 -1
View File
@@ -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,