feat: resume interrupted Today sessions (#395)
This commit is contained in:
1 parent
ff0e47cbe7
commit
1f7fd093fd
13 files changed
+305
-14
No files matched your search
@@ -246,6 +246,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
|
||||
@@ -665,12 +665,30 @@
|
||||
openRoutedWork(item, null, { replace:true });
|
||||
}
|
||||
|
||||
const sessionCheckpoint = createWorkSessionCheckpoint({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
onError: () => {
|
||||
qs('#my-work-action-status').textContent =
|
||||
'Session recovery could not be saved on this device. You can keep working.';
|
||||
},
|
||||
});
|
||||
function updateWorkSessionActions() {
|
||||
const active = workSession.active();
|
||||
qs('#start-work-session').hidden = active;
|
||||
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
|
||||
qs('#end-today-session').hidden = !active;
|
||||
}
|
||||
|
||||
const workSession = createWorkSession({
|
||||
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
|
||||
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkMilestone,
|
||||
checkpoint: sessionCheckpoint,
|
||||
checkpointEnabled: () => selectedWorkFilter === 'today',
|
||||
onOpen: openWorkSessionItem,
|
||||
onProgress: state => {
|
||||
updateWorkSessionActions();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
||||
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + state.index + ' of ' + state.total;
|
||||
@@ -686,6 +704,7 @@
|
||||
closeOpenWorkSheets();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||
updateWorkSessionActions();
|
||||
qs('#start-work-session').focus();
|
||||
},
|
||||
});
|
||||
@@ -1042,6 +1061,7 @@
|
||||
updateWorkPaginationControls();
|
||||
renderMyWork();
|
||||
if (reconcileSession && workSession.active()) workSession.reconcile();
|
||||
updateWorkSessionActions();
|
||||
}
|
||||
|
||||
function activeWorkStreams() {
|
||||
@@ -3576,6 +3596,18 @@
|
||||
}
|
||||
workSession.start();
|
||||
});
|
||||
qs('#resume-today-session').addEventListener('click', () => {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
if (!workSession.resume()) {
|
||||
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
|
||||
updateWorkSessionActions();
|
||||
}
|
||||
});
|
||||
qs('#end-today-session').addEventListener('click', () => {
|
||||
workSession.end();
|
||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||
qs('#start-work-session').focus();
|
||||
});
|
||||
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
||||
button.addEventListener('click', () => workSession.previous())
|
||||
);
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
<div class="my-work-actions">
|
||||
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
||||
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
|
||||
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
|
||||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||
<button class="find-work-action" id="find-work" type="button">Find work</button>
|
||||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||
</div>
|
||||
|
||||
+76
-1
@@ -472,10 +472,67 @@ function workIdentity(item) {
|
||||
return [kind, repository, number, notification].join(':');
|
||||
}
|
||||
|
||||
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
|
||||
function createWorkSessionCheckpoint({
|
||||
storage,
|
||||
getLogin,
|
||||
onError = () => {},
|
||||
key = 'stackchain.today-session.v1',
|
||||
}) {
|
||||
const login = () => String(getLogin() || '').trim();
|
||||
let errorReported = false;
|
||||
const reportError = error => {
|
||||
if (errorReported) return;
|
||||
errorReported = true;
|
||||
onError(error);
|
||||
};
|
||||
const read = () => {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
|
||||
!Number.isInteger(value.index) || value.index < 0) return null;
|
||||
return value;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
read,
|
||||
save(identity, index) {
|
||||
const owner = login();
|
||||
if (!owner) return false;
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
if (!read()) return false;
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -485,9 +542,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
can_next: index < items.length - 1,
|
||||
});
|
||||
const finish = () => {
|
||||
const clearCheckpoint = durable;
|
||||
running = false;
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -495,6 +555,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -502,10 +563,23 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
resume() {
|
||||
const saved = checkpoint?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
|
||||
},
|
||||
start(item = null) {
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
@@ -612,6 +686,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
buildMyWork.workIdentity = workIdentity;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v59';
|
||||
const CACHE = 'stackchain-dashboard-shell-v60';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
|
||||
Reference in new issue
Block a user