diff --git a/README.md b/README.md index cd3fba3..e1eb338 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,11 @@ foreground, and midnight lifecycle checks share one delivery flight. A successfu the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for review. Unsynced Tomorrow work is never promoted into Today. **Week Ahead** opens as a read-first seven-day mobile overview, so operators can inspect the next planned day, -work titles and references, load versus capacity, overloads, and pending sync without staging a change. **Edit day** -enters one date and returns to the refreshed overview; **Edit week** starts the continuous planning pass. +work titles and references, load versus capacity, overloads, and pending sync without staging a change. When Today +is empty and no work session is active, **Start this day early** confirms the next planned date, item count, minutes, +and capacity before atomically moving only that day into Today; offline, pending, stale, or non-empty plans remain +unchanged. **Edit day** enters one date and returns to the refreshed overview; **Edit week** starts the continuous +planning pass. **Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of closing after the seventh save. The review shows planned minutes against each day’s capacity, marks overloads, and flags work assigned to more than one date. Operators can move an item to another date without copying it; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 85f986a..abe421d 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -333,6 +333,8 @@ textarea { resize: vertical; min-height: 120px; } .week-review-item-open { display:block; width:100%; min-width:0; min-height:44px; padding:8px; border:0; border-radius:8px; background:transparent; color:inherit; text-align:left; } .week-review-item-open:hover { background:#173453; } .week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; } +.week-start-early { display:block; width:100%; min-height:44px; margin-top:12px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; } +.week-start-early:focus-visible { outline:3px solid #bfdbfe; outline-offset:2px; } .week-review-item-copy strong, .week-review-item-copy small { overflow-wrap:anywhere; } .week-review-item-copy small { color:#a9bdd3; } .week-review-move { display:flex; align-items:end; gap:8px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 594f676..25f4fe0 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -457,7 +457,7 @@ getItem:weekItem,openItem:openRoutedWork, openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value), escapeHtml,escapeAttribute:escAttr,todayWork, - refresh:refreshMyWorkView,warm:warmTodayOffline}); + t:()=>latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline}); const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr, onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}, }); @@ -7881,7 +7881,7 @@ try { await weekPlan.keepLocal(); qs('#mobile-week-summary').textContent=weekPlan.summary(); - qs('#my-work-action-status').textContent='This phone’s Week Ahead plan is saved to your account. Today was not changed.'; + qs('#my-work-action-status').textContent='This phone’s Week Ahead plan is saved. Today was not changed.'; closePlanToday(); } catch(error) { const conflict=weekPlan.conflict(); diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 40b89e2..2d4bec3 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -406,6 +406,17 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D plan_date:due.plan_date,today_revision:todayRevision, })}); } + async function startEarly(planDate,todayRevision) { + if(offlineSnapshot||pending()||conflict()||!Number.isInteger(todayRevision))return false; + const day=week.days.find(item=>item.plan_date===planDate&&item.ids.length); + if(!day)return false; + const promoted=await fetchJson('api/v1/week/start-early',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ + promotion_id:`early-${day.plan_date}-r${week.revision}`,week_revision:week.revision, + plan_date:day.plan_date,today_revision:todayRevision, + })}); + await load(); + return promoted; + } async function reconcilePromotion(preserved,selection) { if(offlineSnapshot||pending()||conflict()) return false; const due=(preserved?.week?.days||week.days).find(item=>item.plan_date<=localDate()&&item.ids.length); @@ -425,12 +436,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D return label+(pending()?' · sync pending':''); } return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged, - keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem, + keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem, item:id=>pendingItems[id]||confirmedItems[id]||null, offline:()=>offlineSnapshot}; } function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute, - todayWork,refresh,warm}={}) { + todayWork,refresh:r=()=>{},warm:w=()=>{},today:t=()=>null,r:refresh=r,w:warm=w,t:getTodayPlan=t, + confirmEarly=message=>globalThis.confirm?.(message)??false}={}) { let selectedDate=null; let reviewing=false; let overviewing=false; @@ -521,7 +533,10 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla function renderReview() { const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates'); const readOnly=Boolean(controller.offline?.()); + const pending=Boolean(controller.pending?.()); const nextUp=overviewing?value.days.find(day=>day.ids.length):null; + const today=getTodayPlan?.(); + const canStartEarly=Boolean(nextUp&&!readOnly&&!pending&&Number.isInteger(today?.revision)&&!today.ids?.length); const destinations=value.days.map(day=>'').join(''); const itemMarkup=(id,day)=>{ const item=getItem(id)||controller.item?.(id),estimate=Number(day.estimates?.[id])||0; @@ -540,14 +555,14 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla const items=day.ids.length?'': '

Nothing planned.

'; const edit=readOnly?'':''; + const start=day===nextUp&&canStartEarly?'':''; return '

'+escapeHtml(day.label)+(day===nextUp?' Next up':'')+'

'+edit+'

'+ - escapeHtml(load)+'

'+items+'
'; + escapeHtml(load)+'

'+items+start+''; }).join(''); duplicates.hidden=!value.duplicates.length; duplicates.innerHTML=value.duplicates.length?'Choose one date for duplicated work before confirming.':''; const confirm=qs('#confirm-week-plan'); - const pending=Boolean(controller.pending?.()); confirm.disabled=!value.can_confirm||pending; const blocker=value.blockers?.[0]; qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?'Week Ahead overview · '+(pending?'sync pending.':'no changes made.'):(value.duplicates.length?'Duplicate work must be moved to one date.': @@ -569,6 +584,26 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla const id=button.dataset.weekOpenItem,item=getItem(id)||controller.item?.(id); if(item)openItem?.(item,event.currentTarget); })); + root.querySelectorAll('[data-week-start-early]').forEach(button=>button.addEventListener('click',async event=>{ + const day=value.days.find(item=>item.plan_date===event.currentTarget.dataset.weekStartEarly); + const current=getTodayPlan?.(); + if(!day||!Number.isInteger(current?.revision)||current.ids?.length)return; + const capacity=Number(day.capacity_minutes)||0; + const detail=day.ids.length+' item'+(day.ids.length===1?'':'s')+' · '+day.planned_minutes+(capacity?' of '+capacity:'')+' min'; + if(!confirmEarly('Start '+day.label+' early?\n\n'+detail+' will move into Today.'))return; + event.currentTarget.disabled=true; + try{ + const promoted=await controller.startEarly(day.plan_date,current.revision); + if(!promoted)return; + todayWork.replace(promoted.ids);todayWork.replacePlanning(promoted);refresh();warm(); + qs('#mobile-week-summary').textContent=controller.summary(); + qs('#my-work-action-status').textContent=day.label+' is now Today.'; + finish(); + }catch(error){ + qs('#my-work-action-status').textContent=(error.message||'Week Ahead changed before it could start.')+' Reopen Week Ahead and review.'; + event.currentTarget.disabled=false; + } + })); root.querySelectorAll('[data-week-edit-day]').forEach(button=>button.addEventListener('click',()=>editDay(button.dataset.weekEditDay))); renderPass(); } diff --git a/src/main.py b/src/main.py index 385baf7..265dd63 100644 --- a/src/main.py +++ b/src/main.py @@ -1468,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/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 ( + 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/start-early", "/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 ( @@ -2857,6 +2857,31 @@ async def promote_week_plan(payload: WeekPromotion): ) +@app.post("/api/v1/week/start-early") +async def start_week_day_early(payload: WeekPromotion): + login = await _confirmed_login() + try: + return await asyncio.to_thread( + _today_store().promote_week, login, **payload.model_dump(), allow_future=True + ) + 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_in_progress", "today": error.today, "week": error.week}, + ) + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) + except (OSError, sqlite3.Error, PrivateStateEncryptionError): + raise HTTPException( + status_code=503, detail="Starting Week Ahead early is unavailable", + headers={"Retry-After": "1"}, + ) + + @app.post("/api/v1/week/reconcile") async def reconcile_week_plan(payload: WeekReconciliation): login = await _confirmed_login() diff --git a/src/today_store.py b/src/today_store.py index 97f9a27..6dc3e61 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -570,7 +570,7 @@ class TodayStore: def promote_week( self, login: str, *, promotion_id: str, week_revision: int, - plan_date: str, today_revision: int, + plan_date: str, today_revision: int, allow_future: bool = False, ) -> dict: login = self._normalize_login(login) if not isinstance(promotion_id, str) or not promotion_id.strip() or len(promotion_id) > 100: @@ -599,7 +599,7 @@ class TodayStore: if day is None: raise WeekPlanConflict(week) local_date = datetime.fromtimestamp(self.clock(), ZoneInfo(week["timezone"])).date().isoformat() - if local_date < plan_date: + if local_date < plan_date and not allow_future: raise TomorrowPlanNotDue(plan_date) today_row = connection.execute( "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py index 944d9fd..4d208ec 100644 --- a/tests/test_week_plan.py +++ b/tests/test_week_plan.py @@ -165,6 +165,49 @@ def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path): assert remaining["days"] == [sample_days()[1]] +def test_week_early_start_moves_a_future_day_to_empty_today_exactly_once(tmp_path): + store = TodayStore( + tmp_path / "today.sqlite3", + encryption_key=b"e" * 32, + clock=lambda: datetime(2026, 8, 20, 8, tzinfo=timezone.utc).timestamp(), + ) + today = store.get("timmy") + week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC") + + promoted = store.promote_week( + "timmy", promotion_id="early-2026-08-21-r1", week_revision=week["revision"], + plan_date="2026-08-21", today_revision=today["revision"], allow_future=True, + ) + replay = store.promote_week( + "timmy", promotion_id="early-2026-08-21-r1", week_revision=week["revision"], + plan_date="2026-08-21", today_revision=today["revision"], allow_future=True, + ) + + assert replay == promoted + assert promoted["ids"] == sample_days()[0]["ids"] + assert promoted["plan_date"] == "2026-08-21" + assert store.get_week("timmy")["days"] == [sample_days()[1]] + + +def test_normal_week_promotion_still_rejects_a_future_day_without_writes(tmp_path): + store = TodayStore( + tmp_path / "today.sqlite3", + encryption_key=b"e" * 32, + clock=lambda: datetime(2026, 8, 20, 8, tzinfo=timezone.utc).timestamp(), + ) + today = store.get("timmy") + week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC") + + with pytest.raises(main.TomorrowPlanNotDue): + store.promote_week( + "timmy", promotion_id="normal-2026-08-21-r1", week_revision=week["revision"], + plan_date="2026-08-21", today_revision=today["revision"], + ) + + assert store.get("timmy") == today + assert store.get_week("timmy") == week + + def test_week_promotion_preserves_nonempty_today_and_the_due_week(tmp_path): store = TodayStore( tmp_path / "today.sqlite3", @@ -261,6 +304,29 @@ async def test_week_promotion_api_returns_both_preserved_plans_for_review(monkey } +@pytest.mark.anyio +async def test_week_early_start_api_promotes_a_future_day(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, 20, 8, tzinfo=timezone.utc).timestamp(), + ) + today = store.get("timmy") + 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.start_week_day_early(main.WeekPromotion( + promotion_id="early-2026-08-21-r1", week_revision=week["revision"], + plan_date="2026-08-21", today_revision=today["revision"], + )) + + assert result["ids"] == sample_days()[0]["ids"] + assert store.get_week("timmy")["days"] == [sample_days()[1]] + + @pytest.mark.anyio async def test_week_reconciliation_api_returns_atomic_today_and_remaining_week(monkeypatch, tmp_path): async def user(): diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 14e0e63..2353bcc 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -66,6 +66,37 @@ console.log(JSON.stringify({dates,saved,requests})); assert result["requests"][1]["body"]["days"][2]["ids"] == ["issue:r:3:"] +def test_week_controller_starts_a_selected_future_day_early_and_refreshes_the_week(): + result = run_controller(""" +const requests=[]; +let loadCount=0; +const day={plan_date:'2026-08-21',ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}}; +const fetchJson=async(url,options={})=>{ + requests.push({url,method:options.method||'GET',body:options.body?JSON.parse(options.body):null}); + if(url==='api/v1/week/start-early')return {revision:3,...day,timezone:'UTC'}; + loadCount+=1; + return loadCount===1?{revision:4,timezone:'UTC',days:[day]}:{revision:5,timezone:'UTC',days:[]}; +}; +const week=createWeekPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); +await week.load(); +const promoted=await week.startEarly('2026-08-21',2); +console.log(JSON.stringify({promoted,requests,state:week.state()})); +""") + + assert result["promoted"]["ids"] == ["issue:r:1:"] + assert result["requests"][1] == { + "url": "api/v1/week/start-early", + "method": "POST", + "body": { + "promotion_id": "early-2026-08-21-r4", + "week_revision": 4, + "plan_date": "2026-08-21", + "today_revision": 2, + }, + } + assert result["state"]["days"] == [] + + def test_week_controller_advances_through_one_continuous_seven_day_pass(): result = run_controller(""" const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); @@ -260,6 +291,39 @@ console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,ope assert result["editWeekLabel"] == "Edit week" +def test_week_overview_confirms_and_starts_the_next_day_early_from_empty_today(): + result = run_controller(""" +const createWorkflow=createWeekPlan.Workflow; +const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27']; +const elements=new Map(); +const makeElement=()=>({hidden:false,textContent:'',disabled:false,innerHTML:'',addEventListener:()=>{},focus:()=>{},querySelectorAll:()=>[],querySelector:()=>null,scrollIntoView:()=>{}}); +const root=makeElement(); +Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.startButtons=[...value.matchAll(/data-week-start-early=\"([^\"]+)/g)].map(match=>({dataset:{weekStartEarly:match[1]},listeners:{},disabled:false,addEventListener(name,listener){this.listeners[name]=listener;}}));},get(){return this.value||'';}}); +root.querySelectorAll=selector=>selector==='[data-week-start-early]'?root.startButtons||[]:[]; +elements.set('#week-review-days',root); +elements.set('#week-plan-dates',makeElement()); +for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan','#week-review','#week-review-duplicates','#confirm-week-plan','#week-review-status','#back-to-week-review','#edit-week-plan','#week-offline-snapshot','#retry-week-live','#open-week-capacity-import'])if(!elements.has(selector))elements.set(selector,makeElement()); +const day={plan_date:'2026-08-21',label:'Friday, Aug 21',ids:['one','two'],capacity_minutes:90,estimates:{one:30,two:45},planned_minutes:75,overloaded:false}; +const review={days:dates.map((date,index)=>index===0?day:{plan_date:date,label:'Day '+(index+1),ids:[],capacity_minutes:null,estimates:{},planned_minutes:0,overloaded:false}),duplicates:[],blockers:[],can_confirm:true}; +let request=null,confirmation='',replaced=null,refreshed=0; +const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(value=>value.plan_date===date),pass:()=>({}),load:async()=>({}),summary:()=> 'Nothing planned',review:()=>review,pending:()=>false,offline:()=>false,move:()=>true,flush:async()=>{},startEarly:async(date,revision)=>{request={date,revision};return {ids:['one','two'],capacity_minutes:90,estimates:{one:30,two:45}};}}; +const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),openPlanner:()=>{},setReviewMode:()=>{},escapeHtml:value=>value,escapeAttribute:value=>value,today:()=>({revision:7,ids:[]}),confirmEarly:message=>{confirmation=message;return true;},todayWork:{replace:ids=>{replaced=ids;},replacePlanning:()=>{}},refresh:()=>{refreshed+=1;},warm:()=>{}}); +await workflow.open({disabled:false}); +const button=root.startButtons[0]; +await button.listeners.click({currentTarget:button}); +console.log(JSON.stringify({buttonCount:root.startButtons.length,markup:root.innerHTML,confirmation,request,replaced,refreshed,status:elements.get('#my-work-action-status').textContent})); +""") + + assert result["buttonCount"] == 1 + assert "Start this day early" in result["markup"] + assert "Friday, Aug 21" in result["confirmation"] + assert "2 items · 75 of 90 min" in result["confirmation"] + assert result["request"] == {"date": "2026-08-21", "revision": 7} + assert result["replaced"] == ["one", "two"] + assert result["refreshed"] == 1 + assert result["status"] == "Friday, Aug 21 is now Today." + + def test_week_overview_opens_known_work_with_the_originating_control_and_leaves_unknown_work_static(): result = run_controller(""" const createWorkflow=createWeekPlan.Workflow; @@ -325,10 +389,16 @@ def test_dashboard_routes_week_overview_controls_through_existing_detail_flow_wi workflow_mount = dashboard.split("const weekFlow=createWeekPlanWorkflow({", 1)[1].split("});", 1)[0] assert "openItem:openRoutedWork" in workflow_mount + assert "t:()=>latestTodayPlan" in workflow_mount + assert ".week-review-item-open" in css rule = css.split(".week-review-item-open", 1)[1].split("}", 1)[0] assert "min-height:44px" in rule assert "width:100%" in rule + assert ".week-start-early" in css + start_rule = css.split(".week-start-early", 1)[1].split("}", 1)[0] + assert "min-height:44px" in start_rule + assert "width:100%" in start_rule def test_week_workflow_marks_a_confirmed_fallback_read_only_and_retries_live_data():