Merge pull request 'Review wrap-up commitments before rebuilding Today' (#993) from timmy/992-review-wrap-up-commitments into main
This commit is contained in:
commit
0f30ca94ab
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
frontend/today-handoff.js
Normal file
154
frontend/today-handoff.js
Normal 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 };
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ FEATURE_SOURCES = {
|
|||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ class LaterStore:
|
|||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
handoff: str | None = None,
|
||||
base_revision: int | None = None,
|
||||
) -> dict:
|
||||
result = self.apply_batch(login, [{
|
||||
|
|
@ -138,6 +139,7 @@ class LaterStore:
|
|||
"action": action,
|
||||
"item_id": item_id,
|
||||
"wake_at": wake_at,
|
||||
"handoff": handoff,
|
||||
"base_revision": base_revision,
|
||||
}])
|
||||
return {"revision": result["revision"], "records": result["records"]}
|
||||
|
|
@ -181,6 +183,9 @@ class LaterStore:
|
|||
wake_at = operation.get("wake_at")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
handoff = operation.get("handoff")
|
||||
if handoff not in {None, "today"}:
|
||||
raise ValueError("unsupported Later handoff")
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
|
|
@ -204,8 +209,9 @@ class LaterStore:
|
|||
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
record = {"wake_at": wake_at, "handoff": handoff} if handoff else wake_at
|
||||
records[item_id] = record
|
||||
changed = before != record
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
|
|
|
|||
|
|
@ -610,6 +610,7 @@ class LaterOperation(BaseModel):
|
|||
action: Literal["defer", "restore"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
wake_at: str | None = Field(default=None, max_length=100)
|
||||
handoff: Literal["today"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
|
|
@ -2628,6 +2629,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
|||
payload.action,
|
||||
payload.item_id,
|
||||
wake_at=payload.wake_at,
|
||||
handoff=payload.handoff,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
except ValueError as error:
|
||||
|
|
|
|||
65
tests/e2e/test_mobile_wrap_up_handoff_release.py
Normal file
65
tests/e2e/test_mobile_wrap_up_handoff_release.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged next-day Today handoff runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.on("pageerror", lambda error: errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Tomorrow review phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate("""localStorage.setItem('stackchain.today-handoff.v1.timmy', JSON.stringify([
|
||||
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
||||
]))""")
|
||||
page.reload(wait_until="networkidle")
|
||||
|
||||
dialog = page.locator("#today-handoff-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(page.locator("#today-handoff-items .today-handoff-item")).to_have_count(2)
|
||||
for control in dialog.locator("button, input").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 24
|
||||
buttons = dialog.locator("button")
|
||||
for control in buttons.all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-today-handoff="issue:acme/mobile:42:"]').uncheck()
|
||||
page.locator("#confirm-today-handoff").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy'))") == [
|
||||
"issue:acme/mobile:41:"
|
||||
]
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-handoff.v1.timmy'))") == [
|
||||
"issue:acme/mobile:42:"
|
||||
]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
|
@ -60,6 +60,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
|||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py -q"
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
|
|
|
|||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
|||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v115" in worker
|
||||
|
|
|
|||
|
|
@ -65,6 +65,30 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
|
|||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
|
||||
legacy = store.apply(
|
||||
"timmy", "legacy", "defer", "issue:r:1:",
|
||||
wake_at="2026-08-17T09:00:00.000Z",
|
||||
)
|
||||
handoff = store.apply(
|
||||
"timmy", "wrap-up", "defer", "issue:r:2:",
|
||||
wake_at="2026-08-17T09:00:00.000Z",
|
||||
handoff="today",
|
||||
)
|
||||
|
||||
assert legacy["records"]["issue:r:1:"] == "2026-08-17T09:00:00.000Z"
|
||||
assert handoff["records"] == {
|
||||
"issue:r:1:": "2026-08-17T09:00:00.000Z",
|
||||
"issue:r:2:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
},
|
||||
}
|
||||
assert LaterStore(store.path).get("timmy") == handoff
|
||||
|
||||
|
||||
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, timeout=0.05)
|
||||
|
|
|
|||
|
|
@ -97,6 +97,35 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
|||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_today_handoff_intent_replays_through_account_sync():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'wrap-up-op',
|
||||
fetchJson:async(_url,options={{}})=>{{
|
||||
const body=JSON.parse(options.body); requests.push(body);
|
||||
return {{revision:1,records:{{[body.operations[0].item_id]:{{wake_at:body.operations[0].wake_at,handoff:body.operations[0].handoff}}}},accepted_operation_ids:['wrap-up-op'],duplicate_operation_ids:[],rejected_operations:[]}};
|
||||
}},onRemoteRecords:records=>{{globalThis.records=records}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-17T09:00:00.000Z','today');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,records:globalThis.records}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["requests"] == [{"operations": [{
|
||||
"operation_id": "wrap-up-op",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
"base_revision": 0,
|
||||
}]}]
|
||||
assert result["records"] == {"issue:r:2:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z", "handoff": "today",
|
||||
}}
|
||||
|
||||
|
||||
def test_later_queue_expires_ancient_edits_before_replay():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
|
|
@ -294,6 +323,26 @@ sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:
|
|||
}
|
||||
|
||||
|
||||
def test_unsynced_wrap_up_handoff_migrates_after_browser_restart():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'migrated-handoff',
|
||||
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:1,records:{{}},accepted_operation_ids:['migrated-handoff'],duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.migrate({{'issue:r:1:':{{wake_at:'2026-08-17T09:00:00.000Z',handoff:'today'}}}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify(requests));}})();
|
||||
"""
|
||||
assert run_node(script) == [{"operations": [{
|
||||
"operation_id": "migrated-handoff", "action": "defer",
|
||||
"item_id": "issue:r:1:", "wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today", "base_revision": 0,
|
||||
}]}]
|
||||
|
||||
|
||||
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
|
|
@ -324,6 +373,45 @@ process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:
|
|||
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||
|
||||
|
||||
def test_later_work_preserves_synced_today_handoff_until_it_wakes():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
const values=new Map(); const changes=[]; const expired=[];
|
||||
let clock=new Date('2026-08-16T20:00:00Z');
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:992,title:'Commitment'}};
|
||||
const work=createLaterWork({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||
onChange:(action,id,wakeAt,handoff)=>changes.push([action,id,wakeAt,handoff||null]),
|
||||
onExpire:(ids,handoffs)=>expired.push([ids,handoffs]),
|
||||
}});
|
||||
work.defer(item,new Date('2026-08-17T09:00:00Z'),{{handoff:'today'}});
|
||||
const stored=work.read();
|
||||
clock=new Date('2026-08-17T09:00:01Z');
|
||||
const awake=work.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{stored,changes,expired,awake:awake.active}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["stored"] == {
|
||||
"issue:stackchain/dashboard:992:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
}
|
||||
}
|
||||
assert result["changes"] == [[
|
||||
"defer", "issue:stackchain/dashboard:992:",
|
||||
"2026-08-17T09:00:00.000Z", "today",
|
||||
]]
|
||||
assert result["expired"] == [[
|
||||
["issue:stackchain/dashboard:992:"],
|
||||
["issue:stackchain/dashboard:992:"],
|
||||
]]
|
||||
assert result["awake"] == [{
|
||||
"kind": "issue", "repository": "stackchain/dashboard",
|
||||
"number": 992, "title": "Commitment",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
html = await dashboard()
|
||||
|
|
@ -332,9 +420,9 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
assert 'id="later-sync-status"' in html
|
||||
assert "const laterSync = createLaterSync({" in html
|
||||
assert "onRemoteRecords: records =>" in html
|
||||
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||
assert "onExpire: ids =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||
assert "onChange: (action, itemId, wakeAt, handoff) =>" in html
|
||||
assert "onExpire: (ids, handoffs) =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in html
|
||||
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
|
|
@ -347,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v115" in worker
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
|||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v115" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
|||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v115" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
|
|
|||
|
|
@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
|||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v114" in service_worker
|
||||
assert "stackchain-dashboard-shell-v115" in service_worker
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -165,13 +165,13 @@ async function dispatchPush(payload) {{
|
|||
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -180,7 +180,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
|
|
@ -189,7 +189,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
|||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
|
|
@ -197,14 +197,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -220,7 +220,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
|
@ -230,14 +230,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -246,21 +246,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -930,7 +930,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
|||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
@ -989,6 +989,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
"/dashboard/static/today-wrap-up.js",
|
||||
"/dashboard/static/today-handoff.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
|
|
|
|||
84
tests/test_today_handoff.py
Normal file
84
tests/test_today_handoff.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HANDOFF = Path(__file__).parents[1] / "frontend" / "today-handoff.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_due_wrap_up_commitments_are_reviewed_in_order_and_added_durably():
|
||||
script = f"""
|
||||
const createHandoff=require({json.dumps(str(HANDOFF))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const items=[
|
||||
{{kind:'issue',repository:'r',number:2,title:'Second'}},
|
||||
{{kind:'issue',repository:'r',number:1,title:'First'}},
|
||||
];
|
||||
const identity=item=>'issue:'+item.repository+':'+item.number+':';
|
||||
const today=[]; const operations=[];
|
||||
const controller=createHandoff({{
|
||||
storage,getLogin:()=> 'timmy',identity,
|
||||
todayWork:{{contains:item=>today.includes(identity(item)),add:item=>{{if(today.length>=1)return 'full';today.push(identity(item));return 'added';}}}},
|
||||
todaySync:{{enqueue:(action,id)=>{{operations.push([action,id]);return true;}},flush:async()=>true}},
|
||||
}});
|
||||
controller.capture(['issue:r:1:','issue:r:2:','issue:r:1:']);
|
||||
const review=controller.review(items);
|
||||
controller.choose('issue:r:2:',false);
|
||||
const first=await controller.commit(items);
|
||||
controller.choose('issue:r:2:',true);
|
||||
const blocked=await controller.commit(items);
|
||||
process.stdout.write(JSON.stringify({{review,first,blocked,today,operations,pending:controller.pending()}}));
|
||||
"""
|
||||
result = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert [row["identity"] for row in result["review"]] == ["issue:r:1:", "issue:r:2:"]
|
||||
assert all(row["selected"] for row in result["review"])
|
||||
assert result["first"] == {"scheduled": 1, "blocked": 0, "remaining": 1}
|
||||
assert result["blocked"] == {"scheduled": 0, "blocked": 1, "remaining": 1}
|
||||
assert result["today"] == ["issue:r:1:"]
|
||||
assert result["operations"] == [["add", "issue:r:1:"]]
|
||||
assert result["pending"] == ["issue:r:2:"]
|
||||
|
||||
|
||||
def test_handoff_pending_state_is_account_scoped_and_capture_is_idempotent():
|
||||
script = f"""
|
||||
const createHandoff=require({json.dumps(str(HANDOFF))});
|
||||
const values=new Map();let login='timmy';
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const options={{storage,getLogin:()=>login,identity:item=>item.id,todayWork:{{contains:()=>false,add:()=> 'added'}},todaySync:{{enqueue:()=>true,flush:async()=>true}}}};
|
||||
const controller=createHandoff(options);
|
||||
controller.capture(['one','two']);controller.capture(['two','one']);
|
||||
const timmy=controller.pending();login='alexander';const isolated=controller.pending();
|
||||
login='timmy';const resumed=createHandoff(options).pending();
|
||||
process.stdout.write(JSON.stringify({{timmy,isolated,resumed}}));
|
||||
"""
|
||||
assert run_node(f"(async()=>{{{script}}})()") == {
|
||||
"timmy": ["one", "two"], "isolated": [], "resumed": ["one", "two"],
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_packages_accessible_mobile_commitment_review_and_account_sync():
|
||||
from src import main
|
||||
|
||||
html = main.FRONTEND_BUILD.dashboard_html
|
||||
dashboard = (HANDOFF.parents[0] / "dashboard.js").read_text()
|
||||
css = (HANDOFF.parents[0] / "dashboard.css").read_text()
|
||||
service_worker = (HANDOFF.parents[0] / "service-worker.js").read_text()
|
||||
|
||||
assert 'id="today-handoff-dialog"' in html
|
||||
assert 'aria-labelledby="today-handoff-title"' in html
|
||||
assert 'id="today-handoff-items"' in html
|
||||
assert 'id="confirm-today-handoff"' in html
|
||||
assert '<script src="static/today-handoff.js"></script>' in (HANDOFF.parents[0] / "index.html").read_text()
|
||||
assert "createTodayHandoff({" in dashboard
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in dashboard
|
||||
assert "todayHandoff.capture(handoffs)" in dashboard
|
||||
assert ".today-handoff-actions button { min-height:44px;" in css
|
||||
assert "BASE + 'static/today-handoff.js'" in service_worker
|
||||
assert "static/today-handoff.js" in main.FRONTEND_BUILD.page_sources
|
||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
|||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v114';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v115';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v115" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const todayWork = {{
|
|||
const laterWork = {{
|
||||
identity:todayWork.identity,
|
||||
presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'),
|
||||
defer:(item, until) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push(todayWork.identity(item)); return 'deferred'; }},
|
||||
defer:(item, until, options) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push([todayWork.identity(item), options]); return 'deferred'; }},
|
||||
}};
|
||||
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
|
||||
const wrap = createWrapUp({{todayWork,laterWork,todaySync}});
|
||||
|
|
@ -52,7 +52,7 @@ process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}}));
|
|||
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
|
||||
assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"}
|
||||
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
|
||||
assert output["laterOps"] == ["issue:stackchain/dashboard:2:"]
|
||||
assert output["laterOps"] == [["issue:stackchain/dashboard:2:", {"handoff": "today"}]]
|
||||
|
||||
|
||||
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user