feat: keep My Work available offline (#228)
This commit is contained in:
1 parent
a579b95f47
commit
1b2a3c83af
6 files changed
+274
-3
No files matched your search
@@ -57,6 +57,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
@@ -305,6 +309,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<option value="unplanned">Unplanned</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="offline-work-controls">
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
@@ -709,6 +718,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
@@ -744,6 +754,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
});
|
||||
});
|
||||
let liveMode = true;
|
||||
let offlineWorkMode = false;
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||||
let selectedWorkFilter = 'all';
|
||||
@@ -822,6 +833,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
issueCapture.stageSharedContent(sharedLaunch) : null;
|
||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage });
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
|
||||
const findWorkController = createFindWork({
|
||||
fetchJson: fetchReviewJson,
|
||||
onItems: renderAvailableIssues,
|
||||
@@ -1024,6 +1036,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
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.';
|
||||
return;
|
||||
}
|
||||
if (item.has_update && item.kind === 'update') updateTrigger = trigger;
|
||||
else if (item.is_review) reviewTrigger = trigger;
|
||||
else if (item.kind === 'issue') issueTrigger = trigger;
|
||||
@@ -2103,10 +2119,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
}
|
||||
if (snapshot.context) {
|
||||
setOfflineWorkMode(false);
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
if (contextFreshness?.stale) markMyWorkStale();
|
||||
else if (!notificationsFresh) markNotificationsStale();
|
||||
if (!contextFreshness?.stale && notificationsFresh) {
|
||||
offlineWorkStore.save({
|
||||
...snapshot.context,
|
||||
notifications: snapshot.notifications,
|
||||
notification_pagination: snapshot.notification_pagination,
|
||||
});
|
||||
updateOfflineWorkControls();
|
||||
}
|
||||
} else handleContextError(new Error('Context section unavailable'));
|
||||
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
|
||||
if (eventsFreshness?.revalidating) {
|
||||
@@ -2995,15 +3020,59 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
function load() { return contextPoller.refresh(); }
|
||||
|
||||
const offlineStatus = qs('#offline-status');
|
||||
const keepWorkOffline = qs('#keep-work-offline');
|
||||
const offlineWorkStatus = qs('#offline-work-status');
|
||||
function updateOfflineWorkControls(message) {
|
||||
keepWorkOffline.checked = offlineWorkStore.enabled();
|
||||
const saved = offlineWorkStore.load();
|
||||
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
||||
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
||||
}
|
||||
function setOfflineWorkMode(value) {
|
||||
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; });
|
||||
}
|
||||
function hydrateOfflineWork() {
|
||||
const saved = offlineWorkStore.load();
|
||||
if (!saved) return false;
|
||||
lastNotifications = saved.notifications || [];
|
||||
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
|
||||
workPagination = saved.work_pagination || {};
|
||||
lastContextSnapshot = saved;
|
||||
hasContextSnapshot = true;
|
||||
paintMyWork(saved);
|
||||
setOfflineWorkMode(true);
|
||||
const savedLabel = fmt(saved.saved_at);
|
||||
qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only';
|
||||
offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
|
||||
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
|
||||
return true;
|
||||
}
|
||||
function showOfflineStatus() {
|
||||
offlineStatus.hidden = false;
|
||||
setStatus('Offline');
|
||||
if (!hasContextSnapshot) hydrateOfflineWork();
|
||||
}
|
||||
function reconnectLiveData() {
|
||||
offlineStatus.hidden = true;
|
||||
setOfflineWorkMode(false);
|
||||
setStatus('Reconnecting…');
|
||||
contextPoller.refresh();
|
||||
}
|
||||
keepWorkOffline.addEventListener('change', () => {
|
||||
offlineWorkStore.setEnabled(keepWorkOffline.checked);
|
||||
if (keepWorkOffline.checked && liveMode && lastContextSnapshot) {
|
||||
offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||||
notification_pagination:notificationPagination });
|
||||
}
|
||||
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||
});
|
||||
qs('#clear-offline-work').addEventListener('click', () => {
|
||||
offlineWorkStore.clear();
|
||||
updateOfflineWorkControls('Offline work data cleared.');
|
||||
});
|
||||
updateOfflineWorkControls();
|
||||
if (!navigator.onLine) showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.createOfflineWorkStore = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
|
||||
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
|
||||
const VERSION = 1;
|
||||
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ITEM_FIELDS = [
|
||||
'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees',
|
||||
'updated_at', 'due_date', 'milestone', 'url', 'work_reasons',
|
||||
];
|
||||
const NOTIFICATION_FIELDS = [
|
||||
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
|
||||
];
|
||||
|
||||
function pick(source, fields) {
|
||||
const output = {};
|
||||
fields.forEach(field => {
|
||||
if (source && source[field] !== undefined) output[field] = source[field];
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try { storage.removeItem(SNAPSHOT_KEY); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function setEnabled(value) {
|
||||
try {
|
||||
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
|
||||
if (!value) clear();
|
||||
return true;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function save(snapshot) {
|
||||
if (!enabled() || !snapshot?.user?.login) return false;
|
||||
const record = {
|
||||
version: VERSION,
|
||||
user_login: String(snapshot.user.login),
|
||||
saved_at: now().toISOString(),
|
||||
data: {
|
||||
user: pick(snapshot.user, ['login', 'full_name']),
|
||||
issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)),
|
||||
pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)),
|
||||
notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)),
|
||||
work_pagination: snapshot.work_pagination || {},
|
||||
notification_pagination: snapshot.notification_pagination || {},
|
||||
},
|
||||
};
|
||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function load(expectedLogin) {
|
||||
let record;
|
||||
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
|
||||
catch (_) { clear(); return null; }
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
|
||||
!Number.isFinite(savedAt);
|
||||
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
|
||||
if (invalid || expired) {
|
||||
clear();
|
||||
return null;
|
||||
}
|
||||
if (expectedLogin && record.user_login !== expectedLogin) return null;
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, save, load, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'stackchain-dashboard-shell-v2';
|
||||
const CACHE = 'stackchain-dashboard-shell-v3';
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
@@ -10,6 +10,7 @@ const SHELL = [
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
|
||||
Reference in new issue
Block a user