feat: recover conflicted Week-to-Today transfers (Closes #1254)
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-22 07:06:33 +00:00
1 parent 24cadf88f8
commit 6e72f59fcf
4 files changed
+244 -5

No files matched your search

+138
View File
@@ -8,6 +8,8 @@ import pytest
FRONTEND = Path(__file__).parents[1] / "frontend"
CONTROLLER = FRONTEND / "week-plan.js"
COORDINATOR = FRONTEND / "outbox-coordinator.js"
INDEX = FRONTEND / "index.html"
CSS = FRONTEND / "dashboard.css"
def run_controller(scenario: str) -> dict:
@@ -20,6 +22,32 @@ const createOutboxCoordinator = require({json.dumps(str(COORDINATOR))});
return json.loads(completed.stdout)
def test_conflicted_pull_review_has_accessible_touch_safe_recovery_actions():
markup = INDEX.read_text()
styles = CSS.read_text()
assert 'id="week-pull-conflict-review"' in markup
assert 'aria-labelledby="week-pull-conflict-title"' in markup
assert 'id="week-pull-conflict-item"' in markup
assert 'id="week-pull-conflict-context"' in markup
assert 'id="rebase-week-pull"' in markup
assert 'id="keep-week-pull"' in markup
assert "Add using current plans" in markup
assert "Keep in Week Ahead" in markup
assert ".week-pull-conflict-actions button { min-height:44px" in styles
assert "overflow-wrap:anywhere" in styles
def test_week_workflow_wires_both_conflict_decisions_to_the_durable_controller():
source = CONTROLLER.read_text()
assert "resolvePullConflict('add')" in source
assert "resolvePullConflict('keep')" in source
assert "controller.pullConflict?.()" in source
assert "renderPullConflict" in source
assert "#week-pull-conflict-review" in source
def test_week_controller_requires_executable_days_before_calendar_handoff():
result = run_controller("""
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
@@ -514,6 +542,116 @@ console.log(JSON.stringify({outcome,state:week.state(),pending:week.pendingPull(
]
def test_week_controller_describes_the_conflicted_transfer_from_current_plans():
result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const remoteToday={revision:9,ids:['active'],capacity_minutes:90,estimates:{active:30}};
const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]};
const fetchJson=async(url,options={})=>{if(options.method){const error=new Error('changed');error.status=409;throw error;}return url.endsWith('/today')?remoteToday:remoteWeek;};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]});
week.rememberItems({'pull':{title:'Ship recovery',kind:'issue',repository:'acme/app',number:7}});
await week.pullItem('pull',{revision:4,ids:['old'],capacity_minutes:90,estimates:{old:30}},'stable-pull');
console.log(JSON.stringify(week.pullConflict()));
""")
assert result == {
"identity": "pull", "title": "Ship recovery", "source_date": "2026-08-21",
"today_items": 1, "today_minutes": 30, "today_capacity": 90,
"operation_id": "stable-pull", "queue_length": 1,
}
def test_week_controller_rechecks_capacity_before_rebasing_a_conflicted_pull():
result = run_controller("""
const values=new Map();let posts=0;
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const remoteToday={revision:9,ids:['active'],capacity_minutes:50,estimates:{active:40}};
const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:20}}]};
const fetchJson=async(url,options={})=>{if(options.method){posts+=1;const error=new Error('changed');error.status=409;throw error;}return url.endsWith('/today')?remoteToday:remoteWeek;};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:20}}]});
await week.pullItem('pull',{revision:4,ids:['old'],capacity_minutes:90,estimates:{old:30}},'stable-pull');
let code=null;try{await week.resolvePullConflict('add');}catch(error){code=error.code;}
console.log(JSON.stringify({code,posts,pending:week.pendingPull()}));
""")
assert result["code"] == "today_over_capacity"
assert result["posts"] == 1
assert result["pending"]["conflict"] is True
def test_week_controller_rebases_a_conflicted_pull_and_drains_the_fifo_with_stable_operations():
result = run_controller("""
const values=new Map(),delivered=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let offline=true;
let serverToday={revision:10,ids:['active'],capacity_minutes:180,estimates:{active:30}};
let serverWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]};
const fetchJson=async(url,options={})=>{
if(offline)throw new Error('offline');
if(!options.method)return url.endsWith('/today')?serverToday:serverWeek;
const body=JSON.parse(options.body);delivered.push(body);
if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;}
serverToday={...serverToday,revision:serverToday.revision+1,ids:[...serverToday.ids,body.identity],estimates:{...serverToday.estimates,[body.identity]:serverWeek.days[0].estimates[body.identity]}};
serverWeek={...serverWeek,revision:serverWeek.revision+1,days:serverWeek.days.map(day=>({...day,ids:day.ids.filter(id=>id!==body.identity),estimates:Object.fromEntries(Object.entries(day.estimates).filter(([id])=>id!==body.identity))}))};
return {today:serverToday,week:serverWeek};
};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]});
let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}};
today=(await week.pullItem('first',today,'pull-first')).today;
await week.pullItem('second',today,'pull-second');
offline=false;await week.flushPull();
const resolved=await week.resolvePullConflict('add');
console.log(JSON.stringify({resolved,delivered,pending:week.pendingPull(),state:week.state()}));
""")
assert [request["operation_id"] for request in result["delivered"]] == [
"pull-first", "pull-first", "pull-second"
]
assert result["delivered"][1]["today_revision"] == 10
assert result["delivered"][1]["week_revision"] == 12
assert result["resolved"]["today"]["ids"] == ["active", "first", "second"]
assert result["pending"] is None
assert result["state"]["revision"] == 14
def test_week_controller_keeps_the_conflicted_item_in_week_and_advances_only_the_fifo_head():
result = run_controller("""
const values=new Map(),delivered=[];
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let offline=true;
let serverToday={revision:10,ids:['active'],capacity_minutes:180,estimates:{active:30}};
let serverWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]};
const fetchJson=async(url,options={})=>{
if(offline)throw new Error('offline');
if(!options.method)return url.endsWith('/today')?serverToday:serverWeek;
const body=JSON.parse(options.body);delivered.push(body);
if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;}
serverToday={...serverToday,revision:11,ids:[...serverToday.ids,body.identity],estimates:{...serverToday.estimates,[body.identity]:40}};
serverWeek={...serverWeek,revision:13,days:serverWeek.days.map(day=>({...day,ids:day.ids.filter(id=>id!==body.identity)}))};
return {today:serverToday,week:serverWeek};
};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]});
let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}};
today=(await week.pullItem('first',today,'pull-first')).today;
await week.pullItem('second',today,'pull-second');
offline=false;await week.flushPull();
const resolved=await week.resolvePullConflict('keep');
console.log(JSON.stringify({resolved,delivered,pending:week.pendingPull()}));
""")
assert [request["operation_id"] for request in result["delivered"]] == ["pull-first", "pull-second"]
assert result["delivered"][1]["today_revision"] == 10
assert result["delivered"][1]["week_revision"] == 12
assert result["resolved"]["today"]["ids"] == ["active", "second"]
assert result["resolved"]["week"]["days"][0]["ids"] == ["first"]
assert result["pending"] is None
def test_week_controller_retires_completed_work_from_every_day_in_one_durable_transition():
result = run_controller("""
const values=new Map();let writes=0;