Plan Today in one focused mobile flow #386

Merged
timmy merged 1 commits from timmy/385-plan-today-mobile-flow into main 2026-08-09 08:39:14 +00:00
11 changed files with 344 additions and 11 deletions

View File

@ -82,6 +82,20 @@ textarea { resize: vertical; min-height: 120px; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
.event:last-child { border-bottom: 0; }
.my-work { grid-column: 1 / -1; }
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
.plan-today-sheet[hidden] { display:none; }
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
.plan-today-error { min-height:1.4em; color:#fca5a5; }
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.work-settings { width:100%; }
.work-settings > summary { display:none; }
@ -335,6 +349,10 @@ 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; }
.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%; }
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
.work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }

View File

@ -688,6 +688,87 @@
},
});
let planTodayTrigger = null;
function planTodayItemMarkup(item, selected, index = -1) {
const id = todayWork.identity(item);
const key = escapeHtml(item.key || (item.repository + '#' + (item.number || '')));
const title = escapeHtml(item.title || 'Untitled work');
const controls = selected ?
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === planToday.snapshot().count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
'<button type="button" data-plan-add="' + escAttr(id) + '">Add</button>';
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong></div>' + controls + '</article>';
}
function renderPlanToday() {
const state = planToday.snapshot();
qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected';
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
planTodayItemMarkup(planToday.item(id), true, index)
).join('') : '<p class="muted">No work selected yet.</p>';
const candidates = planToday.candidates();
qs('#plan-today-candidates').innerHTML = candidates.length ? candidates.map(item =>
planTodayItemMarkup(item, false)
).join('') : '<p class="muted">All available work is already selected.</p>';
document.querySelectorAll('[data-plan-add]').forEach(button => button.addEventListener('click', () => {
const result = planToday.toggle(planToday.item(button.dataset.planAdd));
qs('#plan-today-error').textContent = result === 'full' ? 'Today is full. Remove an item before adding another.' : '';
renderPlanToday();
}));
document.querySelectorAll('[data-plan-remove]').forEach(button => button.addEventListener('click', () => {
planToday.toggle(planToday.item(button.dataset.planRemove));
qs('#plan-today-error').textContent = '';
renderPlanToday();
}));
document.querySelectorAll('[data-plan-move]').forEach(button => button.addEventListener('click', () => {
planToday.move(button.dataset.planId, button.dataset.planMove);
renderPlanToday();
document.querySelector('[data-plan-id="' + CSS.escape(button.dataset.planId) + '"][data-plan-move="' + button.dataset.planMove + '"]')?.focus();
}));
}
function closePlanToday() {
planToday.cancel();
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
planTodayTrigger?.focus();
}
function saveTodayPlan(ids) {
const previous = todayWork.read();
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
if (!todayWork.replace(ids)) return false;
refreshMyWorkView();
todaySync.flush();
warmTodayOffline();
qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order.' : 'Today plan cleared.';
return true;
}
const planToday = createPlanToday({
identity: item => todayWork.identity(item),
limit: todayWork.limit,
save: saveTodayPlan,
start: () => {
qs('[data-work-filter="today"]').click();
workSession.start();
},
});
function openPlanToday(trigger) {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
planTodayTrigger = trigger;
planToday.open(todayMyWork, activeMyWork);
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
renderPlanToday();
qs('#cancel-plan-today').focus();
}
const detailDefer = createDetailDefer({
laterWork,
session: workSession,
@ -3402,6 +3483,23 @@
window.addEventListener('online', reconnectLiveData);
qs('#refresh').addEventListener('click', load);
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
});
qs('#save-today-plan').addEventListener('click', () => {
const result = planToday.commit();
if (result === 'saved') closePlanToday();
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', () => {
const result = planToday.commit({ start:true });
if (result === 'saved') {
qs('#plan-today-sheet').hidden = true;
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', () => {
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
if (!sessionItems.length) {

View File

@ -48,6 +48,7 @@
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</div>
<div class="my-work-actions">
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
@ -170,6 +171,29 @@
</aside>
</main>
<div class="plan-today-sheet" id="plan-today-sheet" role="dialog" aria-modal="true" aria-labelledby="plan-today-title" hidden>
<section class="plan-today-panel">
<div class="plan-today-header">
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
<button id="cancel-plan-today" type="button">Cancel</button>
</div>
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
<section aria-labelledby="today-plan-heading">
<h3 id="today-plan-heading">Today, in order</h3>
<div class="plan-today-list" id="plan-today-list"></div>
</section>
<section aria-labelledby="today-candidates-heading">
<h3 id="today-candidates-heading">Available My Work</h3>
<div class="plan-today-candidates" id="plan-today-candidates"></div>
</section>
<div class="plan-today-actions">
<button id="save-today-plan" type="button">Save plan</button>
<button id="save-and-start-today" type="button">Save &amp; start</button>
</div>
</section>
</div>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
@ -558,6 +582,7 @@
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/today-work.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script>

85
frontend/plan-today.js Normal file
View File

@ -0,0 +1,85 @@
function createPlanToday({ identity, save, start, limit = 5 }) {
let openState = false;
let draftIds = [];
let itemsById = new Map();
function cleanItems(items) {
const unique = new Map();
for (const item of items || []) {
const id = identity?.(item);
if (id && !unique.has(id)) unique.set(id, item);
}
return unique;
}
function open(selectedItems, candidates) {
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
draftIds = [];
for (const item of selectedItems || []) {
const id = identity?.(item);
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
}
openState = true;
return snapshot();
}
function toggle(item) {
if (!openState) return 'closed';
const id = identity?.(item);
if (!id) return 'unavailable';
itemsById.set(id, item);
const index = draftIds.indexOf(id);
if (index >= 0) {
draftIds.splice(index, 1);
return 'removed';
}
if (draftIds.length >= limit) return 'full';
draftIds.push(id);
return 'added';
}
function move(id, direction) {
const index = draftIds.indexOf(id);
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
if (!openState || index < 0 || target < 0 || target >= draftIds.length) return false;
[draftIds[index], draftIds[target]] = [draftIds[target], draftIds[index]];
return true;
}
function close() {
openState = false;
draftIds = [];
itemsById = new Map();
}
function cancel() {
close();
return true;
}
function commit({ start: startAfterSave = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
if (save?.(ids) === false) return 'unavailable';
const first = ids.length ? itemsById.get(ids[0]) : null;
close();
if (startAfterSave && first) start?.(first);
return 'saved';
}
function snapshot() {
return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
}
function item(id) {
return itemsById.get(id) || null;
}
function candidates() {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v56';
const CACHE = 'stackchain-dashboard-shell-v57';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@ -25,6 +25,7 @@ const SHELL = [
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/today-work.js',
BASE + 'static/plan-today.js',
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',

View File

@ -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-v56" in source
assert "stackchain-dashboard-shell-v57" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -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-v56" in worker
assert "stackchain-dashboard-shell-v57" in worker

View File

@ -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]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v56" in worker
assert "stackchain-dashboard-shell-v57" in worker

105
tests/test_plan_today.py Normal file
View File

@ -0,0 +1,105 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
PLAN_TODAY = Path(__file__).parents[1] / "frontend" / "plan-today.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_plan_today_drafts_capacity_order_and_cancel_without_writing():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const saved = [];
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
limit: 3,
save: ids => saved.push(ids),
}});
planner.open([item(1), item(2)], [item(1), item(2), item(3), item(4)]);
const add = planner.toggle(item(3));
const full = planner.toggle(item(4));
const moved = planner.move('issue:stackchain/dashboard:3:', 'up');
const snapshot = planner.snapshot();
planner.cancel();
process.stdout.write(JSON.stringify({{add, full, moved, snapshot, afterCancel:planner.snapshot(), saved}}));
"""
assert run_node(script) == {
"add": "added",
"full": "full",
"moved": True,
"snapshot": {
"open": True,
"ids": [
"issue:stackchain/dashboard:1:",
"issue:stackchain/dashboard:3:",
"issue:stackchain/dashboard:2:",
],
"count": 3,
"limit": 3,
},
"afterCancel": {"open": False, "ids": [], "count": 0, "limit": 3},
"saved": [],
}
def test_plan_today_saves_exact_draft_and_starts_first_item_only_after_success():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const calls = [];
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: ids => {{ calls.push(['save', ...ids]); return true; }},
start: first => calls.push(['start', first.number]),
}});
planner.open([item(1)], [item(1), item(2)]);
planner.toggle(item(2));
const result = planner.commit({{start:true}});
process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()}}));
"""
assert run_node(script) == {
"result": "saved",
"calls": [
["save", "issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
["start", 1],
],
"snapshot": {"open": False, "ids": [], "count": 0, "limit": 5},
}
@pytest.mark.anyio
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
html = await dashboard()
assert '<script src="static/plan-today.js"></script>' in html
assert 'id="plan-today"' in html
assert 'id="plan-today-sheet" role="dialog"' in html
assert 'id="plan-today-capacity"' in html
assert 'id="save-and-start-today"' in html
assert "const planToday = createPlanToday({" in html
assert "todaySync.enqueue('remove'" in html
assert "todaySync.enqueue('add'" in html
assert "qs('#plan-today').addEventListener('click'" in html
assert ".plan-today-actions { position:sticky; bottom:0;" in html
assert ".plan-today-actions button { min-height:44px;" in html
assert "padding-bottom:calc(12px + env(safe-area-inset-bottom))" in html
assert ".plan-today-item { grid-template-columns:1fr; }" in html
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v57" in source
assert "BASE + 'static/plan-today.js'" in source

View File

@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v56" in source
assert "stackchain-dashboard-shell-v57" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -116,14 +116,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-v56" in source
assert "stackchain-dashboard-shell-v57" 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-v56" in source
assert "stackchain-dashboard-shell-v57" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -132,21 +132,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-v56" in source
assert "stackchain-dashboard-shell-v57" 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-v56" in source
assert "stackchain-dashboard-shell-v57" 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-v56" in source
assert "stackchain-dashboard-shell-v57" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -341,6 +341,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/today-work.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",

View File

@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
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-v56" in source
assert "stackchain-dashboard-shell-v57" in source
assert "BASE + 'static/today-sync.js'" in source