feat: configure deadline reminder time in device setup (Closes #715)
This commit is contained in:
1 parent
9320016b50
commit
741d545199
18 files changed
+194
-37
No files matched your search
@@ -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-v98" in worker
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v98';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v99';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -347,5 +347,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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,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-v98" in worker
|
||||
assert "stackchain-dashboard-shell-v99" 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-v98" in worker
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -15,16 +15,18 @@ class FakeTarget {
|
||||
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
|
||||
focus() { state.focused = this; }
|
||||
}
|
||||
const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null};
|
||||
const state = {installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null};
|
||||
const launcher = new FakeTarget();
|
||||
const closeButton = new FakeTarget();
|
||||
const sheet = new FakeTarget(); sheet.hidden = true;
|
||||
const installButton = new FakeTarget();
|
||||
const offlineButton = new FakeTarget();
|
||||
const pushButton = new FakeTarget();
|
||||
const deadlineButton = new FakeTarget();
|
||||
const installStatus = new FakeTarget();
|
||||
const offlineStatus = new FakeTarget();
|
||||
const pushStatus = new FakeTarget();
|
||||
const deadlineStatus = new FakeTarget();
|
||||
const readyStatus = new FakeTarget();
|
||||
const escapeTarget = new FakeTarget();
|
||||
const promptCard = new FakeTarget(); promptCard.hidden = true;
|
||||
@@ -41,16 +43,18 @@ let readiness = {
|
||||
install:{state:'complete', detail:'Stackchain is installed.'},
|
||||
offline:{state:'incomplete', detail:'Offline work is off.'},
|
||||
push:{state:'unavailable', detail:'Notifications are unavailable.'},
|
||||
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
|
||||
};
|
||||
const setup = createMobileDeviceSetup({
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
|
||||
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
|
||||
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||
promptStorage, now:() => now,
|
||||
getReadiness:() => readiness,
|
||||
install:async () => { state.installCalls += 1; },
|
||||
enableOffline:async () => { state.offlineCalls += 1; },
|
||||
enablePush:async () => { state.pushCalls += 1; },
|
||||
enableDeadline:async () => { state.deadlineCalls += 1; },
|
||||
});
|
||||
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
@@ -166,6 +170,29 @@ process.stdout.write(JSON.stringify({
|
||||
assert result == {"hidden": True, "launcherFocused": True}
|
||||
|
||||
|
||||
def test_deadline_step_runs_one_setup_action_and_uses_confirmed_readiness():
|
||||
result = run_scenario("""
|
||||
readiness.push = {state:'complete', detail:'New update notifications are enabled.'};
|
||||
readiness.deadline = {state:'incomplete', detail:'Choose when to receive deadline reminders.'};
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
readiness.deadline = {state:'complete', detail:'Deadline reminders enabled for 08:00 local time.'};
|
||||
await deadlineButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:state.deadlineCalls,
|
||||
detail:deadlineStatus.textContent,
|
||||
hidden:deadlineButton.hidden,
|
||||
summary:readyStatus.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"detail": "Deadline reminders enabled for 08:00 local time.",
|
||||
"hidden": True,
|
||||
"summary": "3 of 4 available steps ready.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
@@ -180,13 +207,14 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
|
||||
assert 'id="device-setup-sheet"' in html
|
||||
assert 'aria-labelledby="device-setup-heading"' in html
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push"))
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
|
||||
assert 'id="device-setup-deadline-hour"' in html
|
||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" 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
|
||||
@@ -18,6 +18,7 @@ const deadlineControl = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.deadlineChange = callback,
|
||||
};
|
||||
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
|
||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||
@@ -26,7 +27,7 @@ const registration = {pushManager:{
|
||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus,
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour,
|
||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||
serviceWorker: {ready:Promise.resolve(registration)},
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
@@ -79,7 +80,53 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert body["enabled"] is True
|
||||
assert body["reminder_hour"] == 9
|
||||
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 9:00 local time."
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time."
|
||||
|
||||
|
||||
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
|
||||
await feature.init();
|
||||
deadlineHour.value = '8';
|
||||
await feature.enableDeadline();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["prompts"] == 1
|
||||
assert len(result["subscriptions"]) == 1
|
||||
assert [request[0] for request in result["requests"][-2:]] == [
|
||||
"api/v1/push-subscription", "api/v1/push-subscription/deadlines",
|
||||
]
|
||||
body = json.loads(result["requests"][-1][2])
|
||||
assert body["enabled"] is True
|
||||
assert body["reminder_hour"] == 8
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time."
|
||||
|
||||
|
||||
def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete():
|
||||
restored = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineControl.checked, readiness:feature.deadlineReadiness()}));
|
||||
""")
|
||||
assert restored == {
|
||||
"hour": "17", "checked": True,
|
||||
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time."},
|
||||
}
|
||||
|
||||
denied = run_scenario("""
|
||||
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
|
||||
state.permission = 'denied';
|
||||
await feature.init();
|
||||
deadlineHour.value = '8';
|
||||
await feature.enableDeadline();
|
||||
process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests}));
|
||||
""")
|
||||
assert denied["checked"] is False
|
||||
assert denied["readiness"]["state"] == "incomplete"
|
||||
assert "blocked" in denied["readiness"]["detail"].lower()
|
||||
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
@@ -94,6 +141,7 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
assert 'id="push-updates"' in html
|
||||
assert 'id="push-update-status"' in html
|
||||
assert 'id="push-deadlines"' in html
|
||||
assert 'id="push-deadline-hour"' in html
|
||||
assert 'id="push-deadline-status"' in html
|
||||
assert '<script src="static/push-notifications.js"></script>' in html
|
||||
assert "createPushNotifications({" in dashboard
|
||||
|
||||
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" 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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -169,7 +169,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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" 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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -193,21 +193,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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" 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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" 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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -680,7 +680,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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -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-v98';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v99';" 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-v98" in source
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user