Merge pull request 'Pull one Week Ahead item into an active Today plan' (#1243) from timmy/1242-week-item-to-today into main
All checks were successful
CI / lint (push) Successful in 3m25s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m26s
CI / release-candidate (push) Successful in 8s

This commit is contained in:
rockachopa 2026-08-22 00:24:40 +00:00
commit d01f502e70
7 changed files with 314 additions and 13 deletions

View File

@ -347,6 +347,8 @@ textarea { resize: vertical; min-height: 120px; }
.week-review-item-open:hover { background:#173453; }
.week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; }
.week-review-unplan { width:100%; min-height:44px; border-color:#6b87a6; background:transparent; color:#d7e5f5; }
.week-review-pull { grid-column:1/-1; width:100%; min-height:44px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
.week-review-unplan { grid-column:1/-1; }
.week-unplan-receipt { margin:12px 0 6px; padding:10px 12px; border:1px solid #60a5fa; border-radius:10px; background:#112d4d; color:#dbeafe; }
#undo-week-unplan { width:100%; min-height:44px; margin-bottom:8px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
.week-start-early { display:block; width:100%; min-height:44px; margin-top:12px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }

View File

@ -464,6 +464,15 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
plan_date:due.plan_date,today_revision:todayRevision,
})});
}
async function pullItem(identity,today,operationId) {
if(offlineSnapshot||pending()||conflict()||!identity||!operationId||
!Number.isInteger(today?.revision)||!(today.ids||[]).length)return false;
const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
operation_id:operationId,identity,today_revision:today.revision,week_revision:week.revision,
})});
adoptConfirmed(result.week,{...confirmedItems,...pendingItems});
return result;
}
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);
@ -494,13 +503,14 @@ 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,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
keepLocal,useRemote,promote,pullItem,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
}
function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
todayWork,refresh:r=()=>{},warm:w=()=>{},today:t=()=>null,r:refresh=r,w:warm=w,t:getTodayPlan=t,
confirmEarly=message=>globalThis.confirm?.(message)??false,setTimer=globalThis.setTimeout,clearTimer=globalThis.clearTimeout,x=null}={}) {
confirmEarly=message=>globalThis.confirm?.(message)??false,setTimer=globalThis.setTimeout,clearTimer=globalThis.clearTimeout,
operationId=()=>globalThis.crypto?.randomUUID?.()||('pull-'+Date.now()+'-'+Math.random().toString(16).slice(2)),x=null}={}) {
let selectedDate=null;
let reviewing=false;
let overviewing=false;
@ -689,7 +699,13 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
const move=id=>overviewing?'':'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+escapeAttribute(id)+'">'+destinations+
'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
const unplan=id=>overviewing&&!readOnly?'<button type="button" class="week-review-unplan" data-week-unplan="'+escapeAttribute(id)+'">Remove from week</button>':'';
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+unplan(id)+'</li>').join('')+'</ul>':
const pull=id=>{
const inToday=(today?.ids||[]).includes(id),full=(today?.ids||[]).length>=5;
if(!overviewing||readOnly||pending||!Number.isInteger(today?.revision)||!(today?.ids||[]).length||inToday)return '';
return '<button type="button" class="week-review-pull" data-week-pull-item="'+escapeAttribute(id)+'"'+(full?' disabled':'')+'>'+
(full?'Today is full':'Add to Today')+'</button>';
};
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+pull(id)+unplan(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>':'';
@ -732,6 +748,25 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Removal remains saved on this phone.';
}
}));
root.querySelectorAll('[data-week-pull-item]').forEach(button=>button.addEventListener('click',async event=>{
const id=event.currentTarget.dataset.weekPullItem,current=getTodayPlan?.();
if(!id||!Number.isInteger(current?.revision)||!(current.ids||[]).length||current.ids.length>=5)return;
event.currentTarget.disabled=true;
try{
const result=await controller.pullItem?.(id,current,operationId());
if(!result)return;
getTodayPlan?.(result.today);todayWork.replace(result.today.ids);
todayWork.replacePlanning({capacity_minutes:result.today.capacity_minutes??null,estimates:result.today.estimates||{}});
refresh();warm();renderReview();
qs('#mobile-week-summary').textContent=controller.summary();
const item=getItem(id)||controller.item?.(id);
qs('#week-review-status').textContent=String(item?.title||'Work')+' added to Today.';
}catch(error){
renderReview();
qs('#week-review-status').textContent=error?.code==='today_full'?'Today is full. Finish or move work before adding more.':
((error.message||'Today or Week Ahead changed.')+' Reopen Week Ahead and retry.');
}
}));
root.querySelectorAll('[data-week-open-item]').forEach(button=>button.addEventListener('click',event=>{
const id=button.dataset.weekOpenItem,item=getItem(id)||controller.item?.(id);
if(item)openItem?.(item,event.currentTarget);

View File

@ -757,6 +757,13 @@ class WeekReschedule(BaseModel):
allow_over_capacity: bool = False
class WeekItemPull(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
identity: str = Field(min_length=1, max_length=500)
today_revision: int = Field(ge=0)
week_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)
@ -1478,7 +1485,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/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/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/week/reschedule", "/api/v1/week/pull-item", "/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 (
@ -2892,6 +2899,33 @@ async def reschedule_today_to_week(payload: WeekReschedule):
)
@app.post("/api/v1/week/pull-item")
async def pull_week_item_into_today(payload: WeekItemPull):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().pull_week_item, login, **payload.model_dump()
)
except WeekPlanConflict as error:
raise HTTPException(
status_code=409, detail={"code": "week_changed", "week": error.snapshot}
)
except TodayPromotionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "today_changed", "today": error.today, "week": error.week},
)
except TodayPlanFull:
raise HTTPException(status_code=409, detail={"code": "today_full"})
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Adding Week Ahead work to Today is unavailable",
headers={"Retry-After": "1"},
)
@app.post("/api/v1/week/start-early")
async def start_week_day_early(payload: WeekPromotion):
login = await _confirmed_login()

