feat: plan tomorrow independently (Closes #1152)
This commit is contained in:
1 parent
664e5e999f
commit
0cb7328aa1
15 files changed
+632
-25
No files matched your search
@@ -219,6 +219,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#work-settings-toggle").click()
|
||||
tomorrow = page.locator("#plan-tomorrow")
|
||||
expect(tomorrow).to_be_visible()
|
||||
tomorrow_bounds = tomorrow.bounding_box()
|
||||
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
|
||||
tomorrow.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
|
||||
expect(page.locator("#save-and-start-today")).to_be_hidden()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||
expect(delivery_queue).to_be_visible()
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
"today-timer", "security-center", "planning",
|
||||
}
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
@@ -71,6 +71,9 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createQueueToday" not in first.runtime_bytes
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createPlanToday" not in first.runtime_bytes
|
||||
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||
|
||||
@@ -1212,6 +1212,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-readiness.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/tomorrow-plan.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayStore, TomorrowPlanConflict, TodayPromotionConflict
|
||||
|
||||
|
||||
def test_tomorrow_plan_is_encrypted_scoped_and_promotes_atomically_exactly_once(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
key = b"n" * 32
|
||||
store = TodayStore(path, encryption_key=key)
|
||||
store.apply("timmy", "today-1", "add", "issue:r:1:")
|
||||
|
||||
tomorrow = store.replace_tomorrow(
|
||||
"Timmy", base_revision=0,
|
||||
ids=["issue:secret/repo:3:", "issue:secret/repo:2:"],
|
||||
capacity_minutes=150,
|
||||
estimates={"issue:secret/repo:3:": 60, "issue:secret/repo:2:": 45},
|
||||
plan_date="2026-08-20", timezone="America/Los_Angeles",
|
||||
)
|
||||
|
||||
assert tomorrow == {
|
||||
"revision": 1,
|
||||
"ids": ["issue:secret/repo:3:", "issue:secret/repo:2:"],
|
||||
"capacity_minutes": 150,
|
||||
"estimates": {"issue:secret/repo:3:": 60, "issue:secret/repo:2:": 45},
|
||||
"plan_date": "2026-08-20",
|
||||
"timezone": "America/Los_Angeles",
|
||||
}
|
||||
assert store.get("timmy")["ids"] == ["issue:r:1:"]
|
||||
assert store.get_tomorrow("alexander")["ids"] == []
|
||||
retained = path.read_bytes()
|
||||
assert b"issue:secret/repo" not in retained
|
||||
assert b"America/Los_Angeles" not in retained
|
||||
|
||||
promoted = store.promote_tomorrow(
|
||||
"timmy", promotion_id="rollover-2026-08-20",
|
||||
tomorrow_revision=1, today_revision=1,
|
||||
)
|
||||
replay = store.promote_tomorrow(
|
||||
"timmy", promotion_id="rollover-2026-08-20",
|
||||
tomorrow_revision=1, today_revision=1,
|
||||
)
|
||||
|
||||
assert replay == promoted
|
||||
assert promoted["ids"] == tomorrow["ids"]
|
||||
assert promoted["capacity_minutes"] == 150
|
||||
assert promoted["plan_date"] == "2026-08-20"
|
||||
assert store.get_tomorrow("timmy") == {
|
||||
"revision": 2, "ids": [], "capacity_minutes": None, "estimates": {}
|
||||
}
|
||||
|
||||
|
||||
def test_tomorrow_edit_and_promotion_reject_stale_revisions_without_touching_today(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"s" * 32)
|
||||
original_today = store.apply("timmy", "today", "add", "issue:r:1:")
|
||||
first = store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60,
|
||||
estimates={"issue:r:2:": 30}, plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
|
||||
with pytest.raises(TomorrowPlanConflict) as stale_edit:
|
||||
store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["issue:r:9:"], capacity_minutes=None,
|
||||
estimates={}, plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
assert stale_edit.value.snapshot == first
|
||||
|
||||
store.apply("timmy", "today-changed", "add", "issue:r:3:")
|
||||
with pytest.raises(TodayPromotionConflict) as stale_today:
|
||||
store.promote_tomorrow(
|
||||
"timmy", promotion_id="rollover", tomorrow_revision=1,
|
||||
today_revision=original_today["revision"],
|
||||
)
|
||||
assert stale_today.value.today["ids"] == ["issue:r:1:", "issue:r:3:"]
|
||||
assert store.get_tomorrow("timmy") == first
|
||||
|
||||
|
||||
def test_tomorrow_validation_is_bounded_and_date_specific(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
with pytest.raises(ValueError, match="ISO calendar date"):
|
||||
store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=[], capacity_minutes=None, estimates={},
|
||||
plan_date="tomorrow", timezone="UTC",
|
||||
)
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
store.replace_tomorrow(
|
||||
"timmy", base_revision=0, ids=["same", "same"], capacity_minutes=None,
|
||||
estimates={}, plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tomorrow_api_round_trip_and_conflict_contract(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"a" * 32)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
payload = main.TomorrowPlanUpdate(
|
||||
base_revision=0, ids=["issue:r:2:"], capacity_minutes=90,
|
||||
estimates={"issue:r:2:": 45}, plan_date="2026-08-20", timezone="UTC",
|
||||
)
|
||||
saved = await main.replace_tomorrow_plan(payload)
|
||||
assert await main.get_tomorrow_plan() == saved
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.replace_tomorrow_plan(payload)
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail == {"code": "tomorrow_changed", "snapshot": saved}
|
||||
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
CONTROLLER = FRONTEND / "tomorrow-plan.js"
|
||||
INDEX = FRONTEND / "index.html"
|
||||
CSS = FRONTEND / "dashboard.css"
|
||||
|
||||
|
||||
def run_controller(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createTomorrowPlan = require({json.dumps(str(CONTROLLER))});
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_tomorrow_planner_loads_and_saves_independently_from_today():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
const fetchJson=async (url, options={})=>{
|
||||
requests.push({url,method:options.method||'GET',body:options.body ? JSON.parse(options.body) : null});
|
||||
if ((options.method||'GET') === 'GET') return {
|
||||
revision:2, ids:['issue:r:2:'], capacity_minutes:90,
|
||||
estimates:{'issue:r:2:':45}, plan_date:'2026-08-20', timezone:'UTC'
|
||||
};
|
||||
return {revision:3,...JSON.parse(options.body)};
|
||||
};
|
||||
const planner=createTomorrowPlan({fetchJson, localDate:()=> '2026-08-19', timeZone:()=> 'UTC'});
|
||||
const loaded=await planner.load();
|
||||
const saved=await planner.save({ids:['issue:r:3:'],capacity_minutes:120,estimates:{'issue:r:3:':60}});
|
||||
console.log(JSON.stringify({loaded,saved,requests}));
|
||||
""")
|
||||
|
||||
assert result["loaded"]["ids"] == ["issue:r:2:"]
|
||||
assert result["saved"]["plan_date"] == "2026-08-20"
|
||||
assert result["requests"] == [
|
||||
{"url": "api/v1/tomorrow", "method": "GET", "body": None},
|
||||
{
|
||||
"url": "api/v1/tomorrow",
|
||||
"method": "PUT",
|
||||
"body": {
|
||||
"base_revision": 2,
|
||||
"ids": ["issue:r:3:"],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {"issue:r:3:": 60},
|
||||
"plan_date": "2026-08-20",
|
||||
"timezone": "UTC",
|
||||
},
|
||||
},
|
||||
]
|
||||
assert all(request["url"] != "api/v1/today" for request in result["requests"])
|
||||
|
||||
|
||||
def test_tomorrow_planner_promotes_only_on_matching_local_date():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
const fetchJson=async (url, options={})=>{
|
||||
requests.push({url,body:options.body ? JSON.parse(options.body) : null});
|
||||
return {ids:['issue:r:2:'],revision:8};
|
||||
};
|
||||
const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC',createId:()=> 'rollover-id'});
|
||||
before.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
|
||||
const early=await before.promote(7);
|
||||
const due=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',createId:()=> 'rollover-id'});
|
||||
due.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
|
||||
const promoted=await due.promote(7);
|
||||
console.log(JSON.stringify({early,promoted,requests}));
|
||||
""")
|
||||
|
||||
assert result["early"] is False
|
||||
assert result["promoted"]["revision"] == 8
|
||||
assert result["requests"] == [{
|
||||
"url": "api/v1/tomorrow/promote",
|
||||
"body": {
|
||||
"promotion_id": "rollover-id",
|
||||
"tomorrow_revision": 3,
|
||||
"today_revision": 7,
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>' in index
|
||||
assert ".plan-tomorrow { min-height:44px;" in css
|
||||
assert '<script src="static/tomorrow-plan.js"></script>' in index
|
||||
assert "const tomorrowPlan = createTomorrowPlan" in dashboard
|
||||
assert "qs('#plan-tomorrow').addEventListener('click'" in dashboard
|
||||
assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
|
||||
assert "tomorrowPlan.load()" in dashboard
|
||||
assert "tomorrowPlan.promote(plan.revision)" in dashboard
|
||||
@@ -25,16 +25,23 @@ const status={textContent:''};
|
||||
const document={
|
||||
querySelector(selector) {
|
||||
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
||||
if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
let requested='';
|
||||
const createLoader=options=>({load:async name=>{requested=name + ':' + options.urls[name];}});
|
||||
const requested=[];
|
||||
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
||||
await loadWorkspace({document,createLoader});
|
||||
console.log(JSON.stringify({requested,status:status.textContent}));
|
||||
""")
|
||||
assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""}
|
||||
assert result == {
|
||||
"requested": [
|
||||
"today-timer:feature-workspace-abc.js",
|
||||
"planning:feature-planning-def.js",
|
||||
],
|
||||
"status": "",
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
|
||||
@@ -51,7 +58,7 @@ const schedule=callback=>{callback();};
|
||||
await loadWorkspace({document,createLoader,schedule});
|
||||
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {"attempts": 2, "status": "", "retryHidden": True}
|
||||
assert result == {"attempts": 4, "status": "", "retryHidden": True}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_offers_single_flight_manual_retry_without_reloading():
|
||||
@@ -65,7 +72,7 @@ const document={querySelector(selector) {
|
||||
return null;
|
||||
}};
|
||||
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
|
||||
@@ -74,7 +81,7 @@ await Promise.all([first,second,loading]);
|
||||
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 3,
|
||||
"attempts": 6,
|
||||
"reloads": 0,
|
||||
"offered": {
|
||||
"hidden": False,
|
||||
@@ -109,7 +116,7 @@ const window={
|
||||
addEventListener(name,callback) { listeners[name]=callback; },
|
||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const waiting=Boolean(listeners.online);
|
||||
@@ -118,7 +125,7 @@ await loading;
|
||||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 3,
|
||||
"attempts": 6,
|
||||
"waiting": True,
|
||||
"reloads": 0,
|
||||
"status": "",
|
||||
|
||||
Reference in new issue
Block a user