Merge pull request 'Start the next Week Ahead day early from mobile' (#1225) from timmy/1224-start-week-day-early into main
All checks were successful
CI / lint (push) Successful in 3m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m26s
CI / release-candidate (push) Successful in 10s

Start the next Week Ahead day early from mobile (#1225)
This commit is contained in:
timmy 2026-08-21 14:48:16 +00:00
commit b7dd9757aa
8 changed files with 212 additions and 11 deletions

View File

@ -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 days capacity, marks overloads,
and flags work assigned to more than one date. Operators can move an item to another date without copying it;

View File

@ -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; }

View File

@ -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 phones Week Ahead plan is saved to your account. Today was not changed.';
qs('#my-work-action-status').textContent='This phones Week Ahead plan is saved. Today was not changed.';
closePlanToday();
} catch(error) {
const conflict=weekPlan.conflict();

View File

@ -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=>'<option value="'+escapeAttribute(day.plan_date)+'">'+escapeHtml(day.label)+'</option>').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?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+'</li>').join('')+'</ul>':
'<p class="small muted">Nothing planned.</p>';
const edit=readOnly?'':'<button type="button" data-week-edit-day="'+escapeAttribute(day.plan_date)+'" aria-label="Edit '+escapeAttribute(day.label)+'">Edit day</button>';
const start=day===nextUp&&canStartEarly?'<button type="button" class="week-start-early" data-week-start-early="'+escapeAttribute(day.plan_date)+'">Start this day early</button>':'';
return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+(day===nextUp?' is-next-up':'')+'"><header><h3>'+escapeHtml(day.label)+(day===nextUp?' <span class="week-next-up">Next up</span>':'')+'</h3>'+edit+'</header><p class="week-review-load">'+
escapeHtml(load)+'</p>'+items+'</article>';
escapeHtml(load)+'</p>'+items+start+'</article>';
}).join('');
duplicates.hidden=!value.duplicates.length;
duplicates.innerHTML=value.duplicates.length?'<strong>Choose one date for duplicated work before confirming.</strong><ul>'+value.duplicates.map(item=>
'<li><code>'+escapeHtml(item.id)+'</code> · '+escapeHtml(item.dates.join(', '))+'</li>').join('')+'</ul>':'';
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();
}

View File

@ -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()

View File

@ -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 "

View File

@ -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():

View File

@ -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():