View File

@ -84,7 +84,7 @@ class TodayStore:
def _initialize(self) -> None:
connection = connect_private_sqlite(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 6:
if connection.execute("PRAGMA user_version").fetchone()[0] >= 7:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
@ -194,7 +194,12 @@ class TodayStore:
"login TEXT NOT NULL, operation_id TEXT NOT NULL, result TEXT NOT NULL, "
"created_at REAL NOT NULL, PRIMARY KEY (login, operation_id))"
)
connection.execute("PRAGMA user_version = 6")
connection.execute(
"CREATE TABLE IF NOT EXISTS week_item_pulls ("
"login TEXT NOT NULL, operation_id TEXT NOT NULL, result TEXT NOT NULL, "
"created_at REAL NOT NULL, PRIMARY KEY (login, operation_id))"
)
connection.execute("PRAGMA user_version = 7")
connection.commit()
connection.close()
@ -677,6 +682,86 @@ class TodayStore:
)
return result
def pull_week_item(
self, login: str, *, operation_id: str, identity: str,
today_revision: int, week_revision: int,
) -> dict:
"""Atomically append one Week Ahead item to Today and remove it from Week."""
login = self._normalize_login(login)
if not isinstance(operation_id, str) or not operation_id.strip() or len(operation_id) > 100:
raise ValueError("operation_id is required and bounded")
if not isinstance(identity, str) or not identity or len(identity) > 500:
raise ValueError("identity is required and bounded")
operation_id = operation_id.strip()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
receipt = connection.execute(
"SELECT result FROM week_item_pulls WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if receipt:
result, _legacy = self._cipher.open(
receipt[0], binding=f"week-item-pull:{login}:{operation_id}"
)
if not isinstance(result, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return result
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)
week_row = connection.execute(
"SELECT revision, payload FROM week_plans WHERE login = ?", (login,),
).fetchone()
week = self._week_snapshot(week_row, login)
if today["revision"] != today_revision:
raise TodayPromotionConflict(today, week)
if week["revision"] != week_revision:
raise WeekPlanConflict(week)
if identity in today["ids"]:
raise ValueError("work is already in Today")
if len(today["ids"]) >= self.limit:
raise TodayPlanFull("Today plan is full")
source = next((day for day in week["days"] if identity in day["ids"]), None)
if source is None:
raise WeekPlanConflict(week)
estimate = source.get("estimates", {}).get(identity)
today_result = {
**today, "revision": today_revision + 1,
"ids": [*today["ids"], identity],
"estimates": {**today["estimates"], **({identity: estimate} if estimate else {})},
}
remaining_days = []
for day in week["days"]:
estimates = dict(day.get("estimates", {}))
estimates.pop(identity, None)
remaining_days.append({
**day, "ids": [item_id for item_id in day["ids"] if item_id != identity],
"estimates": estimates,
})
week_payload = {"timezone": week["timezone"], "days": remaining_days}
week_result = {"revision": week_revision + 1, **week_payload}
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)),
)
connection.execute(
"UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?",
(week_result["revision"], self._cipher.seal(week_payload, binding=f"week:{login}"), login),
)
result = {"today": today_result, "week": week_result}
connection.execute(
"INSERT INTO week_item_pulls(login, operation_id, result, created_at) VALUES (?, ?, ?, ?)",
(login, operation_id, self._cipher.seal(
result, binding=f"week-item-pull:{login}:{operation_id}"
), self.clock()),
)
return result
def promote_week(
self, login: str, *, promotion_id: str, week_revision: int,
plan_date: str, today_revision: int, allow_future: bool = False,

View File

@ -34,6 +34,28 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
page_errors: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
pulled: list[dict] = []
page.route("**/api/v1/today", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps({
"revision": 3, "ids": ["issue:acme/mobile:99:"],
"capacity_minutes": 120, "estimates": {"issue:acme/mobile:99:": 30},
}),
))
def pull_item_route(route):
body = json.loads(route.request.post_data or "{}")
pulled.append(body)
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"today": {"revision": 4, "ids": ["issue:acme/mobile:99:", body["identity"]],
"capacity_minutes": 120, "estimates": {body["identity"]: 45}},
"week": {"revision": 99, "timezone": "UTC", "days": [{
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
"ids": [], "capacity_minutes": 60, "estimates": {},
}]},
}))
page.route("**/api/v1/week/pull-item", pull_item_route)
def week_route(route):
if route.request.method == "PUT":
@ -66,6 +88,8 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
page.wait_for_timeout(100)
@ -294,6 +318,17 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
assert len(saved) == before_retirement + 1
assert saved[-1]["days"][0]["ids"] == ["issue:acme/mobile:42:"]
assert saved[-1]["days"][0]["capacity_minutes"] == 60
pull = cards.first.locator('[data-week-pull-item="issue:acme/mobile:42:"]')
expect(pull).to_have_text("Add to Today")
pull_bounds = pull.bounding_box()
assert pull_bounds and pull_bounds["height"] >= 44
pull.click()
expect(cards.first).not_to_contain_text("Polish desktop filters")
expect(page.locator("#week-review-status")).to_have_text(
"Polish desktop filters added to Today."
)
assert pulled and pulled[-1]["identity"] == "issue:acme/mobile:42:"
assert pulled[-1]["today_revision"] == 3
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:

