feat: show timed Today breaks on lock screen (Closes #1050)
This commit is contained in:
parent
af86034339
commit
ed15392097
|
|
@ -421,7 +421,7 @@ 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, rawActionToken = '') {
|
async function updateTodayLockScreen(active, running, rawActionToken = '', rawBreakDeadline = 0) {
|
||||||
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 });
|
||||||
|
|
@ -429,12 +429,24 @@ async function updateTodayLockScreen(active, running, rawActionToken = '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : '';
|
const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : '';
|
||||||
await self.registration.showNotification(running ? 'Today session running' : 'Today session paused', {
|
const now = Date.now();
|
||||||
body: running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.',
|
const breakDeadline = Number(rawBreakDeadline);
|
||||||
|
const onBreak = !running && actionToken && Number.isSafeInteger(breakDeadline) &&
|
||||||
|
breakDeadline > now && breakDeadline <= now + 120 * 60 * 1000;
|
||||||
|
const title = onBreak ? 'On a Today break' :
|
||||||
|
running ? 'Today session running' : 'Today session paused';
|
||||||
|
const body = onBreak ? 'Return at ' + new Date(breakDeadline).toLocaleTimeString([], {
|
||||||
|
hour:'numeric', minute:'2-digit',
|
||||||
|
}) : running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.';
|
||||||
|
await self.registration.showNotification(title, {
|
||||||
|
body,
|
||||||
tag,
|
tag,
|
||||||
renotify:false,
|
renotify:false,
|
||||||
silent:true,
|
silent:true,
|
||||||
actions: [
|
actions: onBreak ? [
|
||||||
|
{ action:'resume-today', title:'Resume now' },
|
||||||
|
{ action:'open-today', title:'Open Today' },
|
||||||
|
] : [
|
||||||
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
|
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
|
||||||
...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
|
...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
|
||||||
],
|
],
|
||||||
|
|
@ -464,7 +476,8 @@ self.addEventListener('message', event => {
|
||||||
event.waitUntil(updateTodayLockScreen(
|
event.waitUntil(updateTodayLockScreen(
|
||||||
event.data.active === true,
|
event.data.active === true,
|
||||||
event.data.running === true,
|
event.data.running === true,
|
||||||
String(event.data.actionToken || '')
|
String(event.data.actionToken || ''),
|
||||||
|
Number(event.data.breakDeadlineAt || 0)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -561,19 +574,20 @@ async function openCanonicalIssueUrl(rawUrl) {
|
||||||
|
|
||||||
async function applyTodayTimerAction(action, actionToken = '') {
|
async function applyTodayTimerAction(action, actionToken = '') {
|
||||||
if (!['pause', 'resume', 'complete'].includes(action)) return;
|
if (!['pause', 'resume', 'complete'].includes(action)) return;
|
||||||
if (action === 'complete' && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return;
|
if (actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return;
|
||||||
|
if (action === 'complete' && !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?.({
|
client.postMessage?.({
|
||||||
type:'stackchain-today-timer-action', action,
|
type:'stackchain-today-timer-action', action,
|
||||||
...(action === 'complete' ? { actionToken } : {}),
|
...(actionToken ? { actionToken } : {}),
|
||||||
});
|
});
|
||||||
return client.focus?.();
|
return client.focus?.();
|
||||||
}
|
}
|
||||||
const query = '?today_timer_action=' + action +
|
const query = '?today_timer_action=' + action +
|
||||||
(action === 'complete' ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
|
(actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
|
||||||
const target = new URL(BASE + query + route, self.location.origin).href;
|
const target = new URL(BASE + query + route, self.location.origin).href;
|
||||||
return self.clients.openWindow(target);
|
return self.clients.openWindow(target);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ function createTodayLockScreen({
|
||||||
onAction = () => {},
|
onAction = () => {},
|
||||||
}) {
|
}) {
|
||||||
let activeIdentity = '';
|
let activeIdentity = '';
|
||||||
|
let activeActionIdentity = '';
|
||||||
|
let activeBreakDeadline = 0;
|
||||||
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) : '';
|
||||||
|
|
@ -82,13 +84,14 @@ function createTodayLockScreen({
|
||||||
};
|
};
|
||||||
const consumeAction = async (action, token = '') => {
|
const consumeAction = async (action, token = '') => {
|
||||||
if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false;
|
if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false;
|
||||||
if (action !== 'complete') {
|
const requiresToken = action === 'complete' || (action === 'resume' && activeBreakDeadline > 0);
|
||||||
|
if (!requiresToken) {
|
||||||
onAction(action, null);
|
onAction(action, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const pending = readAction();
|
const pending = readAction();
|
||||||
if (!pending || !token || !activeIdentity || pending.token !== token ||
|
if (!pending || !token || !activeIdentity || pending.token !== token ||
|
||||||
await fingerprint(token, activeIdentity) !== pending.fingerprint) return false;
|
await fingerprint(token, activeActionIdentity) !== pending.fingerprint) return false;
|
||||||
if (!clearAction()) return false;
|
if (!clearAction()) return false;
|
||||||
onAction(action, activeIdentity);
|
onAction(action, activeIdentity);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -128,12 +131,17 @@ function createTodayLockScreen({
|
||||||
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 : '';
|
activeIdentity = visible ? snapshot.identity : '';
|
||||||
const pending = visible ? await actionFor(activeIdentity) : null;
|
const rawBreakDeadline = Number(snapshot?.break_deadline_at);
|
||||||
|
activeBreakDeadline = visible && Number.isSafeInteger(rawBreakDeadline) && rawBreakDeadline > 0 ?
|
||||||
|
rawBreakDeadline : 0;
|
||||||
|
activeActionIdentity = activeIdentity + (activeBreakDeadline ? '\0break:' + activeBreakDeadline : '');
|
||||||
|
const pending = visible ? await actionFor(activeActionIdentity) : null;
|
||||||
if (!visible) clearAction();
|
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),
|
||||||
|
...(activeBreakDeadline ? { breakDeadlineAt:activeBreakDeadline } : {}),
|
||||||
...(pending?.token ? { actionToken:pending.token } : {}),
|
...(pending?.token ? { actionToken:pending.token } : {}),
|
||||||
});
|
});
|
||||||
if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.');
|
if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.');
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,40 @@ def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification
|
||||||
assert "secret" not in json.dumps(result["notifications"])
|
assert "secret" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_today_break_shows_return_time_and_safe_resume_controls():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
const deadline = Date.now() + 5 * 60 * 1000;
|
||||||
|
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:false,breakDeadlineAt:deadline,identity:'secret/repo#42',actionToken:'opaque-token-1234567890'});
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
notification = result["notifications"][0]
|
||||||
|
assert notification["title"] == "On a Today break"
|
||||||
|
assert notification["options"]["body"].startswith("Return at ")
|
||||||
|
assert notification["options"]["actions"] == [
|
||||||
|
{"action": "resume-today", "title": "Resume now"},
|
||||||
|
{"action": "open-today", "title": "Open Today"},
|
||||||
|
]
|
||||||
|
assert notification["options"]["data"]["actionToken"] == "opaque-token-1234567890"
|
||||||
|
assert "secret" not in json.dumps(notification)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_or_expired_break_deadline_falls_back_to_paused_controls():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:false,breakDeadlineAt:Date.now()-1,actionToken:'opaque-token-1234567890'});
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["notifications"][0]["title"] == "Today session paused"
|
||||||
|
assert result["notifications"][0]["options"]["actions"][0] == {
|
||||||
|
"action": "resume-today", "title": "Resume"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_inactive_today_message_closes_the_lock_screen_notification():
|
def test_inactive_today_message_closes_the_lock_screen_notification():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
@ -610,6 +644,27 @@ def test_finish_today_lock_screen_action_forwards_only_its_opaque_token():
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_break_resume_action_forwards_opaque_token_to_reject_stale_breaks():
|
||||||
|
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','resume-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["clientMessages"] == [{
|
||||||
|
"type": "stackchain-today-timer-action",
|
||||||
|
"action": "resume",
|
||||||
|
"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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const historyRef = {replaceState:(_a,_b,url)=>{locationRef.href=new URL(url, loc
|
||||||
let tokenCounter = 0;
|
let tokenCounter = 0;
|
||||||
const tokens = ['opaque-token-1234567890', 'new-opaque-token-0987654321'];
|
const tokens = ['opaque-token-1234567890', 'new-opaque-token-0987654321'];
|
||||||
(async()=>{
|
(async()=>{
|
||||||
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])});
|
const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,randomToken:()=> tokens[tokenCounter++],fingerprint:async (_token, identity)=>identity.includes('break:1000000') ? 'opaque-break-fingerprint' : 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)
|
||||||
|
|
@ -110,6 +110,29 @@ def test_finish_action_is_bound_to_the_exact_active_identity_and_consumed_once()
|
||||||
assert all("private/repo" not in json.dumps(message) for message in result["messages"])
|
assert all("private/repo" not in json.dumps(message) for message in result["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_break_sync_carries_deadline_and_resume_is_bound_to_that_exact_break():
|
||||||
|
result = run_scenario(r"""
|
||||||
|
await lockScreen.enable();
|
||||||
|
await lockScreen.sync({identity:'issue:private/repo:42:',running:false,break_deadline_at:1000000}, true);
|
||||||
|
const resumed = await lockScreen.consumeAction('resume', 'opaque-token-1234567890');
|
||||||
|
await lockScreen.sync({identity:'issue:private/repo:42:',running:false,break_deadline_at:2000000}, true);
|
||||||
|
const stale = await lockScreen.consumeAction('resume', 'opaque-token-1234567890');
|
||||||
|
process.stdout.write(JSON.stringify({resumed,stale,actions,messages,stored:[...values.entries()]}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["resumed"] is True
|
||||||
|
assert result["stale"] is False
|
||||||
|
assert result["actions"] == [["resume", "issue:private/repo:42:"]]
|
||||||
|
assert result["messages"][0] == {
|
||||||
|
"type": "stackchain-today-lock-screen",
|
||||||
|
"active": True,
|
||||||
|
"running": False,
|
||||||
|
"breakDeadlineAt": 1_000_000,
|
||||||
|
"actionToken": "opaque-token-1234567890",
|
||||||
|
}
|
||||||
|
assert "private/repo" not in json.dumps(result["messages"])
|
||||||
|
|
||||||
|
|
||||||
def test_cold_launch_finish_action_is_removed_from_history_before_completion():
|
def test_cold_launch_finish_action_is_removed_from_history_before_completion():
|
||||||
result = run_scenario(r"""
|
result = run_scenario(r"""
|
||||||
await lockScreen.enable();
|
await lockScreen.enable();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user