feat: review wrap-up commitments in Today (Closes #992)
CI / lint (pull_request) Successful in 2m56s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m50s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-16 23:15:00 +00:00
1 parent 40623f939c
commit 468676d2f4
27 files changed
+537 -53

No files matched your search

+13
View File
@@ -271,6 +271,19 @@ textarea { resize: vertical; min-height: 120px; }
.today-wrap-up-item input { width:24px; min-height:24px; }
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-wrap-up-actions button { min-height:44px; width:100%; }
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
.today-handoff-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-handoff-panel h2 { margin:.2rem 0; }
.today-handoff-panel header button { min-height:44px; min-width:44px; }
.today-handoff-items { display:grid; gap:8px; margin:14px 0; }
.today-handoff-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px; border:1px solid #29486c; border-radius:12px; background:#101f35; }
.today-handoff-item span { display:block; }
.today-handoff-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
.today-handoff-item input { width:24px; min-height:24px; }
.today-handoff-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-handoff-actions button { min-height:44px; width:100%; }
@media (max-width:420px) {
.today-interruption-actions { grid-template-columns:1fr; }
.today-recap-actions { grid-template-columns:1fr; }
+21 -3
View File
@@ -339,19 +339,36 @@
},
});
todaySync.startLifecycle({ window, document });
const todayHandoff = createTodayHandoff({
storage:localStorage,
getLogin:() => planningOwnerLogin,
identity:item => todayWork.identity(item),
items:() => [...todayMyWork, ...activeMyWork],
todayWork,
todaySync,
});
const todayHandoffView = todayHandoff.mount({ qs, escapeHtml,
onComplete:() => refreshMyWorkView(),
});
const promptTodayHandoff = () => todayHandoffView.open();
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
onChange: (action, itemId, wakeAt) => {
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
onChange: (action, itemId, wakeAt, handoff) => {
if (laterSync.enqueue(action, itemId, wakeAt, handoff)) laterSync.flush();
},
onExpire: ids => {
onExpire: (ids, handoffs) => {
if (handoffs.length) {
todayHandoff.capture(handoffs);
todayHandoffView.reset();
}
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
if (queued) laterSync.flush();
},
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
setTimeout(promptTodayHandoff, 0);
},
});
const laterSync = createLaterSync({
@@ -3014,6 +3031,7 @@
}
if (reconcileSession && workSession.active()) workSession.reconcile();
updateWorkSessionActions();
if (todayHandoff.pending().length) setTimeout(promptTodayHandoff, 0);
}
function activeWorkStreams() {
+11
View File
@@ -413,6 +413,16 @@
</section>
</div>
<dialog class="today-handoff-dialog" id="today-handoff-dialog" aria-labelledby="today-handoff-title">
<section class="today-handoff-panel">
<header><div><div class="small">Yesterday’s commitments</div><h2 id="today-handoff-title">Review tomorrow’s plan</h2></div><button id="close-today-handoff" type="button">Not now</button></header>
<p class="small muted">Choose the work you still intend to do Today. Unchecked work stays safely in My Work.</p>
<div id="today-handoff-status" class="small" role="status" aria-live="polite"></div>
<div id="today-handoff-items" class="today-handoff-items"></div>
<div class="today-handoff-actions"><button id="confirm-today-handoff" type="button">Add selected to Today</button></div>
</section>
</dialog>
<div class="today-wrap-up-sheet" id="today-wrap-up-sheet" role="dialog" aria-modal="true" aria-labelledby="today-wrap-up-title" hidden>
<section class="today-wrap-up-panel">
<div class="today-wrap-up-header">
@@ -1563,6 +1573,7 @@
<script src="static/today-timer.js"></script>
<script src="static/today-recap.js"></script>
<script src="static/today-wrap-up.js"></script>
<script src="static/today-handoff.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>
+9 -5
View File
@@ -160,15 +160,17 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, wakeAt = null) {
function enqueue(action, itemId, wakeAt = null, handoff = null) {
if (!['defer', 'restore'].includes(action) || !itemId ||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
(action === 'defer' && typeof wakeAt !== 'string') ||
![null, 'today'].includes(handoff)) return false;
pending().filter(operation => operation.item_id === itemId)
.forEach(operation => removeOperation(operation.operation_id));
const operation = {
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
base_revision: Math.max(0, savedRevision()),
};
if (handoff) operation.handoff = handoff;
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
@@ -189,9 +191,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
enqueue('defer', itemId, wakeAt)
);
Object.entries(records || {}).forEach(([itemId, record]) => {
const wakeAt = record && typeof record === 'object' ? record.wake_at : record;
const handoff = record?.handoff === 'today' ? 'today' : null;
enqueue('defer', itemId, wakeAt, handoff);
});
storage.setItem(marker, '1');
return true;
} catch (_error) {
+22 -10
View File
@@ -38,7 +38,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
}
}
function defer(item, until) {
function defer(item, until, options = {}) {
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
@@ -46,9 +46,10 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
const wakeAt = wake.toISOString();
records[id] = wakeAt;
const handoff = options.handoff === 'today' ? 'today' : null;
records[id] = handoff ? { wake_at:wakeAt, handoff } : wakeAt;
if (!write(records)) return 'unavailable';
onChange('defer', id, wakeAt);
onChange('defer', id, wakeAt, handoff);
return 'deferred';
}
@@ -93,9 +94,13 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
function adopt(records) {
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
const normalized = {};
Object.entries(records).forEach(([id, wake]) => {
Object.entries(records).forEach(([id, record]) => {
const wake = record && typeof record === 'object' ? record.wake_at : record;
const wakeTime = new Date(wake).getTime();
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
if (id && Number.isFinite(wakeTime)) {
const wakeAt = new Date(wakeTime).toISOString();
normalized[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
}
});
return write(normalized);
}
@@ -119,25 +124,32 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const wakeTimes = [];
const expired = [];
Object.entries(records).forEach(([id, wake]) => {
const handoffs = [];
Object.entries(records).forEach(([id, record]) => {
const wake = record && typeof record === 'object' ? record.wake_at : record;
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
if (Number.isFinite(wakeTime)) expired.push(id);
if (Number.isFinite(wakeTime)) {
expired.push(id);
if (record?.handoff === 'today') handoffs.push(id);
}
return;
}
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
const wakeAt = new Date(wakeTime).toISOString();
retained[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
onExpire(expired);
onExpire(expired, handoffs);
}
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
const later = (items || []).flatMap(item => {
const wake = retained[identity(item)];
const record = retained[identity(item)];
const wake = record && typeof record === 'object' ? record.wake_at : record;
return wake ? [{ ...item, deferred_until: wake }] : [];
});
return { active, later };
+2 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v114';
const CACHE = 'stackchain-dashboard-shell-v115';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -52,6 +52,7 @@ const SHELL = [
BASE + 'static/today-timer.js',
BASE + 'static/today-recap.js',
BASE + 'static/today-wrap-up.js',
BASE + 'static/today-handoff.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
+154
View File
@@ -0,0 +1,154 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createTodayHandoff = factory;
})(typeof self !== 'undefined' ? self : this, function createTodayHandoff(options) {
const prefix = 'stackchain.today-handoff.v1.';
const storage = options.storage || null;
const selected = new Set();
const excluded = new Set();
function key() {
const login = String(options.getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
try {
const value = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
} catch (_) {
return [];
}
}
function write(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
const unique = [...new Set(ids.filter(id => typeof id === 'string' && id))];
try {
if (unique.length) storage.setItem(storageKey, JSON.stringify(unique));
else storage.removeItem(storageKey);
return true;
} catch (_) {
return false;
}
}
function capture(ids) {
const merged = [...pending()];
(ids || []).forEach(id => {
if (typeof id === 'string' && id && !merged.includes(id)) merged.push(id);
if (typeof id === 'string' && id) {
selected.add(id);
excluded.delete(id);
}
});
return write(merged);
}
function review(items) {
const byIdentity = new Map((items || options.items?.() || []).map(item => [options.identity(item), item]));
return pending().flatMap(identity => {
const item = byIdentity.get(identity);
if (!excluded.has(identity)) selected.add(identity);
return item ? [{ identity, item, selected:selected.has(identity) }] : [];
});
}
function choose(identity, include) {
if (!pending().includes(identity)) return false;
if (include) {
selected.add(identity);
excluded.delete(identity);
} else {
selected.delete(identity);
excluded.add(identity);
}
return true;
}
function render(items, list, escapeHtml) {
const rows = review(items);
list.innerHTML = rows.map(row => {
const title = String(row.item.title || row.identity).slice(0, 180);
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
return '<div class="today-handoff-item"><span><strong>' + escapeHtml(title) + '</strong>' +
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
'data-today-handoff="' + escapeHtml(row.identity) + '"' + (row.selected ? ' checked' : '') +
'> Today</label></div>';
}).join('');
list.querySelectorAll('[data-today-handoff]').forEach(input => input.addEventListener('change', () =>
choose(input.dataset.todayHandoff, input.checked)));
return rows;
}
async function commit(items) {
const rows = review(items);
const remaining = pending();
let scheduled = 0;
let blocked = 0;
for (const row of rows) {
if (!selected.has(row.identity)) continue;
if (options.todayWork.contains(row.item)) {
const index = remaining.indexOf(row.identity);
if (index >= 0) remaining.splice(index, 1);
selected.delete(row.identity);
continue;
}
const added = options.todayWork.add(row.item);
if (added !== 'added') {
blocked += 1;
continue;
}
if (!options.todaySync.enqueue('add', row.identity)) {
options.todayWork.remove?.(row.item);
blocked += 1;
continue;
}
const index = remaining.indexOf(row.identity);
if (index >= 0) remaining.splice(index, 1);
selected.delete(row.identity);
scheduled += 1;
}
write(remaining);
if (scheduled) await options.todaySync.flush();
return { scheduled, blocked, remaining:remaining.length };
}
function mount({ qs, escapeHtml, onComplete = () => {} }) {
const list = qs('#today-handoff-items');
let prompted = false;
function open() {
if (prompted) return false;
const rows = render(null, list, escapeHtml);
if (!rows.length) return false;
qs('#today-handoff-status').textContent = rows.length +
(rows.length === 1 ? ' commitment is ready to review.' : ' commitments are ready to review.');
const dialog = qs('#today-handoff-dialog');
if (!dialog.open) dialog.showModal();
prompted = true;
requestAnimationFrame(() => (list.querySelector('input') || qs('#confirm-today-handoff')).focus());
return true;
}
qs('#close-today-handoff').addEventListener('click', () => qs('#today-handoff-dialog').close());
qs('#confirm-today-handoff').addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
qs('#today-handoff-status').textContent = 'Saving Today’s plan…';
try {
const result = await commit();
if (result.blocked) qs('#today-handoff-status').textContent =
'Today is full. ' + result.remaining + ' commitment remains safe in My Work.';
else qs('#today-handoff-dialog').close();
onComplete(result);
} finally {
button.disabled = false;
}
});
return { open, reset:() => { prompted = false; } };
}
return { capture, choose, commit, mount, pending, render, review };
});
+1 -1
View File
@@ -33,7 +33,7 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
for (const item of items) {
const identity = todayWork.identity(item);
if (!selected.has(identity) || !todayWork.contains(item)) continue;
const deferred = laterWork.defer(item, wake);
const deferred = laterWork.defer(item, wake, {handoff:'today'});
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
if (!todaySync.enqueue('remove', identity)) {
laterWork.restore?.(item);