View File

@ -189,6 +189,54 @@ def test_week_early_start_moves_a_future_day_to_empty_today_exactly_once(tmp_pat
assert store.get_week("timmy")["days"] == [sample_days()[1]]
def test_pull_week_item_atomically_appends_to_active_today_and_replays_exactly_once(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"i" * 32)
active_id = "issue:stackchain/dashboard:1:"
pulled_id = "issue:secret/repo:3:"
today = store.apply("timmy", "seed-active", "add", active_id)
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
result = store.pull_week_item(
"timmy", operation_id="pull-secret-3", identity=pulled_id,
today_revision=today["revision"], week_revision=week["revision"],
)
replay = store.pull_week_item(
"timmy", operation_id="pull-secret-3", identity=pulled_id,
today_revision=today["revision"], week_revision=week["revision"],
)
assert replay == result
assert result["today"]["ids"] == [active_id, pulled_id]
assert result["today"]["estimates"] == {pulled_id: 60}
assert result["week"]["days"][0]["ids"] == ["issue:secret/repo:2:"]
assert result["week"]["days"][0]["estimates"] == {"issue:secret/repo:2:": 45}
assert result["week"]["days"][1] == sample_days()[1]
assert store.get("timmy") == result["today"]
assert store.get_week("timmy") == result["week"]
def test_pull_week_item_rejects_full_or_changed_today_without_partial_write(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"f" * 32)
today = store.get("timmy")
for index in range(5):
today = store.apply("timmy", f"seed-{index}", "add", f"issue:r:{index + 1}:")
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
with pytest.raises(main.TodayPlanFull):
store.pull_week_item(
"timmy", operation_id="full", identity="issue:secret/repo:3:",
today_revision=today["revision"], week_revision=week["revision"],
)
with pytest.raises(TodayPromotionConflict):
store.pull_week_item(
"timmy", operation_id="stale", identity="issue:secret/repo:3:",
today_revision=today["revision"] - 1, week_revision=week["revision"],
)
assert store.get("timmy") == today
assert store.get_week("timmy") == week
def test_normal_week_promotion_still_rejects_a_future_day_without_writes(tmp_path):
store = TodayStore(
tmp_path / "today.sqlite3",
@ -327,6 +375,26 @@ async def test_week_early_start_api_promotes_a_future_day(monkeypatch, tmp_path)
assert store.get_week("timmy")["days"] == [sample_days()[1]]
@pytest.mark.anyio
async def test_pull_week_item_api_returns_both_atomic_plans(monkeypatch, tmp_path):
async def user():
return {"login": "timmy"}
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"u" * 32)
today = store.apply("timmy", "seed", "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.pull_week_item_into_today(main.WeekItemPull(
operation_id="pull-api", identity="issue:secret/repo:3:",
week_revision=week["revision"], today_revision=today["revision"],
))
assert result["today"]["ids"] == ["issue:r:active:", "issue:secret/repo:3:"]
assert result["week"]["days"][0]["ids"] == ["issue:secret/repo:2:"]
@pytest.mark.anyio
async def test_week_reconciliation_api_returns_atomic_today_and_remaining_week(monkeypatch, tmp_path):
async def user():

