diff --git a/README.md b/README.md
index dd58f76..409c3a5 100644
--- a/README.md
+++ b/README.md
@@ -212,6 +212,8 @@ export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
export STACKCHAIN_PUSH_POLL_SECONDS=30
# Unread updates and deadline reminders run on independent, fixed-cadence
# workers, so a slow channel cannot delay the other or add drift to its ticks.
+# On each phone, Device Setup can enable deadline reminders and choose any local
+# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
# Maximum individual alerts per device and poll before one digest covers the rest.
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 250cb0c..1a438d8 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -38,6 +38,9 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
.device-setup-step p { margin:5px 0 0; }
+.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
+.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
+.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour { min-height:44px; }
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 75ed01a..7264658 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -6037,6 +6037,23 @@
renderMyWork();
if (workSession.active()) workSession.reconcile();
});
+ let pushController = null;
+ for (const selector of [qs('#push-deadline-hour'), qs('#device-setup-deadline-hour')]) {
+ for (let hour = 0; hour < 24; hour += 1) {
+ const option = document.createElement('option');
+ option.value = String(hour);
+ option.textContent = `${String(hour).padStart(2, '0')}:00`;
+ selector.appendChild(option);
+ }
+ selector.value = '9';
+ }
+ qs('#push-deadline-hour').addEventListener('change', event => {
+ qs('#device-setup-deadline-hour').value = event.target.value;
+ if (qs('#push-deadlines').checked) pushController?.changeDeadline();
+ });
+ qs('#device-setup-deadline-hour').addEventListener('change', event => {
+ qs('#push-deadline-hour').value = event.target.value;
+ });
let pushControllerReady = Promise.resolve(null);
if ('serviceWorker' in navigator) {
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
@@ -6046,11 +6063,14 @@
status:qs('#push-update-status'),
deadlineControl:qs('#push-deadlines'),
deadlineStatus:qs('#push-deadline-status'),
+ deadlineHour:qs('#push-deadline-hour'),
notification:window.Notification,
serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson,
});
await controller.init();
+ pushController = controller;
+ qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value;
return controller;
}).catch(error => {
qs('#push-updates').disabled = true;
@@ -6093,6 +6113,14 @@
qs('#push-updates').checked = true;
await controller.change();
},
+ deadlineReadiness:() => pushController?.deadlineReadiness()
+ || {state:'unavailable', detail:'Deadline reminders are unavailable.'},
+ enableDeadline:async () => {
+ const controller = await pushControllerReady;
+ if (!controller) return;
+ qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
+ await controller.enableDeadline();
+ },
});
await deviceSetup.start();
return deviceSetup;
diff --git a/frontend/index.html b/frontend/index.html
index 27cc15d..3f3de8c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -82,6 +82,14 @@
Notify me about new updates
+
+ Remind me about deadlines
+
+
+
+
+
+
@@ -130,6 +138,7 @@
+
diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js
index fdd7f3e..a0ea14a 100644
--- a/frontend/mobile-device-setup.js
+++ b/frontend/mobile-device-setup.js
@@ -8,6 +8,7 @@
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
+ ['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
];
let trigger = options.launcher;
@@ -100,8 +101,10 @@ function mountMobileDeviceSetup(options) {
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
+ deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
- pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
+ pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
+ readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
promptStorage:options.promptStorage,
@@ -118,9 +121,11 @@ function mountMobileDeviceSetup(options) {
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
+ deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
enablePush:options.enablePush,
+ enableDeadline:options.enableDeadline,
});
}
diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js
index 7625576..a89f1be 100644
--- a/frontend/push-notifications.js
+++ b/frontend/push-notifications.js
@@ -2,10 +2,23 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createPushNotifications = factory;
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
- control, status, deadlineControl, deadlineStatus, notification, serviceWorker, fetchJson,
+ control, status, deadlineControl, deadlineStatus, deadlineHour, notification, serviceWorker, fetchJson,
}) {
let configuration = null;
+ function formattedHour(value) {
+ return `${String(Number(value)).padStart(2, '0')}:00`;
+ }
+
+ function deadlineReadiness() {
+ if (!configuration?.available || deadlineControl?.disabled) {
+ return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
+ }
+ return configuration.deadline_enabled
+ ? {state:'complete', detail:`Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`}
+ : {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
+ }
+
function applicationServerKey(value) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
if (typeof atob === 'function') {
@@ -25,12 +38,12 @@
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
}
- async function enable() {
+ async function ensureSubscription() {
const permission = await notification.requestPermission();
if (permission !== 'granted') {
control.checked = false;
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
- return;
+ return null;
}
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
@@ -47,6 +60,12 @@
});
control.checked = true;
status.textContent = 'New update notifications enabled for this device.';
+ configuration.subscribed = true;
+ return subscription;
+ }
+
+ async function enable() {
+ await ensureSubscription();
}
async function change() {
@@ -64,31 +83,45 @@
async function changeDeadline() {
deadlineControl.disabled = true;
+ if (deadlineHour) deadlineHour.disabled = true;
try {
const registration = await serviceWorker.ready;
- const subscription = await registration.pushManager.getSubscription();
+ let subscription = await registration.pushManager.getSubscription();
+ if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
if (deadlineControl.checked && !subscription) {
deadlineControl.checked = false;
- deadlineStatus.textContent = 'Enable new update notifications first.';
- return;
+ deadlineStatus.textContent = status.textContent;
+ return false;
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
await fetchJson('api/v1/push-subscription/deadlines', {
method:'PUT',
headers:{'Content-Type':'application/json'},
- body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:9}),
+ body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour}),
});
+ configuration.deadline_enabled = deadlineControl.checked;
+ configuration.reminder_hour = reminderHour;
+ configuration.timezone = timezone;
deadlineStatus.textContent = deadlineControl.checked
- ? 'Deadline reminders enabled for 9:00 local time.'
+ ? `Deadline reminders enabled for ${formattedHour(reminderHour)} local time.`
: 'Deadline reminders are off for this device.';
+ return true;
} catch (error) {
deadlineControl.checked = !deadlineControl.checked;
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
+ return false;
} finally {
deadlineControl.disabled = false;
+ if (deadlineHour) deadlineHour.disabled = false;
}
}
+ async function enableDeadline() {
+ deadlineControl.checked = true;
+ return changeDeadline();
+ }
+
async function init() {
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
@@ -102,13 +135,14 @@
}
control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
+ if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
status.textContent = configuration.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
- ? `Deadline reminders enabled for ${configuration.reminder_hour}:00 local time.`
+ ? `Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`
: 'Deadline reminders are off for this device.';
}
- return {init, change, changeDeadline};
+ return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
});
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 4d814e9..5757aa9 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v98';
+const CACHE = 'stackchain-dashboard-shell-v99';
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;
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index f7ac843..842e83a 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -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
diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py
index ab3bac1..ed25483 100644
--- a/tests/test_frontend_bundle.py
+++ b/tests/test_frontend_bundle.py
@@ -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';",
)
)
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 4f5dafe..f935d93 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -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
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index dbffa7b..4b4fd70 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -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
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 29a0e9c..b74a21d 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -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():
diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py
index 0efe8d5..b00e141 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -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 '' 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
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 1526e23..e5b128d 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -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
diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py
index a64c316..5379e36 100644
--- a/tests/test_push_frontend.py
+++ b/tests/test_push_frontend.py
@@ -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 '' in html
assert "createPushNotifications({" in dashboard
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 18fba36..dd01c63 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -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
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index 687a3e9..fb5eb46 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -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
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 85debdb..18829aa 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -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