Merge pull request 'Reconcile unfinished Today with due Week Ahead work' (#1201) from timmy/1200-reconcile-week-today into main
This commit is contained in:
commit
ecc79ce50f
|
|
@ -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){
|
||||
|
|
|
|||
48
src/main.py
48
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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user