feat: preserve Today before Week Ahead rollover (Closes #1182)
All checks were successful
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m37s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-20 14:33:15 +00:00
parent 3edc4e36b3
commit f0706e2e6f
5 changed files with 104 additions and 8 deletions

View File

@ -162,6 +162,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute, function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
todayWork,refresh,warm}={}) { todayWork,refresh,warm}={}) {
let selectedDate=null; let selectedDate=null;
let blockedReviewOpen=false;
function renderDates() { function renderDates() {
const root=qs('#week-plan-dates'); const root=qs('#week-plan-dates');
root.hidden=!selectedDate; root.hidden=!selectedDate;
@ -191,12 +192,23 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
return true; return true;
} }
async function promote(plan){ 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;} 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){qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;} 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, return {open,save,promote,renderDates,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate,
day:()=>selectedDate?controller.day(selectedDate):null, 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, copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
clear(){selectedDate=null;renderDates();}}; 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;
}

View File

@ -2819,6 +2819,11 @@ async def promote_week_plan(payload: WeekPromotion):
status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot} status_code=409, detail={"code": "week_changed", "snapshot": error.snapshot}
) )
except TodayPromotionConflict as error: 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( raise HTTPException(
status_code=409, detail={"code": "today_changed", "today": error.today} status_code=409, detail={"code": "today_changed", "today": error.today}
) )

View File

@ -48,11 +48,12 @@ class WeekPlanConflict(ValueError):
class TodayPromotionConflict(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): def __init__(self, today: dict, week: dict | None = None):
super().__init__("Today changed before Tomorrow could be promoted") super().__init__("Today must be reviewed before a saved plan can be promoted")
self.today = today self.today = today
self.week = week
class TodayStore: class TodayStore:
@ -576,6 +577,8 @@ class TodayStore:
today, _legacy = self._snapshot(today_row, login) today, _legacy = self._snapshot(today_row, login)
if today["revision"] != today_revision: if today["revision"] != today_revision:
raise TodayPromotionConflict(today) raise TodayPromotionConflict(today)
if today["ids"]:
raise TodayPromotionConflict(today, week)
result = {"revision": today_revision + 1, **day, "timezone": week["timezone"]} result = {"revision": today_revision + 1, **day, "timezone": week["timezone"]}
connection.execute( connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) " "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "

View File

@ -4,7 +4,7 @@ from datetime import datetime, timezone
import pytest import pytest
from src import main from src import main
from src.today_store import TodayStore, WeekPlanConflict from src.today_store import TodayPromotionConflict, TodayStore, WeekPlanConflict
def sample_days(): 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, encryption_key=b"p" * 32,
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(), 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") week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
promoted = store.promote_week( 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]] 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): 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) store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"m" * 32)
tomorrow = store.replace_tomorrow( tomorrow = store.replace_tomorrow(

View File

@ -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(): def test_week_controller_does_not_promote_a_pending_week():
result = run_controller(""" result = run_controller("""
const values=new Map(); const values=new Map();