diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 86f8d5d..a970932 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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; } diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 1f4dea6..9cd00f4 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -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?'':'
'; const unplan=id=>overviewing&&!readOnly?'':''; - const items=day.ids.length?'': + 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 ''; + }; + const items=day.ids.length?'': '

Nothing planned.

'; const edit=readOnly?'':''; const start=day===nextUp&&canStartEarly?'':''; @@ -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); diff --git a/src/main.py b/src/main.py index 8e16a6c..78937f3 100644 --- a/src/main.py +++ b/src/main.py @@ -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() diff --git a/src/today_store.py b/src/today_store.py index 7d410af..0bcd55c 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -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, diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index db4542a..d90c5d5 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -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: diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py index 3f70268..d0f19a0 100644 --- a/tests/test_week_plan.py +++ b/tests/test_week_plan.py @@ -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(): diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 48294ca..595423b 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -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():