feat: reconcile Today with due Week Ahead work (Closes #1200)
This commit is contained in:
1 parent
5ac90d01e6
commit
a10592c349
5 files changed
+280
-24
No files matched your search
@@ -186,6 +186,54 @@ def test_week_promotion_preserves_nonempty_today_and_the_due_week(tmp_path):
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
|
||||
def test_week_reconciliation_atomically_combines_today_and_consumes_only_due_day(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"c" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "active", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
result = store.reconcile_week(
|
||||
"timmy", promotion_id="reconcile-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
ids=["issue:r:active:", "issue:secret/repo:3:"], capacity_minutes=120,
|
||||
estimates={"issue:r:active:": 30, "issue:secret/repo:3:": 60},
|
||||
)
|
||||
replay = store.reconcile_week(
|
||||
"timmy", promotion_id="reconcile-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
ids=["issue:r:active:", "issue:secret/repo:3:"], capacity_minutes=120,
|
||||
estimates={"issue:r:active:": 30, "issue:secret/repo:3:": 60},
|
||||
)
|
||||
|
||||
assert replay == result
|
||||
assert result["today"]["ids"] == ["issue:r:active:", "issue:secret/repo:3:"]
|
||||
assert result["week"]["days"] == [sample_days()[1]]
|
||||
assert store.get("timmy") == result["today"]
|
||||
assert store.get_week("timmy") == result["week"]
|
||||
|
||||
|
||||
def test_week_reconciliation_rejects_work_outside_preserved_plans_without_partial_write(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"c" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "active", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
|
||||
with pytest.raises(ValueError, match="selected work must come from Today or the due Week Ahead day"):
|
||||
store.reconcile_week(
|
||||
"timmy", promotion_id="bad", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
ids=["issue:other/repo:99:"], capacity_minutes=60,
|
||||
estimates={"issue:other/repo:99:": 30},
|
||||
)
|
||||
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_promotion_api_returns_both_preserved_plans_for_review(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
@@ -213,6 +261,31 @@ async def test_week_promotion_api_returns_both_preserved_plans_for_review(monkey
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_reconciliation_api_returns_atomic_today_and_remaining_week(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"a" * 32,
|
||||
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
today = store.apply("timmy", "active", "add", "issue:r:active:")
|
||||
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
|
||||
result = await main.reconcile_week_plan(main.WeekReconciliation(
|
||||
promotion_id="reconcile-2026-08-21-r1", week_revision=week["revision"],
|
||||
plan_date="2026-08-21", today_revision=today["revision"],
|
||||
ids=["issue:r:active:", "issue:secret/repo:3:"], capacity_minutes=120,
|
||||
estimates={"issue:r:active:": 30, "issue:secret/repo:3:": 60},
|
||||
))
|
||||
|
||||
assert result["today"]["ids"] == ["issue:r:active:", "issue:secret/repo:3:"]
|
||||
assert result["week"]["days"] == [sample_days()[1]]
|
||||
|
||||
|
||||
def test_existing_tomorrow_plan_migrates_into_week_without_losing_planning_data(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"m" * 32)
|
||||
tomorrow = store.replace_tomorrow(
|
||||
|
||||
@@ -572,32 +572,57 @@ console.log(JSON.stringify({promoted,requests}));
|
||||
}]
|
||||
|
||||
|
||||
def test_week_workflow_routes_blocked_rollover_into_today_review():
|
||||
def test_week_controller_reconciles_reviewed_today_and_adopts_both_results():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
const week=createWeekPlan({fetchJson:async(url,options={})=>{
|
||||
const body=JSON.parse(options.body);requests.push({url,body});
|
||||
return {today:{revision:5,ids:body.ids,capacity_minutes:body.capacity_minutes,estimates:body.estimates},
|
||||
week:{revision:7,timezone:'UTC',days:[{plan_date:'2026-08-23',ids:['later'],capacity_minutes:30,estimates:{later:30}}]}};
|
||||
},localDate:()=> '2026-08-23',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:6,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['due'],capacity_minutes:60,estimates:{due:30}}]});
|
||||
const reconciled=await week.reconcile({today:{revision:4,ids:['unfinished']},week:week.state()},
|
||||
{ids:['unfinished','due'],capacity_minutes:90,estimates:{unfinished:30,due:30}});
|
||||
console.log(JSON.stringify({reconciled,requests,state:week.state()}));
|
||||
""")
|
||||
|
||||
assert result["requests"][0]["url"] == "api/v1/week/reconcile"
|
||||
assert result["requests"][0]["body"]["ids"] == ["unfinished", "due"]
|
||||
assert result["requests"][0]["body"]["today_revision"] == 4
|
||||
assert result["state"]["revision"] == 7
|
||||
assert result["state"]["days"][0]["ids"] == ["later"]
|
||||
assert result["reconciled"]["today"]["ids"] == ["unfinished", "due"]
|
||||
|
||||
|
||||
def test_week_workflow_routes_blocked_rollover_into_atomic_combined_review():
|
||||
result = run_controller("""
|
||||
const createWorkflow=createWeekPlan.Workflow;
|
||||
const elements={'#mobile-week-summary':{textContent:''},'#my-work-action-status':{textContent:''}};
|
||||
let opened=0,replaced=0,refreshed=0;
|
||||
let opened=0,reconciled=null,replaced=[];
|
||||
const blocked=new Error('blocked');blocked.code='week_today_in_progress';
|
||||
const workflow=createWorkflow({
|
||||
controller:{load:async()=>({}),promote:async()=>{throw blocked;},summary:()=> '1 item across 1 day'},
|
||||
qs:selector=>elements[selector],getLogin:()=> 'timmy',openPlanner:()=>{opened+=1;},
|
||||
blocked.today={revision:4,ids:['unfinished'],capacity_minutes:45,estimates:{unfinished:30}};
|
||||
blocked.week={revision:6,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['due','unfinished'],capacity_minutes:90,estimates:{due:45,unfinished:30}}]};
|
||||
const controller={load:async()=>({}),promote:async()=>{throw blocked;},state:()=>blocked.week,summary:()=> '1 item across 1 day',
|
||||
reconcile:async(preserved,plan)=>{reconciled={preserved,plan};return {today:{revision:5,...plan},week:{revision:7,timezone:'UTC',days:[]}};}};
|
||||
const workflow=createWorkflow({controller,qs:selector=>elements[selector],getLogin:()=> 'timmy',openPlanner:()=>{opened+=1;},
|
||||
escapeHtml:value=>value,escapeAttribute:value=>value,
|
||||
todayWork:{replace:()=>{replaced+=1;},replacePlanning:()=>{replaced+=1;}},
|
||||
refresh:()=>{refreshed+=1;},warm:()=>{},
|
||||
todayWork:{replace:value=>replaced.push(value),replacePlanning:value=>replaced.push(value)},refresh:()=>{},warm:()=>{},
|
||||
});
|
||||
const promoted=await workflow.promote({revision:4,ids:['unfinished']});
|
||||
const retried=await workflow.promote({revision:4,ids:['unfinished']});
|
||||
console.log(JSON.stringify({promoted,retried,opened,replaced,refreshed,status:elements['#my-work-action-status'].textContent}));
|
||||
const review={copy:workflow.copy(),day:workflow.day()};
|
||||
const saved=workflow.save({ids:['unfinished','due'],capacity_minutes:90,estimates:{unfinished:30,due:45}});
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
console.log(JSON.stringify({promoted,opened,review,saved,reconciled,replaced,status:elements['#my-work-action-status'].textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"promoted": False,
|
||||
"retried": False,
|
||||
"opened": 1,
|
||||
"replaced": 0,
|
||||
"refreshed": 0,
|
||||
"status": "Review unfinished Today before starting the saved Week Ahead day.",
|
||||
}
|
||||
assert result["promoted"] is False
|
||||
assert result["opened"] == 1
|
||||
assert result["review"]["copy"]["title"] == "Start today's plan"
|
||||
assert result["review"]["day"]["ids"] == ["unfinished", "due"]
|
||||
assert result["saved"] is True
|
||||
assert result["reconciled"]["plan"]["ids"] == ["unfinished", "due"]
|
||||
assert result["replaced"][0] == ["unfinished", "due"]
|
||||
assert result["status"] == "Today started from unfinished and Week Ahead work."
|
||||
|
||||
|
||||
def test_week_controller_does_not_promote_a_pending_week():
|
||||
@@ -621,6 +646,7 @@ def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
|
||||
assert 'data-mobile-queue="week"' in index
|
||||
assert 'id="week-plan-dates"' in index
|
||||
assert '<script src="static/week-plan.js"></script>' in index
|
||||
|
||||
Reference in new issue
Block a user