feat: plan Today by available time (#459)
CI / lint (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 06:53:25 +00:00
1 parent e3c07c2dfb
commit 161977cb67
20 files changed
+570 -73

No files matched your search

+1 -1
View File
@@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
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-v78" in worker
assert "stackchain-dashboard-shell-v79" in worker
+1 -1
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-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
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-v78" in worker
assert "stackchain-dashboard-shell-v79" in worker
+1 -1
View File
@@ -35,7 +35,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-v78" in worker
assert "stackchain-dashboard-shell-v79" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+62 -2
View File
@@ -79,6 +79,54 @@ process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()
}
def test_plan_today_calculates_capacity_and_confirms_overcommitment_before_saving():
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 + ':',
save: plan => saved.push(plan),
}});
planner.open([item(1), item(2)], [item(1), item(2)]);
planner.setCapacity(90);
planner.setEstimate('issue:stackchain/dashboard:1:', 60);
planner.setEstimate('issue:stackchain/dashboard:2:', 45);
const over = planner.snapshot();
const confirmation = planner.commit();
const stillOpen = planner.snapshot().open;
const result = planner.commit({{confirmOverCapacity:true}});
process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, saved}}));
"""
result = run_node(script)
assert result["over"] == {
"open": True,
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
"count": 2,
"limit": 5,
"capacity_minutes": 90,
"estimates": {
"issue:stackchain/dashboard:1:": 60,
"issue:stackchain/dashboard:2:": 45,
},
"planned_minutes": 105,
"remaining_minutes": -15,
"unestimated_count": 0,
"over_capacity": True,
}
assert result["confirmation"] == "confirm-over-capacity"
assert result["stillOpen"] is True
assert result["result"] == "saved"
assert result["saved"] == [{
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
"capacity_minutes": 90,
"estimates": {
"issue:stackchain/dashboard:1:": 60,
"issue:stackchain/dashboard:2:": 45,
},
}]
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
@@ -192,6 +240,17 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert 'id="plan-today"' in html
assert 'id="plan-today-sheet" role="dialog"' in html
assert 'id="plan-today-capacity"' in html
assert 'id="plan-today-available"' in html
assert 'inputmode="numeric" min="15" max="1440"' in html
assert 'data-plan-estimate="' in html
assert 'aria-label="Estimate for ' in html
assert "formatPlanMinutes(state.planned_minutes)" in html
assert "planToday.setCapacity" in html
assert "planToday.setEstimate" in html
assert "todaySync.enqueueConfiguration" in html
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
assert "resetPlanTodayConfirmation()" in html
assert "result === 'confirm-over-capacity'" in html
assert 'id="save-and-start-today"' in html
assert "const planToday = createPlanToday({" in html
assert "todaySync.enqueue('remove'" in html
@@ -226,12 +285,13 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
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
assert "if (result === 'saved')" in html
assert "taskOverlayHistory.leave();" in html
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+10 -10
View File
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -131,14 +131,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-v78" in source
assert "stackchain-dashboard-shell-v79" 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-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -146,7 +146,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-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -154,14 +154,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-v78" in source
assert "stackchain-dashboard-shell-v79" 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-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -170,21 +170,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-v78" in source
assert "stackchain-dashboard-shell-v79" 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-v78" in source
assert "stackchain-dashboard-shell-v79" 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-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -365,7 +365,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-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/queue-today.js'" in source
+1 -1
View File
@@ -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-v78';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v79';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+97 -2
View File
@@ -36,6 +36,8 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
"revision": 1,
"ids": ["issue:stackchain/dashboard:1:"],
"capacity_minutes": None,
"estimates": {},
}
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
@@ -53,9 +55,49 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
"issue:stackchain/dashboard:3:",
"issue:stackchain/dashboard:2:",
],
"capacity_minutes": None,
"estimates": {},
}
assert TodayStore(path, limit=3).get("timmy") == moved
assert store.get("alexander") == {"revision": 0, "ids": []}
assert store.get("alexander") == {
"revision": 0,
"ids": [],
"capacity_minutes": None,
"estimates": {},
}
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, limit=3)
store.apply("timmy", "add-1", "add", "issue:r:1:")
store.apply("timmy", "add-2", "add", "issue:r:2:")
configured = store.apply_batch("timmy", [{
"operation_id": "capacity-1",
"action": "configure",
"item_id": "plan",
"capacity_minutes": 180,
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45, "issue:r:99:": 30},
"base_revision": 2,
}])
store.apply("timmy", "move-2", "move", "issue:r:2:", direction="up")
store.apply("timmy", "remove-1", "remove", "issue:r:1:")
assert configured["capacity_minutes"] == 180
assert configured["estimates"] == {"issue:r:1:": 60, "issue:r:2:": 45}
assert TodayStore(path, limit=3).get("timmy") == {
"revision": 5,
"ids": ["issue:r:2:"],
"capacity_minutes": 180,
"estimates": {"issue:r:2:": 45},
}
assert store.get("alexander") == {
"revision": 0,
"ids": [],
"capacity_minutes": None,
"estimates": {},
}
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
@@ -69,6 +111,8 @@ def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
assert store.get("timmy") == {
"revision": 2,
"ids": ["issue:r:1:", "issue:r:2:"],
"capacity_minutes": None,
"estimates": {},
}
store.apply("timmy", "remove", "remove", "issue:r:1:")
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
@@ -94,6 +138,8 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
assert result == {
"revision": 4,
"ids": ["issue:r:2:", "issue:r:3:"],
"capacity_minutes": None,
"estimates": {},
"accepted_operation_ids": ["one", "two", "remove", "three"],
"duplicate_operation_ids": [],
"rejected_operations": [{"operation_id": "full", "reason": "today_full"}],
@@ -197,6 +243,48 @@ def test_today_api_model_preserves_client_base_revision():
assert operation.model_dump()["base_revision"] == 7
def test_today_api_model_accepts_bounded_capacity_configuration():
operation = main.TodayOperation(
operation_id="capacity", action="configure", item_id="plan",
capacity_minutes=180,
estimates={"issue:r:1:": 60, "issue:r:2:": 45},
base_revision=2,
)
assert operation.model_dump() == {
"operation_id": "capacity",
"action": "configure",
"item_id": "plan",
"direction": None,
"base_revision": 2,
"capacity_minutes": 180,
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45},
}
@pytest.mark.anyio
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
async def user():
return {"login": "timmy"}
calls = []
class Store:
def apply_batch(self, login, operations):
calls.append((login, operations))
return {"revision": 1, "ids": [], "capacity_minutes": 180, "estimates": {}}
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: Store())
payload = main.TodayOperation(
operation_id="capacity", action="configure", item_id="plan", capacity_minutes=180,
)
result = await main.update_today_plan(payload)
assert result["capacity_minutes"] == 180
assert calls == [("timmy", [payload.model_dump()])]
@pytest.mark.anyio
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@@ -246,9 +334,16 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
assert changed.json() == {
"revision": 2,
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
"capacity_minutes": None,
"estimates": {},
"accepted_operation_ids": ["mobile-1", "mobile-2"],
"duplicate_operation_ids": [],
"rejected_operations": [],
}
assert fetched.json() == {"revision": 2, "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"]}
assert fetched.json() == {
"revision": 2,
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
"capacity_minutes": None,
"estimates": {},
}
assert fetched.headers["cache-control"] == "no-store"
+42 -1
View File
@@ -83,10 +83,51 @@ sync.enqueue('add', 'issue:r:1:');
}
def test_today_capacity_configuration_syncs_offline_and_adopts_remote_plan():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map(); let delivered; let adopted; let broadcast;
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const sync = createTodaySync({{
storage, getLogin:()=> 'timmy', createOperationId:()=> 'capacity-op',
createChannel:()=>({{addEventListener() {{}},postMessage:plan=>{{broadcast=plan}},close() {{}}}}),
fetchJson:async(_url, options={{}})=>{{
if (!options.method) return {{revision:2,ids:['issue:r:1:'],capacity_minutes:null,estimates:{{}}}};
delivered=JSON.parse(options.body).operations[0];
return {{revision:3,ids:['issue:r:1:'],capacity_minutes:120,estimates:{{'issue:r:1:':60}},accepted_operation_ids:['capacity-op'],duplicate_operation_ids:[],rejected_operations:[]}};
}},
onRemotePlan:plan=>{{adopted=plan}}, onRemoteIds:()=>{{}}, onStatus:()=>{{}},
}});
sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,adopted,broadcast,pending:sync.pending()}}));}})();
"""
assert json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout) == {
"delivered": {
"operation_id": "capacity-op", "action": "configure", "item_id": "plan",
"direction": None, "capacity_minutes": 120,
"estimates": {"issue:r:1:": 60}, "base_revision": 0,
},
"adopted": {
"revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
"estimates": {"issue:r:1:": 60},
"accepted_operation_ids": ["capacity-op"],
"duplicate_operation_ids": [], "rejected_operations": [],
},
"broadcast": {
"revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
"estimates": {"issue:r:1:": 60},
},
"pending": [],
}
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-v78" in source
assert "stackchain-dashboard-shell-v79" in source
assert "BASE + 'static/today-sync.js'" in source
+39
View File
@@ -64,6 +64,45 @@ process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted,
}
def test_today_capacity_metadata_is_account_scoped_and_prunes_removed_estimates():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let login = 'timmy';
const queue = createTodayWork({{storage, getLogin: () => login}});
queue.replace(['issue:r:1:', 'issue:r:2:']);
const saved = queue.replacePlanning({{capacity_minutes:120,estimates:{{'issue:r:1:':60,'issue:r:2:':45}}}});
queue.replace(['issue:r:2:']);
const timmy = queue.planning();
login = 'alexander';
const alexander = queue.planning();
process.stdout.write(JSON.stringify({{saved,timmy,alexander}}));
"""
assert json.loads(run_node(script)) == {
"saved": True,
"timmy": {"capacity_minutes": 120, "estimates": {"issue:r:2:": 45}},
"alexander": {"capacity_minutes": None, "estimates": {}},
}
def test_today_estimated_runway_follows_current_session_position():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue',repository:'r',number}});
queue.replace(['issue:r:1:','issue:r:2:','issue:r:3:']);
queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}});
process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}}));
"""
assert json.loads(run_node(script)) == {
"first": {"current_minutes": 30, "remaining_minutes": 135},
"second": {"current_minutes": 60, "remaining_minutes": 105},
}
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});