From a10592c3498d3d2d11cd1f60b15368a26f7f2283 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 21 Aug 2026 00:08:43 +0000 Subject: [PATCH] feat: reconcile Today with due Week Ahead work (Closes #1200) --- frontend/week-plan.js | 47 +++++++++++++++++--- src/main.py | 48 +++++++++++++++++++- src/today_store.py | 76 ++++++++++++++++++++++++++++++++ tests/test_week_plan.py | 73 ++++++++++++++++++++++++++++++ tests/test_week_plan_frontend.py | 60 ++++++++++++++++++------- 5 files changed, 280 insertions(+), 24 deletions(-) diff --git a/frontend/week-plan.js b/frontend/week-plan.js index a2ed848..1ec5e0d 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -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 today’s 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){ diff --git a/src/main.py b/src/main.py index d76abea..385baf7 100644 --- a/src/main.py +++ b/src/main.py @@ -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() diff --git a/src/today_store.py b/src/today_store.py index 783d5a5..97f9a27 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -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 { diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py index 1fc195a..944d9fd 100644 --- a/tests/test_week_plan.py +++ b/tests/test_week_plan.py @@ -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( diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 5cef6fc..b7657c6 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -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 '' in index