Merge pull request 'Reconcile unfinished Today with due Week Ahead work' (#1201) from timmy/1200-reconcile-week-today into main
Some checks failed
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 4m23s
CI / release-candidate (push) Has been skipped

This commit is contained in:
rockachopa 2026-08-21 00:18:01 +00:00
commit ecc79ce50f
5 changed files with 280 additions and 24 deletions

View File

@ -312,6 +312,18 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
plan_date:due.plan_date,today_revision:todayRevision,
})});
}
async function reconcilePromotion(preserved,selection) {
if(pending()||conflict()) return false;
const due=(preserved?.week?.days||week.days).find(item=>item.plan_date<=localDate()&&item.ids.length);
if(!due||!Number.isInteger(preserved?.today?.revision)) return false;
const result=await fetchJson('api/v1/week/reconcile',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
promotion_id:`reconcile-${due.plan_date}-r${preserved.week.revision}`,
week_revision:preserved.week.revision,plan_date:due.plan_date,today_revision:preserved.today.revision,
ids:[...(selection.ids||[])],capacity_minutes:selection.capacity_minutes??null,estimates:{...(selection.estimates||{})},
})});
adopt(result.week);
return result;
}
function summary() {
const planned=week.days.filter(item=>item.ids.length);
const items=planned.reduce((total,item)=>total+item.ids.length,0);
@ -319,7 +331,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
return label+(pending()?' · sync pending':'');
}
return {adopt,state,dates,day,pass,load,saveDay,stageDay,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,summary};
keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary};
}
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
todayWork,refresh,warm}={}) {
@ -327,6 +339,15 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
let reviewing=false;
let editingFromReview=false;
let blockedReviewOpen=false;
let reconciliation=null;
function reconciliationDay(){
if(!reconciliation)return null;
const due=(reconciliation.week?.days||[]).find(day=>day.ids?.length);
if(!due)return null;
const ids=[...new Set([...(reconciliation.today?.ids||[]),...(due.ids||[])])];
return {plan_date:due.plan_date,ids,capacity_minutes:due.capacity_minutes??reconciliation.today?.capacity_minutes??null,
estimates:{...(due.estimates||{}),...(reconciliation.today?.estimates||{})}};
}
function renderPass() {
const progress=qs('#week-plan-progress'),save=qs('#save-today-plan');
if(reviewing){
@ -458,6 +479,18 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
}
function save(plan){
const date=selectedDate,normalized=Array.isArray(plan)?{ids:plan,capacity_minutes:null,estimates:{}}:plan;
if(reconciliation){
const preserved=reconciliation;
controller.reconcile(preserved,normalized).then(result=>{
reconciliation=null;blockedReviewOpen=false;
todayWork.replace(result.today.ids);todayWork.replacePlanning(result.today);
refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();
qs('#my-work-action-status').textContent='Today started from unfinished and Week Ahead work.';
}).catch(error=>{
qs('#my-work-action-status').textContent=(error.message||'Plans changed during review.')+' Reopen Start todays plan.';
});
return true;
}
const staged=controller.stageDay(date,normalized);
if(!staged){qs('#my-work-action-status').textContent='Week Ahead could not be saved on this phone. Free browser storage and retry.';return false;}
qs('#mobile-week-summary').textContent=controller.summary();
@ -496,7 +529,8 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;blockedReviewOpen=false;todayWork.replace(promoted.ids);todayWork.replacePlanning({capacity_minutes:promoted.capacity_minutes??null,estimates:promoted.estimates||{}});refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent='Your saved Week Ahead plan is now Today.';return true;}
catch(error){
if(error?.code==='week_today_in_progress'){
qs('#my-work-action-status').textContent='Review unfinished Today before starting the saved Week Ahead day.';
reconciliation={today:plan,week:controller.state()};
qs('#my-work-action-status').textContent='Review unfinished Today with the due Week Ahead work before starting.';
if(!blockedReviewOpen){blockedReviewOpen=true;openPlanner(null,false);}
return false;
}
@ -504,11 +538,12 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
}
}
return {open,save,advance,confirm,finish,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing,
return {open,save,advance,confirm,finish,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
reviewing:()=>reviewing,selectedDate:()=>selectedDate,
day:()=>selectedDate?controller.day(selectedDate):null,
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
clear(){selectedDate=null;reviewing=false;editingFromReview=false;setReviewMode(false);qs('#week-review').hidden=true;
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):null),
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
(selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null),
clear(){selectedDate=null;reviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true;
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
}
if(typeof module!=='undefined'&&module.exports){

View File

@ -747,6 +747,23 @@ class WeekPromotion(BaseModel):
today_revision: int = Field(ge=0)
class WeekReconciliation(WeekPromotion):
ids: list[str] = Field(max_length=5)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
@model_validator(mode="after")
def validate_selection(self):
if len(set(self.ids)) != len(self.ids):
raise ValueError("reconciliation IDs must be unique")
if any(not item_id or len(item_id) > 500 for item_id in self.ids):
raise ValueError("reconciliation IDs must be bounded")
if any(item_id not in self.ids or minutes < 5 or minutes > 1440
for item_id, minutes in self.estimates.items()):
raise ValueError("estimates must match selected IDs and be bounded")
return self
class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
@ -1451,7 +1468,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/reconcile", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@ -2840,6 +2857,35 @@ async def promote_week_plan(payload: WeekPromotion):
)
@app.post("/api/v1/week/reconcile")
async def reconcile_week_plan(payload: WeekReconciliation):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().reconcile_week, login, **payload.model_dump()
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "week_today_changed", "today": error.today, "week": error.week},
)
except TomorrowPlanNotDue as error:
raise HTTPException(
status_code=409, detail={"code": "week_not_due", "plan_date": error.plan_date}
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Week Ahead reconciliation is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/today/session")
async def get_today_session():
login = await _confirmed_login()

View File

@ -633,6 +633,82 @@ class TodayStore:
)
return result
def reconcile_week(
self, login: str, *, promotion_id: str, week_revision: int,
plan_date: str, today_revision: int, ids: list[str],
capacity_minutes: int | None, estimates: dict[str, int],
) -> dict:
"""Atomically replace Today from preserved Today/due-week work and consume that day."""
login = self._normalize_login(login)
if not isinstance(promotion_id, str) or not promotion_id.strip() or len(promotion_id) > 100:
raise ValueError("promotion_id is required and bounded")
promotion_id = promotion_id.strip()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
receipt = connection.execute(
"SELECT result FROM week_promotions WHERE login = ? AND promotion_id = ?",
(login, promotion_id),
).fetchone()
if receipt:
result, _legacy = self._cipher.open(
receipt[0], binding=f"week-promotion:{login}:{promotion_id}"
)
if not isinstance(result, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return result
row = connection.execute(
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,)
).fetchone()
week = self._week_snapshot(row, login)
if week["revision"] != week_revision:
raise WeekPlanConflict(week)
day = next((item for item in week["days"] if item["plan_date"] == plan_date), None)
if day is None:
raise WeekPlanConflict(week)
local_date = datetime.fromtimestamp(self.clock(), ZoneInfo(week["timezone"])).date().isoformat()
if local_date < plan_date:
raise TomorrowPlanNotDue(plan_date)
today_row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?", (login,)
).fetchone()
today, _legacy = self._snapshot(today_row, login)
if today["revision"] != today_revision:
raise TodayPromotionConflict(today, week)
allowed = set(today["ids"]) | set(day["ids"])
if any(item_id not in allowed for item_id in ids):
raise ValueError("selected work must come from Today or the due Week Ahead day")
normalized = self._normalize_tomorrow(
ids=ids, capacity_minutes=capacity_minutes, estimates=estimates,
plan_date=plan_date, timezone=week["timezone"],
)
today_result = {"revision": today_revision + 1, **normalized}
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
"VALUES (?, ?, ?, NULL, '{}', NULL, NULL) ON CONFLICT(login) DO UPDATE SET "
"revision=excluded.revision, ids=excluded.ids, capacity_minutes=NULL, estimates='{}', "
"plan_date=NULL, timezone=NULL",
(login, today_result["revision"], self._sealed_plan(login, today_result)),
)
remaining_payload = {"timezone": week["timezone"], "days": [
item for item in week["days"] if item["plan_date"] != plan_date
]}
week_result = {"revision": week_revision + 1, **remaining_payload}
connection.execute(
"UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?",
(week_result["revision"], self._cipher.seal(
remaining_payload, binding=f"week:{login}"
), login),
)
result = {"today": today_result, "week": week_result}
connection.execute(
"INSERT INTO week_promotions(login, promotion_id, result, created_at) VALUES (?, ?, ?, ?)",
(login, promotion_id, self._cipher.seal(
result, binding=f"week-promotion:{login}:{promotion_id}"
), self.clock()),
)
return result
@staticmethod
def _empty_session() -> dict:
return {

View File

@ -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(

View File

@ -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