Merge pull request 'Finish the current Today item from the mobile lock screen' (#1033) from timmy/1032-finish-today-lock-screen into main
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m13s
CI / release-candidate (push) Successful in 12s

This commit is contained in:
rockachopa 2026-08-17 17:09:29 +00:00
commit e47e6329cc
6 changed files with 211 additions and 52 deletions

View File

@ -118,7 +118,7 @@ and an active Today session shows the current estimate plus estimated remaining
first previews its Gitea dependencies: unresolved blockers are listed with links and require the first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap. stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
After a reload or installed-app After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps

View File

@ -1945,28 +1945,6 @@
todayRecapView.finish(selectedWorkFilter); todayRecapView.finish(selectedWorkFilter);
}, },
}); });
todayLockScreen = createTodayLockScreen({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
serviceWorker:navigator.serviceWorker,
NotificationRef:window.Notification,
control:qs('#today-lock-screen'),
status:qs('#today-lock-screen-status'),
locationRef:window.location,
historyRef:window.history,
onAction:action => {
const state = timer.snapshot();
if (!state.identity || (action === 'pause' && !state.running) || (action === 'resume' && state.running)) return;
const changed = action === 'pause' ? timer.pause() : timer.resume();
if (changed === false) return;
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
if (item) workSession.reopen(item);
timerView.render();
},
});
todayLockScreen.consumeLaunchAction();
todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
function selectTodayWork() { function selectTodayWork() {
qs('[data-work-filter="today"]').click(); qs('[data-work-filter="today"]').click();
} }
@ -2165,6 +2143,47 @@
announce: message => { qs('#my-work-action-status').textContent = message; }, announce: message => { qs('#my-work-action-status').textContent = message; },
advance: () => runTodayTransition('complete'), advance: () => runTodayTransition('complete'),
}); });
todayLockScreen = createTodayLockScreen({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
serviceWorker:navigator.serviceWorker,
NotificationRef:window.Notification,
control:qs('#today-lock-screen'),
status:qs('#today-lock-screen-status'),
locationRef:window.location,
historyRef:window.history,
onAction:(action, expectedIdentity) => {
const state = timer.snapshot();
if (action === 'complete') {
selectTodayWork();
if (!state.identity || state.identity !== expectedIdentity) {
qs('#my-work-action-status').textContent =
'That lock-screen action is stale. The current Today item was not changed.';
return;
}
const item = todayMyWork.find(entry => todayWork.identity(entry) === expectedIdentity);
if (!item) {
qs('#my-work-action-status').textContent =
'That Today item is no longer available. Nothing was changed.';
return;
}
workSession.reopen(item);
completeTodayItem(item);
timerView.render();
return;
}
if (!state.identity || (action === 'pause' && !state.running) ||
(action === 'resume' && state.running)) return;
const changed = action === 'pause' ? timer.pause() : timer.resume();
if (changed === false) return;
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
if (item) workSession.reopen(item);
timerView.render();
},
});
await todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
await todayLockScreen.consumeLaunchAction();
async function completeOwnershipExitToday(item) { async function completeOwnershipExitToday(item) {
if (!item || !todayWork.remove(item)) return null; if (!item || !todayWork.remove(item)) return null;
todaySync.enqueue('remove', todayWork.identity(item)); todaySync.enqueue('remove', todayWork.identity(item));

View File

@ -420,13 +420,14 @@ self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify()); if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
}); });
async function updateTodayLockScreen(active, running) { async function updateTodayLockScreen(active, running, rawActionToken = '') {
const tag = 'stackchain-today-session'; const tag = 'stackchain-today-session';
if (!active) { if (!active) {
const notifications = await self.registration.getNotifications({ tag }); const notifications = await self.registration.getNotifications({ tag });
notifications.forEach(notification => notification.close()); notifications.forEach(notification => notification.close());
return; return;
} }
const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : '';
await self.registration.showNotification(running ? 'Today session running' : 'Today session paused', { await self.registration.showNotification(running ? 'Today session running' : 'Today session paused', {
body: running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.', body: running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.',
tag, tag,
@ -434,9 +435,9 @@ async function updateTodayLockScreen(active, running) {
silent:true, silent:true,
actions: [ actions: [
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' }, { action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
{ action:'open-today', title:'Open Today' }, ...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
], ],
data: { route:'#/my-work/today' }, data: { route:'#/my-work/today', ...(actionToken ? { actionToken } : {}) },
}); });
} }
@ -459,7 +460,11 @@ self.addEventListener('message', event => {
} }
})()); })());
if (event.data?.type === 'stackchain-today-lock-screen') { if (event.data?.type === 'stackchain-today-lock-screen') {
event.waitUntil(updateTodayLockScreen(event.data.active === true, event.data.running === true)); event.waitUntil(updateTodayLockScreen(
event.data.active === true,
event.data.running === true,
String(event.data.actionToken || '')
));
} }
}); });
@ -553,16 +558,22 @@ async function openCanonicalIssueUrl(rawUrl) {
return client.focus(); return client.focus();
} }
async function applyTodayTimerAction(action) { async function applyTodayTimerAction(action, actionToken = '') {
if (!['pause', 'resume'].includes(action)) return; if (!['pause', 'resume', 'complete'].includes(action)) return;
if (action === 'complete' && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return;
const route = '#/my-work/today'; const route = '#/my-work/today';
const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true }); const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE)); const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (client) { if (client) {
client.postMessage?.({ type:'stackchain-today-timer-action', action }); client.postMessage?.({
type:'stackchain-today-timer-action', action,
...(action === 'complete' ? { actionToken } : {}),
});
return client.focus?.(); return client.focus?.();
} }
const target = new URL(BASE + '?today_timer_action=' + action + route, self.location.origin).href; const query = '?today_timer_action=' + action +
(action === 'complete' ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
const target = new URL(BASE + query + route, self.location.origin).href;
return self.clients.openWindow(target); return self.clients.openWindow(target);
} }
@ -572,11 +583,13 @@ self.addEventListener('notificationclick', event => {
if ( if (
event.notification.tag === 'stackchain-today-session' event.notification.tag === 'stackchain-today-session'
&& route === '#/my-work/today' && route === '#/my-work/today'
&& ['pause-today', 'resume-today', 'open-today', ''].includes(event.action) && ['pause-today', 'resume-today', 'finish-today', 'open-today', ''].includes(event.action)
) { ) {
event.notification.close(); event.notification.close();
if (event.action === 'pause-today' || event.action === 'resume-today') { if (['pause-today', 'resume-today', 'finish-today'].includes(event.action)) {
event.waitUntil(applyTodayTimerAction(event.action === 'pause-today' ? 'pause' : 'resume')); const action = event.action === 'pause-today' ? 'pause' :
event.action === 'resume-today' ? 'resume' : 'complete';
event.waitUntil(applyTodayTimerAction(action, String(event.notification.data?.actionToken || '')));
} else { } else {
event.waitUntil(openWorkRoute(route)); event.waitUntil(openWorkRoute(route));
} }

View File

@ -7,13 +7,57 @@ function createTodayLockScreen({
status, status,
locationRef, locationRef,
historyRef, historyRef,
randomToken = () => {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
},
fingerprint = async (token, identity) => {
const input = new TextEncoder().encode(token + '\0' + identity);
const digest = await globalThis.crypto.subtle.digest('SHA-256', input);
return Array.from(new Uint8Array(digest), value =>
value.toString(16).padStart(2, '0')).join('');
},
onAction = () => {}, onAction = () => {},
}) { }) {
let activeIdentity = '';
const preferenceKey = () => { const preferenceKey = () => {
const login = String(getLogin?.() || '').trim().toLowerCase(); const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : ''; return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : '';
}; };
const supported = Boolean(serviceWorker && NotificationRef); const supported = Boolean(serviceWorker && NotificationRef);
const actionKey = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-lock-screen-action.v1.' + encodeURIComponent(login) : '';
};
const readAction = () => {
const key = actionKey();
if (!key) return null;
try {
const value = JSON.parse(storage?.getItem(key) || 'null');
return typeof value?.token === 'string' && value.token &&
typeof value.fingerprint === 'string' && value.fingerprint ? value : null;
} catch (_error) { return null; }
};
const clearAction = () => {
const key = actionKey();
try { if (key) storage?.removeItem(key); }
catch (_error) { return false; }
return true;
};
const actionFor = async identity => {
const existing = readAction();
if (existing && await fingerprint(existing.token, identity) === existing.fingerprint) return existing;
const key = actionKey();
if (!key) return null;
try {
const token = String(randomToken() || '');
const value = { token, fingerprint:token ? await fingerprint(token, identity) : '' };
if (!value.token) return null;
storage?.setItem(key, JSON.stringify(value));
return value;
} catch (_error) { return null; }
};
const enabled = () => { const enabled = () => {
const key = preferenceKey(); const key = preferenceKey();
return Boolean(key && storage?.getItem(key) === '1'); return Boolean(key && storage?.getItem(key) === '1');
@ -36,9 +80,17 @@ function createTodayLockScreen({
if (!supported) setStatus('Lock-screen controls are not supported on this device.'); if (!supported) setStatus('Lock-screen controls are not supported on this device.');
else if (enabled()) setStatus('Lock-screen Today controls are on.'); else if (enabled()) setStatus('Lock-screen Today controls are on.');
}; };
const consumeAction = action => { const consumeAction = async (action, token = '') => {
if (!enabled() || !['pause', 'resume'].includes(action)) return false; if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false;
onAction(action); if (action !== 'complete') {
onAction(action, null);
return true;
}
const pending = readAction();
if (!pending || !token || !activeIdentity || pending.token !== token ||
await fingerprint(token, activeIdentity) !== pending.fingerprint) return false;
if (!clearAction()) return false;
onAction(action, activeIdentity);
return true; return true;
}; };
const api = { const api = {
@ -66,6 +118,7 @@ function createTodayLockScreen({
async disable() { async disable() {
const key = preferenceKey(); const key = preferenceKey();
if (key) storage?.removeItem(key); if (key) storage?.removeItem(key);
clearAction();
if (control) control.checked = false; if (control) control.checked = false;
setStatus('Lock-screen Today controls are off.'); setStatus('Lock-screen Today controls are off.');
await hide(); await hide();
@ -74,22 +127,30 @@ function createTodayLockScreen({
async sync(snapshot, active) { async sync(snapshot, active) {
if (!enabled() || NotificationRef?.permission !== 'granted') return false; if (!enabled() || NotificationRef?.permission !== 'granted') return false;
const visible = Boolean(active && snapshot?.identity); const visible = Boolean(active && snapshot?.identity);
activeIdentity = visible ? snapshot.identity : '';
const pending = visible ? await actionFor(activeIdentity) : null;
if (!visible) clearAction();
await post({ await post({
type:'stackchain-today-lock-screen', type:'stackchain-today-lock-screen',
active:visible, active:visible,
running:visible && Boolean(snapshot.running), running:visible && Boolean(snapshot.running),
...(pending?.token ? { actionToken:pending.token } : {}),
}); });
if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.');
return true; return true;
}, },
consumeLaunchAction() { consumeAction,
async consumeLaunchAction() {
let url; let url;
try { url = new URL(locationRef?.href || ''); } try { url = new URL(locationRef?.href || ''); }
catch (_error) { return false; } catch (_error) { return false; }
const action = url.searchParams.get('today_timer_action'); const action = url.searchParams.get('today_timer_action');
if (!['pause', 'resume'].includes(action)) return false; if (!['pause', 'resume', 'complete'].includes(action)) return false;
const token = url.searchParams.get('today_action_token') || '';
url.searchParams.delete('today_timer_action'); url.searchParams.delete('today_timer_action');
url.searchParams.delete('today_action_token');
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash); historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
return consumeAction(action); return await consumeAction(action, token);
}, },
render, render,
}; };
@ -98,7 +159,9 @@ function createTodayLockScreen({
else api.disable(); else api.disable();
}); });
serviceWorker?.addEventListener?.('message', event => { serviceWorker?.addEventListener?.('message', event => {
if (event.data?.type === 'stackchain-today-timer-action') consumeAction(String(event.data.action || '')); if (event.data?.type === 'stackchain-today-timer-action') {
consumeAction(String(event.data.action || ''), String(event.data.actionToken || ''));
}
}); });
render(); render();
return api; return api;

View File

@ -138,11 +138,11 @@ async function dispatchMessage(data, ports = []) {{
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }}); listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending; if (pending) await pending;
}} }}
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null) {{ async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null, actionToken = '') {{
let pending; let pending;
listeners.notificationclick({{ listeners.notificationclick({{
action, action,
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}}, notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}}), ...(actionToken ? {{actionToken}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}},
waitUntil: promise => {{ pending = promise; }}, waitUntil: promise => {{ pending = promise; }},
}}); }});
if (pending) await pending; if (pending) await pending;
@ -507,7 +507,7 @@ def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing(
def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification(): def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification():
result = run_worker_scenario( result = run_worker_scenario(
""" """
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:true,identity:'secret/repo#42'}); await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:true,identity:'secret/repo#42',actionToken:'opaque-token-1234567890'});
process.stdout.write(JSON.stringify(state)); process.stdout.write(JSON.stringify(state));
""" """
) )
@ -522,9 +522,9 @@ def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification
"silent": True, "silent": True,
"actions": [ "actions": [
{"action": "pause-today", "title": "Pause"}, {"action": "pause-today", "title": "Pause"},
{"action": "open-today", "title": "Open Today"}, {"action": "finish-today", "title": "Finish current"},
], ],
"data": {"route": "#/my-work/today"}, "data": {"route": "#/my-work/today", "actionToken": "opaque-token-1234567890"},
}, },
} }
] ]
@ -589,6 +589,27 @@ def test_today_lock_screen_action_opens_a_validated_one_shot_route_when_closed()
] ]
def test_finish_today_lock_screen_action_forwards_only_its_opaque_token():
result = run_worker_scenario(
"""
state.clientMessages=[];
state.clientList=[{
url:'https://forge.example/dashboard/#/my-work/today',
postMessage:message=>state.clientMessages.push(message),
focus:async()=>state.focused.push('today'),
}];
await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["clientMessages"] == [{
"type": "stackchain-today-timer-action",
"action": "complete",
"actionToken": "opaque-token-1234567890",
}]
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup(): def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
result = run_worker_scenario( result = run_worker_scenario(
""" """

View File

@ -25,8 +25,10 @@ const NotificationRef = {get permission(){return permission;},requestPermission:
const actions = []; const actions = [];
const locationRef = {href:'https://forge.example/dashboard/#/my-work/today'}; const locationRef = {href:'https://forge.example/dashboard/#/my-work/today'};
const historyRef = {replaceState:(_a,_b,url)=>{locationRef.href=new URL(url, locationRef.href).href;}}; const historyRef = {replaceState:(_a,_b,url)=>{locationRef.href=new URL(url, locationRef.href).href;}};
let tokenCounter = 0;
const tokens = ['opaque-token-1234567890', 'new-opaque-token-0987654321'];
(async()=>{ (async()=>{
const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,onAction:action=>actions.push(action)}); const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,randomToken:()=> tokens[tokenCounter++],fingerprint:async (_token, identity)=>identity.startsWith('issue:') ? 'opaque-issue-fingerprint' : 'opaque-pull-fingerprint',onAction:(action, identity)=>actions.push([action, identity])});
%SCENARIO% %SCENARIO%
})().catch(error=>{console.error(error);process.exit(1)}); })().catch(error=>{console.error(error);process.exit(1)});
""".replace("%SCENARIO%", scenario) """.replace("%SCENARIO%", scenario)
@ -43,11 +45,15 @@ def test_operator_opt_in_syncs_one_privacy_safe_session_notification():
assert result["checked"] is True assert result["checked"] is True
assert result["status"] == "Lock-screen Today controls are on." assert result["status"] == "Lock-screen Today controls are on."
assert result["stored"] == [["stackchain.today-lock-screen.v1.timmy", "1"]] assert result["stored"] == [
["stackchain.today-lock-screen.v1.timmy", "1"],
["stackchain.today-lock-screen-action.v1.timmy", '{"token":"opaque-token-1234567890","fingerprint":"opaque-issue-fingerprint"}'],
]
assert result["messages"][-1] == { assert result["messages"][-1] == {
"type": "stackchain-today-lock-screen", "type": "stackchain-today-lock-screen",
"active": True, "active": True,
"running": True, "running": True,
"actionToken": "opaque-token-1234567890",
} }
assert "secret" not in json.dumps(result) assert "secret" not in json.dumps(result)
@ -72,8 +78,8 @@ def test_valid_notification_action_is_consumed_once_and_removed_from_url():
result = run_scenario(r""" result = run_scenario(r"""
values.set('stackchain.today-lock-screen.v1.timmy','1'); values.set('stackchain.today-lock-screen.v1.timmy','1');
locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today'; locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today';
const consumed = lockScreen.consumeLaunchAction(); const consumed = await lockScreen.consumeLaunchAction();
const second = lockScreen.consumeLaunchAction(); const second = await lockScreen.consumeLaunchAction();
listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}}); listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}});
process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href})); process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href}));
""") """)
@ -81,7 +87,41 @@ def test_valid_notification_action_is_consumed_once_and_removed_from_url():
assert result == { assert result == {
"consumed": True, "consumed": True,
"second": False, "second": False,
"actions": ["pause", "resume"], "actions": [["pause", None], ["resume", None]],
"href": "https://forge.example/dashboard/#/my-work/today",
}
def test_finish_action_is_bound_to_the_exact_active_identity_and_consumed_once():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true);
const first = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
const replay = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
await lockScreen.sync({identity:'pull:private/repo:9:',running:true}, true);
const stale = await lockScreen.consumeAction('complete', 'opaque-token-1234567890');
process.stdout.write(JSON.stringify({first,replay,stale,actions,messages,stored:[...values.entries()]}));
""")
assert result["first"] is True
assert result["replay"] is False
assert result["stale"] is False
assert result["actions"] == [["complete", "issue:private/repo:42:"]]
assert all("private/repo" not in json.dumps(message) for message in result["messages"])
def test_cold_launch_finish_action_is_removed_from_history_before_completion():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true);
locationRef.href='https://forge.example/dashboard/?today_timer_action=complete&today_action_token=opaque-token-1234567890#/my-work/today';
const consumed = await lockScreen.consumeLaunchAction();
process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href}));
""")
assert result == {
"consumed": True,
"actions": [["complete", "issue:private/repo:42:"]],
"href": "https://forge.example/dashboard/#/my-work/today", "href": "https://forge.example/dashboard/#/my-work/today",
} }
@ -111,7 +151,10 @@ def test_lock_screen_flow_is_wired_into_the_packaged_today_journey():
assert '<script src="static/today-lock-screen.js"></script>' in index assert '<script src="static/today-lock-screen.js"></script>' in index
assert index.index('static/today-lock-screen.js') < index.index('static/dashboard.js') assert index.index('static/today-lock-screen.js') < index.index('static/dashboard.js')
assert 'createTodayLockScreen({' in dashboard assert 'createTodayLockScreen({' in dashboard
assert "action === 'pause' ? timer.pause() : timer.resume()" in dashboard assert "action === 'complete'" in dashboard
assert "completeTodayItem(item)" in dashboard
assert "todayLockScreen.consumeLaunchAction()" in dashboard
assert dashboard.index("const completeTodayItem = createTodayCompletion({") < dashboard.index("todayLockScreen.consumeLaunchAction()")
assert "todayLockScreen.sync(snapshot, workSession.checkpointed())" in dashboard assert "todayLockScreen.sync(snapshot, workSession.checkpointed())" in dashboard
assert "todayLockScreen.consumeLaunchAction()" in dashboard assert "todayLockScreen.consumeLaunchAction()" in dashboard
assert '"static/today-lock-screen.js"' in bundle assert '"static/today-lock-screen.js"' in bundle