feat: control Today from mobile lock screen (Closes #1026)
CI / lint (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m21s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-17 14:47:07 +00:00
1 parent 7ba238fb53
commit a7f4f9714f
17 files changed
+424 -29

No files matched your search

+1 -1
View File
@@ -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-v118" in worker
assert "stackchain-dashboard-shell-v119" in worker
+1 -1
View File
@@ -435,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-v118" in source
assert "stackchain-dashboard-shell-v119" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -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-v118" in worker
assert "stackchain-dashboard-shell-v119" in worker
+1 -1
View File
@@ -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-v118" in worker
assert "stackchain-dashboard-shell-v119" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -223,7 +223,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-v118" in worker
assert "stackchain-dashboard-shell-v119" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+1 -1
View File
@@ -174,5 +174,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v118" in worker
assert "stackchain-dashboard-shell-v119" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+1 -1
View File
@@ -358,7 +358,7 @@ 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-v118" in service_worker
assert "stackchain-dashboard-shell-v119" in service_worker
@pytest.mark.anyio
+1 -1
View File
@@ -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-v118" in source
assert "stackchain-dashboard-shell-v119" 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
+105 -16
View File
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -73,7 +73,10 @@ const context = {{
matchAll: async () => state.clientList || [],
openWindow: async url => {{ state.opened.push(url); }},
}},
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
registration: {{
showNotification: async (title, options) => state.notifications.push({{title,options}}),
getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}],
}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
@@ -165,13 +168,13 @@ async function dispatchPush(payload) {{
def test_offline_activation_migration_rolls_the_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v118" in source
assert "stackchain-dashboard-shell-v119" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v118" in source
assert "stackchain-dashboard-shell-v119" 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 +183,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-v118" in source
assert "stackchain-dashboard-shell-v119" 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 +192,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-v118" in source
assert "stackchain-dashboard-shell-v119" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -197,14 +200,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-v118" in source
assert "stackchain-dashboard-shell-v119" 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-v118" in source
assert "stackchain-dashboard-shell-v119" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -212,7 +215,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-v118" in source
assert "stackchain-dashboard-shell-v119" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -220,7 +223,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-v118" in source
assert "stackchain-dashboard-shell-v119" 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 +233,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-v118" in source
assert "stackchain-dashboard-shell-v119" 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-v118" in source
assert "stackchain-dashboard-shell-v119" 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 +249,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-v118" in source
assert "stackchain-dashboard-shell-v119" 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-v118" in source
assert "stackchain-dashboard-shell-v119" 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-v118" in source
assert "stackchain-dashboard-shell-v119" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -501,6 +504,91 @@ def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing(
assert result["state"]["backgroundResumes"] == 1
def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:true,identity:'secret/repo#42'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [
{
"title": "Today session running",
"options": {
"body": "Your active Today timer is running.",
"tag": "stackchain-today-session",
"renotify": False,
"silent": True,
"actions": [
{"action": "pause-today", "title": "Pause"},
{"action": "open-today", "title": "Open Today"},
],
"data": {"route": "#/my-work/today"},
},
}
]
assert "secret" not in json.dumps(result["notifications"])
def test_inactive_today_message_closes_the_lock_screen_notification():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-today-lock-screen',active:false,running:false});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == []
assert result["closedNotifications"] == 1
def test_private_data_purge_also_removes_the_today_lock_screen_notification():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:()=>{}}]);
process.stdout.write(JSON.stringify(state));
"""
)
assert result["outboxPurges"] == 1
assert result["closedNotifications"] == 1
def test_today_lock_screen_action_updates_an_open_dashboard_without_navigation():
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','pause-today',null,'stackchain-today-session');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["clientMessages"] == [
{"type": "stackchain-today-timer-action", "action": "pause"}
]
assert result["focused"] == ["today"]
assert result["opened"] == []
def test_today_lock_screen_action_opens_a_validated_one_shot_route_when_closed():
result = run_worker_scenario(
"""
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["opened"] == [
"https://forge.example/dashboard/?today_timer_action=resume#/my-work/today"
]
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
result = run_worker_scenario(
"""
@@ -930,7 +1018,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-v118" in source
assert "stackchain-dashboard-shell-v119" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -988,6 +1076,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/work-selection.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-lock-screen.js",
"/dashboard/static/today-session-sync.js",
"/dashboard/static/today-recap.js",
"/dashboard/static/today-wrap-up.js",
+117
View File
@@ -0,0 +1,117 @@
import json
import subprocess
from pathlib import Path
SOURCE = Path(__file__).parents[1] / "frontend" / "today-lock-screen.js"
ROOT = SOURCE.parents[1]
def run_scenario(scenario: str) -> dict:
script = SOURCE.read_text() + r"""
const values = new Map();
const messages = [];
const listeners = {};
const control = {checked:false, disabled:false, addEventListener:(name, fn)=>listeners[name]=fn};
const status = {textContent:''};
const serviceWorker = {
controller:{postMessage:message=>messages.push(message)},
ready:Promise.resolve({active:{postMessage:message=>messages.push(message)}}),
addEventListener:(name, fn)=>listeners['sw-' + name]=fn,
};
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let permission = 'default';
const NotificationRef = {get permission(){return permission;},requestPermission:async()=>{permission='granted';return permission;}};
const actions = [];
const locationRef = {href:'https://forge.example/dashboard/#/my-work/today'};
const historyRef = {replaceState:(_a,_b,url)=>{locationRef.href=new URL(url, locationRef.href).href;}};
(async()=>{
const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,onAction:action=>actions.push(action)});
%SCENARIO%
})().catch(error=>{console.error(error);process.exit(1)});
""".replace("%SCENARIO%", scenario)
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_operator_opt_in_syncs_one_privacy_safe_session_notification():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:secret/repo:42:',running:true,elapsed_ms:125000}, true);
process.stdout.write(JSON.stringify({checked:control.checked,status:status.textContent,messages,stored:[...values.entries()]}));
""")
assert result["checked"] is True
assert result["status"] == "Lock-screen Today controls are on."
assert result["stored"] == [["stackchain.today-lock-screen.v1.timmy", "1"]]
assert result["messages"][-1] == {
"type": "stackchain-today-lock-screen",
"active": True,
"running": True,
}
assert "secret" not in json.dumps(result)
def test_disabling_or_ending_session_removes_notification():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:r:42:',running:false,elapsed_ms:0}, false);
await lockScreen.disable();
process.stdout.write(JSON.stringify({checked:control.checked,messages,stored:[...values.entries()]}));
""")
assert result["checked"] is False
assert result["stored"] == []
assert result["messages"][-2:] == [
{"type": "stackchain-today-lock-screen", "active": False, "running": False},
{"type": "stackchain-today-lock-screen", "active": False, "running": False},
]
def test_valid_notification_action_is_consumed_once_and_removed_from_url():
result = run_scenario(r"""
values.set('stackchain.today-lock-screen.v1.timmy','1');
locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today';
const consumed = lockScreen.consumeLaunchAction();
const second = lockScreen.consumeLaunchAction();
listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}});
process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href}));
""")
assert result == {
"consumed": True,
"second": False,
"actions": ["pause", "resume"],
"href": "https://forge.example/dashboard/#/my-work/today",
}
def test_permission_denial_is_truthful_and_does_not_persist_opt_in():
result = run_scenario(r"""
NotificationRef.requestPermission=async()=>{permission='denied';return permission;};
const enabled = await lockScreen.enable();
process.stdout.write(JSON.stringify({enabled,checked:control.checked,status:status.textContent,stored:[...values.entries()]}));
""")
assert result == {
"enabled": False,
"checked": False,
"status": "Lock-screen controls are blocked in browser settings.",
"stored": [],
}
def test_lock_screen_flow_is_wired_into_the_packaged_today_journey():
index = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'id="today-lock-screen"' in index
assert 'id="today-lock-screen-status"' 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 'createTodayLockScreen({' in dashboard
assert "action === 'pause' ? timer.pause() : timer.resume()" in dashboard
assert "todayLockScreen.sync(snapshot, workSession.checkpointed())" in dashboard
assert "todayLockScreen.consumeLaunchAction()" in dashboard
assert '"static/today-lock-screen.js"' in bundle
+1 -1
View File
@@ -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-v118';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v119';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -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-v118" in source
assert "stackchain-dashboard-shell-v119" in source
assert "BASE + 'static/today-sync.js'" in source