Merge pull request 'Pull one Week Ahead item into an active Today plan' (#1243) from timmy/1242-week-item-to-today into main
This commit is contained in:
commit
d01f502e70
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
36
src/main.py
36
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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user