Make Plan Today browser-back safe on mobile #402
|
|
@ -759,7 +759,11 @@
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePlanToday() {
|
function closePlanToday(navigate = true) {
|
||||||
|
if (navigate) {
|
||||||
|
taskOverlayHistory.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
planToday.cancel();
|
planToday.cancel();
|
||||||
qs('#plan-today-sheet').hidden = true;
|
qs('#plan-today-sheet').hidden = true;
|
||||||
document.body.classList.remove('task-overlay-open');
|
document.body.classList.remove('task-overlay-open');
|
||||||
|
|
@ -788,12 +792,16 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function openPlanToday(trigger) {
|
function openPlanToday(trigger, navigate = true) {
|
||||||
if (!planningOwnerLogin) {
|
if (!planningOwnerLogin) {
|
||||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
planTodayTrigger = trigger;
|
if (trigger) planTodayTrigger = trigger;
|
||||||
|
if (navigate) {
|
||||||
|
taskOverlayHistory.open('plan-today');
|
||||||
|
return;
|
||||||
|
}
|
||||||
planToday.open(todayMyWork, activeMyWork);
|
planToday.open(todayMyWork, activeMyWork);
|
||||||
qs('#plan-today-error').textContent = '';
|
qs('#plan-today-error').textContent = '';
|
||||||
qs('#plan-today-sheet').hidden = false;
|
qs('#plan-today-sheet').hidden = false;
|
||||||
|
|
@ -2618,9 +2626,11 @@
|
||||||
mobileSearchViewport.close();
|
mobileSearchViewport.close();
|
||||||
qs('#open-palette').focus();
|
qs('#open-palette').focus();
|
||||||
}
|
}
|
||||||
|
if (previous === 'plan-today' && kind !== 'plan-today') closePlanToday(false);
|
||||||
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
||||||
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
||||||
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
|
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
|
||||||
|
if (kind === 'plan-today' && previous !== 'plan-today') openPlanToday(planTodayTrigger, false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
taskOverlayHistory.start();
|
taskOverlayHistory.start();
|
||||||
|
|
@ -3578,15 +3588,13 @@
|
||||||
});
|
});
|
||||||
qs('#save-today-plan').addEventListener('click', () => {
|
qs('#save-today-plan').addEventListener('click', () => {
|
||||||
const result = planToday.commit();
|
const result = planToday.commit();
|
||||||
if (result === 'saved') closePlanToday();
|
if (result === 'saved') taskOverlayHistory.leave();
|
||||||
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
||||||
});
|
});
|
||||||
qs('#save-and-start-today').addEventListener('click', () => {
|
qs('#save-and-start-today').addEventListener('click', () => {
|
||||||
const result = planToday.commit({ start:true });
|
const result = planToday.commit({ start:true });
|
||||||
if (result === 'saved') {
|
if (result === 'saved') taskOverlayHistory.leave();
|
||||||
qs('#plan-today-sheet').hidden = true;
|
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
||||||
document.body.classList.remove('task-overlay-open');
|
|
||||||
} else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
|
||||||
});
|
});
|
||||||
qs('#start-work-session').addEventListener('click', () => {
|
qs('#start-work-session').addEventListener('click', () => {
|
||||||
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
|
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v62';
|
const CACHE = 'stackchain-dashboard-shell-v63';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const allowed = new Set(['new', 'find', 'search', 'search-preview']);
|
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today']);
|
||||||
|
|
||||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/later-sync.js'" 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 { 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v62" in worker
|
assert "stackchain-dashboard-shell-v63" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
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 local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v62" in worker
|
assert "stackchain-dashboard-shell-v63" in worker
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,20 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||||
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
|
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_plan_today_wires_cancel_back_and_success_through_overlay_history():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert "taskOverlayHistory.open('plan-today')" in html
|
||||||
|
assert "previous === 'plan-today' && kind !== 'plan-today'" in html
|
||||||
|
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
|
||||||
|
assert "openPlanToday(planTodayTrigger, false)" in html
|
||||||
|
assert "closePlanToday(false)" in html
|
||||||
|
assert "if (result === 'saved') taskOverlayHistory.leave();" in html
|
||||||
|
|
||||||
|
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -130,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -138,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -154,21 +154,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,51 @@ process.stdout.write(JSON.stringify({{left, state:history.state, current:control
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_today_uses_one_history_layer_and_can_leave_after_save():
|
||||||
|
script = f"""
|
||||||
|
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||||
|
const listeners = {{}};
|
||||||
|
const changes = [];
|
||||||
|
const stack = [{{ page:'dashboard' }}];
|
||||||
|
let cursor = 0;
|
||||||
|
const history = {{
|
||||||
|
get state() {{ return stack[cursor]; }},
|
||||||
|
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
|
||||||
|
replaceState(state) {{ stack[cursor] = state; }},
|
||||||
|
back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
|
||||||
|
}};
|
||||||
|
const controller = createTaskOverlayHistory({{
|
||||||
|
history,
|
||||||
|
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
|
||||||
|
onChange(kind, previous) {{ changes.push([kind, previous]); }},
|
||||||
|
}});
|
||||||
|
controller.start();
|
||||||
|
const opened = controller.open('plan-today');
|
||||||
|
const repeated = controller.open('plan-today');
|
||||||
|
const left = controller.leave();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
opened, repeated, left, stack, cursor, current:controller.current(), changes,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"opened": True,
|
||||||
|
"repeated": True,
|
||||||
|
"left": True,
|
||||||
|
"stack": [
|
||||||
|
{"page": "dashboard"},
|
||||||
|
{"page": "dashboard"},
|
||||||
|
],
|
||||||
|
"cursor": 1,
|
||||||
|
"current": None,
|
||||||
|
"changes": [["plan-today", None], [None, "plan-today"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
||||||
html = dashboard_bundle_text()
|
html = dashboard_bundle_text()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v62" in source
|
assert "stackchain-dashboard-shell-v63" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user