1 parent
e44f49708a
commit
297ae7550a
11 files changed
+259
-60
No files matched your search
@@ -1,5 +1,7 @@
|
||||
(async function(){
|
||||
const workspaceLifecycle = await loadWorkspace({ document, window });
|
||||
const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window }));
|
||||
await workspaceLifecycle.optionalReady;
|
||||
window.stackchainProgressiveMyWork?.stop();
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
|
||||
@@ -2284,6 +2284,7 @@
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/progressive-my-work.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/agenda-calendar.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||
const list = document.querySelector('#my-work-list');
|
||||
const status = document.querySelector('#my-work-status');
|
||||
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
|
||||
const listeners = [];
|
||||
let items = [];
|
||||
let active = 'all';
|
||||
let stopped = false;
|
||||
|
||||
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
})[character]);
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/');
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
const visibleItems = () => active === 'all' ? items : items.filter(item =>
|
||||
active === 'review' ? item.is_review :
|
||||
active === 'update' ? item.has_update :
|
||||
active === 'attention' ? item.needs_attention : item.kind === active
|
||||
);
|
||||
const render = () => {
|
||||
if (stopped || !list) return;
|
||||
const visible = visibleItems();
|
||||
list.innerHTML = visible.length ? visible.map(item => {
|
||||
const href = safeUrl(item.url);
|
||||
const title = escapeHtml(item.title || item.key || 'Untitled work');
|
||||
const context = escapeHtml(item.key || '');
|
||||
const reason = escapeHtml(item.reason || 'Assigned to you');
|
||||
return '<article class="my-work-card progressive-my-work-card">' +
|
||||
(href ? '<a class="my-work-card-main" href="' + escapeHtml(href) + '">' : '<div class="my-work-card-main">') +
|
||||
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
|
||||
(href ? '</a>' : '</div>') + '</article>';
|
||||
}).join('') : '<div class="muted">No work in this queue.</div>';
|
||||
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
|
||||
};
|
||||
filters.forEach(button => {
|
||||
const listener = () => { active = button.dataset.workFilter || 'all'; render(); };
|
||||
button.addEventListener('click', listener);
|
||||
listeners.push([button, listener]);
|
||||
});
|
||||
|
||||
return {
|
||||
async start() {
|
||||
if (status) status.textContent = 'Loading assigned work…';
|
||||
try {
|
||||
const snapshot = await fetchSnapshot();
|
||||
if (stopped) return false;
|
||||
const context = snapshot?.context || snapshot || {};
|
||||
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
|
||||
render();
|
||||
const assigned = items.filter(item => item.is_assigned).length;
|
||||
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
window.stackchainProgressiveMyWork = createProgressiveMyWork({
|
||||
document,
|
||||
fetchSnapshot: async () => {
|
||||
const response = await fetch('api/v1/live', { headers:{Accept:'application/json'} });
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
void window.stackchainProgressiveMyWork.start();
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;
|
||||
@@ -186,6 +186,7 @@ const SHELL = [
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/progressive-my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/agenda-calendar.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
|
||||
@@ -2,76 +2,110 @@ async function loadWorkspace({
|
||||
document,
|
||||
window = null,
|
||||
createLoader = createFeatureLoader,
|
||||
schedule = callback => setTimeout(callback,750),
|
||||
schedule = callback => setTimeout(callback, 750),
|
||||
}) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
let retryInFlight = null;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
|
||||
const names = ['work-core', 'today-timer', 'planning'];
|
||||
const urls = Object.fromEntries(names.map(name => [name,
|
||||
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
||||
]));
|
||||
const originalUrls = {...urls};
|
||||
const loader = createLoader({document, urls});
|
||||
const attempts = Object.fromEntries(names.map(name => [name, 0]));
|
||||
const failed = new Set();
|
||||
const recoveries = new Map();
|
||||
let retryInFlight = null;
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) Object.keys(urls).forEach(name => {
|
||||
urls[name] = originalUrls[name] + '?retry=' + attempts;
|
||||
});
|
||||
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
|
||||
const loadFeature = name => {
|
||||
attempts[name] += 1;
|
||||
urls[name] = originalUrls[name] + (attempts[name] > 1 ? '?retry=' + attempts[name] : '');
|
||||
return loader.load(name);
|
||||
};
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) {
|
||||
retryButton.hidden = retryButton.disabled = false;
|
||||
const retryOnce = async name => {
|
||||
try { return await loadFeature(name); }
|
||||
catch (_error) {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
return loadFeature(name);
|
||||
}
|
||||
|
||||
const recover = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
retryInFlight = load().then(() => {
|
||||
window?.removeEventListener('online', recover);
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) retryButton.disabled = false;
|
||||
}).finally(() => {
|
||||
retryInFlight = null;
|
||||
});
|
||||
return retryInFlight;
|
||||
};
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
window?.addEventListener('online', recover);
|
||||
retryButton?.addEventListener('click', recover);
|
||||
});
|
||||
};
|
||||
const showRecovery = () => {
|
||||
if (status) status.textContent = failed.has('work-core') ?
|
||||
'Workspace unavailable. Reconnect or retry.' :
|
||||
'Today or planning tools unavailable. My Work is ready; reconnect or retry.';
|
||||
if (retryButton) retryButton.hidden = retryButton.disabled = false;
|
||||
};
|
||||
const hideRecovery = () => {
|
||||
if (failed.size) return;
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
};
|
||||
const retryFailed = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
const pending = Array.from(failed);
|
||||
retryInFlight = Promise.all(pending.map(async name => {
|
||||
try {
|
||||
await loadFeature(name);
|
||||
failed.delete(name);
|
||||
recoveries.get(name)?.resolve(true);
|
||||
recoveries.delete(name);
|
||||
} catch (_error) {}
|
||||
})).then(() => {
|
||||
if (failed.size) showRecovery();
|
||||
else hideRecovery();
|
||||
}).finally(() => { retryInFlight = null; });
|
||||
return retryInFlight;
|
||||
};
|
||||
retryButton?.addEventListener?.('click', retryFailed);
|
||||
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
||||
window?.addEventListener('online', handleOnline);
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await waitForRecovery();
|
||||
}
|
||||
await retryOnce('work-core');
|
||||
} catch (_error) {
|
||||
failed.add('work-core');
|
||||
showRecovery();
|
||||
await new Promise(resolve => recoveries.set('work-core', {resolve}));
|
||||
}
|
||||
hideRecovery();
|
||||
|
||||
const optional = ['today-timer', 'planning'].map(async name => {
|
||||
try {
|
||||
await retryOnce(name);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
failed.add(name);
|
||||
showRecovery();
|
||||
return new Promise(resolve => recoveries.set(name, {resolve}));
|
||||
}
|
||||
});
|
||||
const optionalReady = Promise.all(optional).then(() => {
|
||||
hideRecovery();
|
||||
return true;
|
||||
});
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
optionalReady,
|
||||
retryFeature(name) {
|
||||
if (!failed.has(name)) return Promise.resolve(true);
|
||||
return retryFailed().then(() => !failed.has(name));
|
||||
},
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
window?.removeEventListener('online', handleOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
|
||||
typeof createFeatureLoader === 'function') {
|
||||
window.stackchainWorkspaceLifecycle = loadWorkspace({document, window});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
Reference in new issue
Block a user