feat: review Today work at day rollover (Closes #603)
This commit is contained in:
1 parent
8dc155e49a
commit
14a3e1ee8d
20 files changed
+408
-53
No files matched your search
@@ -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-v92" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
@@ -162,7 +162,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-v92';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v93';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v92" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
@@ -41,7 +41,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-v92" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v92" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -333,6 +333,6 @@ 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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -135,7 +135,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -144,14 +144,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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -159,7 +159,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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -167,14 +167,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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -183,21 +183,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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -638,7 +638,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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -687,6 +687,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
|
||||
@@ -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-v92';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v93';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayStore
|
||||
|
||||
|
||||
ROLLOVER = Path(__file__).parents[1] / "frontend" / "today-rollover.js"
|
||||
SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_rollover_replaces_a_stale_plan_atomically_and_is_idempotent(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", limit=3)
|
||||
store.apply_batch("timmy", [
|
||||
{"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:"},
|
||||
{"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:"},
|
||||
{"operation_id": "seed-plan", "action": "configure", "item_id": "plan",
|
||||
"capacity_minutes": 180,
|
||||
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45}},
|
||||
])
|
||||
operation = {
|
||||
"operation_id": "roll-2026-08-13", "action": "rollover", "item_id": "plan",
|
||||
"plan_date": "2026-08-13", "timezone": "America/New_York",
|
||||
"ids": ["issue:r:2:"], "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:2:": 40}, "base_revision": 3,
|
||||
}
|
||||
|
||||
rolled = store.apply_batch("timmy", [operation])
|
||||
replay = store.apply_batch("timmy", [operation])
|
||||
|
||||
assert rolled == {
|
||||
"revision": 4, "ids": ["issue:r:2:"], "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:2:": 40}, "plan_date": "2026-08-13",
|
||||
"timezone": "America/New_York", "accepted_operation_ids": ["roll-2026-08-13"],
|
||||
"duplicate_operation_ids": [], "rejected_operations": [],
|
||||
}
|
||||
assert replay["revision"] == 4
|
||||
assert replay["duplicate_operation_ids"] == ["roll-2026-08-13"]
|
||||
assert TodayStore(store.path).get("timmy")["plan_date"] == "2026-08-13"
|
||||
edited = store.apply("timmy", "after-roll", "add", "issue:r:3:")
|
||||
assert edited["plan_date"] == "2026-08-13"
|
||||
assert edited["timezone"] == "America/New_York"
|
||||
|
||||
|
||||
def test_stale_rollover_cannot_replace_a_newer_device_plan(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
store.apply("timmy", "seed", "add", "issue:r:1:")
|
||||
store.apply("timmy", "newer", "add", "issue:r:2:")
|
||||
|
||||
result = store.apply_batch("timmy", [{
|
||||
"operation_id": "stale-roll", "action": "rollover", "item_id": "plan",
|
||||
"plan_date": "2026-08-13", "timezone": "UTC", "ids": ["issue:r:1:"],
|
||||
"capacity_minutes": 60, "estimates": {"issue:r:1:": 30}, "base_revision": 1,
|
||||
}])
|
||||
|
||||
assert result["ids"] == ["issue:r:1:", "issue:r:2:"]
|
||||
assert result["rejected_operations"] == [
|
||||
{"operation_id": "stale-roll", "reason": "stale_intent"}
|
||||
]
|
||||
|
||||
|
||||
def test_rollover_rejects_invalid_calendar_metadata_and_duplicate_items(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
base = {"operation_id": "roll", "action": "rollover", "item_id": "plan",
|
||||
"plan_date": "08/13/2026", "timezone": "UTC", "ids": []}
|
||||
with pytest.raises(ValueError, match="plan_date"):
|
||||
store.apply_batch("timmy", [base])
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
store.apply_batch("timmy", [{**base, "plan_date": "2026-08-13",
|
||||
"ids": ["issue:r:1:", "issue:r:1:"]}])
|
||||
|
||||
|
||||
def test_today_api_model_accepts_a_bounded_rollover():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="roll", action="rollover", item_id="plan", base_revision=7,
|
||||
plan_date="2026-08-13", timezone="America/New_York",
|
||||
ids=["issue:r:1:"], capacity_minutes=120, estimates={"issue:r:1:": 45},
|
||||
)
|
||||
|
||||
assert operation.model_dump()["plan_date"] == "2026-08-13"
|
||||
assert operation.model_dump()["timezone"] == "America/New_York"
|
||||
assert operation.model_dump()["ids"] == ["issue:r:1:"]
|
||||
|
||||
|
||||
def test_same_day_is_current_but_prior_and_legacy_plans_require_review():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(ROLLOVER))});
|
||||
const rollover = create({{ localDate:()=> '2026-08-13', timeZone:()=> 'America/New_York' }});
|
||||
process.stdout.write(JSON.stringify([
|
||||
rollover.reviewState({{plan_date:'2026-08-13',ids:['one']}}),
|
||||
rollover.reviewState({{plan_date:'2026-08-12',ids:['one']}}),
|
||||
rollover.reviewState({{ids:['one']}}),
|
||||
rollover.reviewState({{ids:[]}}),
|
||||
]));
|
||||
"""
|
||||
assert run_node(script) == ["current", "stale", "legacy", "empty"]
|
||||
|
||||
|
||||
def test_rollover_preserves_selected_order_and_only_selected_estimates():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(ROLLOVER))});
|
||||
const rollover = create({{localDate:()=> '2026-08-13',timeZone:()=> 'America/New_York'}});
|
||||
process.stdout.write(JSON.stringify(rollover.operation({{
|
||||
operation_id:'roll-1', base_revision:7, selected_ids:['two','one'], capacity_minutes:120,
|
||||
estimates:{{one:30,two:45,removed:60}}
|
||||
}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"operation_id": "roll-1", "action": "rollover", "item_id": "plan",
|
||||
"base_revision": 7, "plan_date": "2026-08-13",
|
||||
"timezone": "America/New_York", "ids": ["two", "one"],
|
||||
"capacity_minutes": 120, "estimates": {"one": 30, "two": 45},
|
||||
}
|
||||
|
||||
|
||||
def test_calendar_day_comes_from_local_parts_not_elapsed_hours():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(ROLLOVER))});
|
||||
const date = new Date('2026-11-01T05:30:00Z');
|
||||
const rollover = create({{ now:()=>date, resolvedTimeZone:()=> 'America/New_York' }});
|
||||
process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.timeZone()}}));
|
||||
"""
|
||||
assert run_node(script) == {"date": "2026-11-01", "zone": "America/New_York"}
|
||||
|
||||
|
||||
def test_rollover_is_one_durable_sync_operation_with_the_server_revision():
|
||||
script = f"""
|
||||
const createSync = require({json.dumps(str(SYNC))});
|
||||
const createRollover = require({json.dumps(str(ROLLOVER))});
|
||||
const values = new Map(); let delivered;
|
||||
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=createSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'roll-1',
|
||||
fetchJson:async(_url,options)=>{{delivered=JSON.parse(options.body).operations;return {{revision:8,ids:['two'],plan_date:'2026-08-13',timezone:'UTC',accepted_operation_ids:['roll-1'],duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteIds:()=>{{}},onStatus:()=>{{}}}});
|
||||
const rollover=createRollover({{localDate:()=> '2026-08-13',timeZone:()=> 'UTC'}});
|
||||
const queued=sync.enqueueRollover(rollover.operation({{operation_id:'ignored',base_revision:7,
|
||||
selected_ids:['two'],capacity_minutes:90,estimates:{{two:45}}}}));
|
||||
(async()=>{{const before=sync.pending();await sync.flush();process.stdout.write(JSON.stringify({{queued,before,delivered,after:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["queued"] is True
|
||||
assert result["before"] == result["delivered"]
|
||||
assert result["before"][0]["operation_id"] == "roll-1"
|
||||
assert result["before"][0]["action"] == "rollover"
|
||||
assert result["after"] == []
|
||||
|
||||
|
||||
def test_rollover_review_is_wired_into_the_offline_mobile_shell():
|
||||
root = Path(__file__).parents[1]
|
||||
index = (root / "frontend" / "index.html").read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert '<script src="static/today-rollover.js"></script>' in index
|
||||
assert "BASE + 'static/today-rollover.js'" in worker
|
||||
assert "todayRollover.reviewState(plan)" in dashboard
|
||||
assert "Review yesterday’s unfinished work" in dashboard
|
||||
assert "todaySync.enqueueRollover" in dashboard
|
||||
assert ".plan-today-header button { min-width:44px; min-height:44px;" in css
|
||||
@@ -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-v92" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user