feat: preserve Today before Week Ahead rollover (Closes #1182)
This commit is contained in:
parent
3edc4e36b3
commit
f0706e2e6f
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) "
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user