diff --git a/frontend/card-planning.js b/frontend/card-planning.js
new file mode 100644
index 0000000..dedf168
--- /dev/null
+++ b/frontend/card-planning.js
@@ -0,0 +1,39 @@
+(function (root, factory) {
+ if (typeof module !== 'undefined' && module.exports) module.exports = factory;
+ else root.createCardPlanning = factory;
+})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) {
+ function sync(disclosure) {
+ disclosure.querySelector('summary')?.setAttribute(
+ 'aria-expanded', String(disclosure.open)
+ );
+ }
+
+ function wire() {
+ const disclosures = Array.from(root.querySelectorAll('[data-card-planning]'));
+ disclosures.forEach(disclosure => {
+ if (disclosure.dataset.cardPlanningBound === 'true') return;
+ disclosure.dataset.cardPlanningBound = 'true';
+ sync(disclosure);
+ disclosure.addEventListener('toggle', () => {
+ if (disclosure.open) {
+ disclosures.forEach(other => {
+ if (other === disclosure || !other.open) return;
+ other.open = false;
+ sync(other);
+ });
+ }
+ sync(disclosure);
+ });
+ disclosure.addEventListener('keydown', event => {
+ if (event.key !== 'Escape' || !disclosure.open) return;
+ disclosure.open = false;
+ sync(disclosure);
+ disclosure.querySelector('summary')?.focus();
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ });
+ }
+
+ return { wire };
+});
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 90aed09..912e527 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -121,6 +121,10 @@ textarea { resize: vertical; min-height: 120px; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.today-actions button { min-height:44px; width:100%; }
+.card-planning { min-width:0; }
+.card-planning > summary { display:none; }
+.card-planning:not([open]) > .card-planning-actions { display:grid; }
+.card-planning-actions { display:grid; gap:8px; min-width:0; }
.detail-defer { grid-column:1/-1; max-width:100%; }
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -351,6 +355,11 @@ textarea { resize: vertical; min-height: 120px; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.my-work-list { grid-template-columns:1fr; }
+ .my-work-card { min-width:0; overflow-x:hidden; }
+ .card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
+ .card-planning > summary::-webkit-details-marker { display:none; }
+ .card-planning:not([open]) > .card-planning-actions { display:none; }
+ .card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
.plan-today-panel { width:100%; border-left:0; padding:14px; }
.plan-today-item { grid-template-columns:1fr; }
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index d8b27bc..fc98232 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -1,6 +1,7 @@
(function(){
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
+ const cardPlanning = createCardPlanning(document);
const mobileComposerViewport = createMobileComposerViewport({
viewport: window.visualViewport,
mediaQuery: window.matchMedia('(max-width: 600px)'),
@@ -1215,7 +1216,7 @@
const todayActions = selectedWorkFilter === 'today' ?
'
' :
'';
- const planningActions = todayActions + laterActions;
+ const planningActions = 'Plan or defer
' + todayActions + laterActions + '
';
if (item.is_review) {
return '' + contents + '' + readUpdate + markRead + planningActions + '';
}
@@ -1229,6 +1230,7 @@
}).join('') : '' + (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '
';
+ cardPlanning.wire();
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
diff --git a/frontend/index.html b/frontend/index.html
index de42b17..6e16159 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -582,6 +582,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index f21162c..6a3b886 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -24,6 +24,7 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
+ BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/plan-today.js',
BASE + 'static/today-sync.js',
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index b49e01e..a861f21 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -20,6 +20,87 @@ CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
+CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
+
+
+def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
+ script = f"""
+const createCardPlanning = require({json.dumps(str(CARD_PLANNING))});
+function disclosure(name) {{
+ const listeners = {{}};
+ const attrs = {{}};
+ const summary = {{
+ focusCount: 0,
+ setAttribute(key, value) {{ attrs[key] = value; }},
+ focus() {{ this.focusCount += 1; }},
+ }};
+ return {{
+ name, open: false, dataset: {{}}, summary, attrs,
+ querySelector(selector) {{ return selector === 'summary' ? summary : null; }},
+ addEventListener(type, listener) {{ listeners[type] = listener; }},
+ fire(type, event = {{}}) {{ listeners[type](event); }},
+ }};
+}}
+const first = disclosure('first');
+const second = disclosure('second');
+const controller = createCardPlanning({{
+ querySelectorAll() {{ return [first, second]; }},
+}});
+controller.wire();
+first.open = true;
+first.fire('toggle');
+second.open = true;
+second.fire('toggle');
+const escape = {{
+ key: 'Escape', prevented: 0, stopped: 0,
+ preventDefault() {{ this.prevented += 1; }},
+ stopPropagation() {{ this.stopped += 1; }},
+}};
+second.fire('keydown', escape);
+process.stdout.write(JSON.stringify({{
+ firstOpen: first.open,
+ firstExpanded: first.attrs['aria-expanded'],
+ secondOpen: second.open,
+ secondExpanded: second.attrs['aria-expanded'],
+ secondFocus: second.summary.focusCount,
+ prevented: escape.prevented,
+ stopped: escape.stopped,
+}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+
+ assert json.loads(result.stdout) == {
+ "firstOpen": False,
+ "firstExpanded": "false",
+ "secondOpen": False,
+ "secondExpanded": "false",
+ "secondFocus": 1,
+ "prevented": 1,
+ "stopped": 1,
+ }
+
+
+@pytest.mark.anyio
+async def test_my_work_cards_progressively_disclose_planning_on_phones():
+ html = await dashboard()
+ service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
+
+ assert '' in html
+ assert "const cardPlanning = createCardPlanning(document);" in html
+ assert "cardPlanning.wire();" in html
+ assert (
+ ''
+ 'Plan or defer
'
+ ''
+ ) in html
+ assert '.card-planning > summary { display:none;' in html
+ assert '.card-planning:not([open]) > .card-planning-actions { display:grid;' in html
+ mobile = html.index('@media (max-width: 600px)')
+ assert '.card-planning > summary { min-height:44px; display:flex;' in html[mobile:]
+ assert '.card-planning:not([open]) > .card-planning-actions { display:none;' in html[mobile:]
+ assert "BASE + 'static/card-planning.js'" in service_worker
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 35b38f6..fa931e4 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -340,6 +340,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
+ "/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/today-sync.js",