From f0706e2e6f1d365c6c60163b93a9e84fcef44ba8 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 20 Aug 2026 14:33:15 +0000 Subject: [PATCH] feat: preserve Today before Week Ahead rollover (Closes #1182) --- frontend/week-plan.js | 18 +++++++++-- src/main.py | 5 +++ src/today_store.py | 9 ++++-- tests/test_week_plan.py | 52 ++++++++++++++++++++++++++++++-- tests/test_week_plan_frontend.py | 28 +++++++++++++++++ 5 files changed, 104 insertions(+), 8 deletions(-) diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 2b1396d..5d7d30c 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -162,6 +162,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute, todayWork,refresh,warm}={}) { let selectedDate=null; + let blockedReviewOpen=false; function renderDates() { const root=qs('#week-plan-dates'); root.hidden=!selectedDate; @@ -191,12 +192,23 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e return true; } async function promote(plan){ - try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return 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){qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;} + 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.'; + if(!blockedReviewOpen){blockedReviewOpen=true;openPlanner(null,false);} + return false; + } + blockedReviewOpen=false; + qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false; + } } return {open,save,promote,renderDates,active:()=>Boolean(selectedDate),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;renderDates();}}; } -if(typeof module!=='undefined'&&module.exports)module.exports=createWeekPlan; +if(typeof module!=='undefined'&&module.exports){ + module.exports=createWeekPlan; + module.exports.Workflow=createWeekPlanWorkflow; +} diff --git a/src/main.py b/src/main.py index 6dcf3f4..cb103c2 100644 --- a/src/main.py +++ b/src/main.py @@ -2819,6 +2819,11 @@ async def promote_week_plan(payload: WeekPromotion): status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot} ) except TodayPromotionConflict as error: + if error.week is not None: + raise HTTPException( + status_code=409, + detail={"code": "week_today_in_progress", "today": error.today, "week": error.week}, + ) raise HTTPException( status_code=409, detail={"code": "today_changed", "today": error.today} ) diff --git a/src/today_store.py b/src/today_store.py index 8a503e7..7e148a0 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -48,11 +48,12 @@ class WeekPlanConflict(ValueError): class TodayPromotionConflict(ValueError): - """Raised when rollover would overwrite a changed Today plan.""" + """Raised when promotion cannot safely replace the current Today plan.""" - def __init__(self, today: dict): - super().__init__("Today changed before Tomorrow could be promoted") + def __init__(self, today: dict, week: dict | None = None): + super().__init__("Today must be reviewed before a saved plan can be promoted") self.today = today + self.week = week class TodayStore: @@ -576,6 +577,8 @@ class TodayStore: today, _legacy = self._snapshot(today_row, login) if today["revision"] != today_revision: raise TodayPromotionConflict(today) + if today["ids"]: + raise TodayPromotionConflict(today, week) result = {"revision": today_revision + 1, **day, "timezone": week["timezone"]} connection.execute( "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) " diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py index b8d11b2..d3f056a 100644 --- a/tests/test_week_plan.py +++ b/tests/test_week_plan.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone import pytest from src import main -from src.today_store import TodayStore, WeekPlanConflict +from src.today_store import TodayPromotionConflict, TodayStore, WeekPlanConflict def sample_days(): @@ -57,7 +57,7 @@ def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path): encryption_key=b"p" * 32, clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(), ) - today = store.apply("timmy", "today", "add", "issue:r:1:") + today = store.get("timmy") week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC") promoted = store.promote_week( @@ -76,6 +76,54 @@ def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path): assert remaining["days"] == [sample_days()[1]] +def test_week_promotion_preserves_nonempty_today_and_the_due_week(tmp_path): + store = TodayStore( + tmp_path / "today.sqlite3", + encryption_key=b"p" * 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(TodayPromotionConflict) as blocked: + store.promote_week( + "timmy", promotion_id="week-2026-08-21-r1", week_revision=week["revision"], + plan_date="2026-08-21", today_revision=today["revision"], + ) + + assert blocked.value.today == today + assert blocked.value.week == week + 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(): + return {"login": "timmy"} + + store = TodayStore( + tmp_path / "today.sqlite3", + encryption_key=b"p" * 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) + + with pytest.raises(main.HTTPException) as blocked: + await main.promote_week_plan(main.WeekPromotion( + promotion_id="week-2026-08-21-r1", week_revision=week["revision"], + plan_date="2026-08-21", today_revision=today["revision"], + )) + + assert blocked.value.status_code == 409 + assert blocked.value.detail == { + "code": "week_today_in_progress", "today": today, "week": week, + } + + 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 a34a09f..3174fa3 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -230,6 +230,34 @@ console.log(JSON.stringify({promoted,requests})); }] +def test_week_workflow_routes_blocked_rollover_into_today_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; +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;}, + escapeHtml:value=>value,escapeAttribute:value=>value, + todayWork:{replace:()=>{replaced+=1;},replacePlanning:()=>{replaced+=1;}}, + refresh:()=>{refreshed+=1;},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})); +""") + + assert result == { + "promoted": False, + "retried": False, + "opened": 1, + "replaced": 0, + "refreshed": 0, + "status": "Review unfinished Today before starting the saved Week Ahead day.", + } + + def test_week_controller_does_not_promote_a_pending_week(): result = run_controller(""" const values=new Map();