View File

@ -300,6 +300,31 @@ console.log(JSON.stringify({before,refused,moved,added,state:week.state(),pendin
assert result["pending"]["days"] == result["state"]["days"]
def test_week_controller_pulls_one_item_into_today_and_adopts_returned_week():
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:['active',body.identity],capacity_minutes:120,estimates:{[body.identity]:45}},
week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['sibling'],capacity_minutes:90,estimates:{sibling:30}}]}};
},localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull','sibling'],capacity_minutes:90,estimates:{pull:45,sibling:30}}]});
const pulled=await week.pullItem('pull',{revision:4,ids:['active']},'stable-pull');
console.log(JSON.stringify({pulled,requests,state:week.state()}));
""")
assert result["requests"] == [{
"url": "api/v1/week/pull-item",
"body": {
"operation_id": "stable-pull", "identity": "pull",
"today_revision": 4, "week_revision": 7,
},
}]
assert result["pulled"]["today"]["ids"] == ["active", "pull"]
assert result["state"]["revision"] == 8
assert result["state"]["days"][0]["ids"] == ["sibling"]
def test_week_controller_retires_completed_work_from_every_day_in_one_durable_transition():
result = run_controller("""
const values=new Map();let writes=0;
@ -407,28 +432,37 @@ const dateRoot=makeElement();
Object.defineProperty(dateRoot,'innerHTML',{set(value){this.value=value;this.buttons=[];},get(){return this.value||'';}});
elements.set('#week-plan-dates',dateRoot);
const reviewDays=makeElement();
Object.defineProperty(reviewDays,'innerHTML',{set(value){this.value=value;},get(){return this.value||'';}});
Object.defineProperty(reviewDays,'innerHTML',{set(value){this.value=value;this.pullButtons=[...value.matchAll(/data-week-pull-item=\"([^\"]+)/g)].map(match=>({
dataset:{weekPullItem:match[1]},listeners:{},disabled:false,
addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;}
}));},get(){return this.value||'';}});
reviewDays.querySelectorAll=selector=>selector==='[data-week-pull-item]'?reviewDays.pullButtons||[]:[];
elements.set('#week-review-days',reviewDays);
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']) if(!elements.has(selector))elements.set(selector,makeElement());
let writes=0,opened=0,reviewMode=false;
let writes=0,opened=0,reviewMode=false,pullRequest=null,replacedToday=null;
const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),
ids:index===2?['issue:stackchain/dashboard:42:']:[],capacity_minutes:60,
estimates:index===2?{'issue:stackchain/dashboard:42:':30}:{},planned_minutes:index===2?30:0,overloaded:false})),
duplicates:[],blockers:[],can_confirm:true};
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date),
pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:1,next_date:dates[index+1]||null,last:index===6};},
load:async()=>({}),summary:()=> '1 item across 1 day',review:()=>review,pending:()=>false,
move:()=>true,flush:async()=>{writes+=1;}};
load:async()=>({}),summary:()=> '1 item across 1 day',review:()=>review,pending:()=>false,offline:()=>false,
move:()=>true,flush:async()=>{writes+=1;},pullItem:async(id,today,operationId)=>{
pullRequest={id,today,operationId};return {today:{revision:4,ids:['active',id],estimates:{[id]:30}},week:{revision:8,days:[]}};
}};
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
getItem:id=>id.includes(':42:')?{kind:'issue',title:'Ship mobile overview',repository:'stackchain/dashboard',number:42}:null,
openPlanner:()=>{opened+=1;},setReviewMode:value=>{reviewMode=value;},escapeHtml:value=>value,escapeAttribute:value=>value,
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
today:()=>({revision:3,ids:['active'],estimates:{}}),operationId:()=> 'pull-once',
todayWork:{replace:ids=>{replacedToday=ids;},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
await workflow.open({disabled:false});
const initialMarkup=reviewDays.innerHTML;
await reviewDays.pullButtons[0].listeners.click({currentTarget:reviewDays.pullButtons[0]});
console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,opened,copy:workflow.copy(),
reviewHidden:elements.get('#week-review').hidden,datesHidden:elements.get('#week-plan-dates').hidden,
markup:reviewDays.innerHTML,status:elements.get('#week-review-status').textContent,
markup:initialMarkup,status:elements.get('#week-review-status').textContent,pullRequest,replacedToday,
confirmHidden:elements.get('#confirm-week-plan').hidden,editWeekHidden:elements.get('#edit-week-plan').hidden,
editWeekLabel:elements.get('#edit-week-plan').textContent}));
""")
@ -444,8 +478,12 @@ console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,ope
assert result["datesHidden"] is True
assert "Ship mobile overview" in result["markup"]
assert "Next up" in result["markup"]
assert "Add to Today" in result["markup"]
assert result["pullRequest"]["id"] == "issue:stackchain/dashboard:42:"
assert result["pullRequest"]["operationId"] == "pull-once"
assert result["replacedToday"] == ["active", "issue:stackchain/dashboard:42:"]
assert "data-week-move" not in result["markup"]
assert result["status"] == "Week Ahead overview · no changes made."
assert result["status"] == "Ship mobile overview added to Today."
assert result["confirmHidden"] is True
assert result["editWeekHidden"] is False
assert result["editWeekLabel"] == "Edit week"
@ -661,6 +699,10 @@ def test_dashboard_routes_week_overview_controls_through_existing_detail_flow_wi
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
assert ".week-review-pull" in css
pull_rule = css.split(".week-review-pull", 1)[1].split("}", 1)[0]
assert "min-height:44px" in pull_rule
assert "width:100%" in pull_rule
def test_successful_issue_close_notifies_week_ahead_only_after_upstream_confirmation():