feat: keep Week Ahead available offline (Closes #1218)
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m26s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 10:43:59 +00:00
1 parent bb6a4c4305
commit 37950bd0ed
5 files changed
+337 -26

No files matched your search

@@ -287,3 +287,72 @@ def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reads_the_confirmed_week_offline_then_retries_live(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
online = True
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
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)))
def week_route(route):
if not online:
route.fulfill(status=503, content_type="application/json", body='{"message":"offline"}')
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 7, "timezone": "UTC", "days": [{
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
"ids": ["issue:acme/mobile:41:"], "capacity_minutes": 60,
"estimates": {"issue:acme/mobile:41:": 30},
}],
}))
page.route("**/api/v1/week", week_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Offline Week Ahead phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
page.locator("#cancel-plan-today").click()
online = False
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
notice = page.locator("#week-offline-snapshot")
expect(notice).to_be_visible()
expect(notice).to_contain_text("Offline snapshot")
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
expect(page.locator("[data-week-edit-day]")).to_have_count(0)
expect(page.locator("#edit-week-plan")).to_be_hidden()
expect(page.locator("#open-week-capacity-import")).to_be_hidden()
retry = page.locator("#retry-week-live")
expect(retry).to_be_visible()
bounds = retry.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
online = True
retry.click()
expect(notice).to_be_hidden()
expect(page.locator("#edit-week-plan")).to_be_visible()
expect(page.locator("[data-week-edit-day]")).to_have_count(7)
assert not page_errors, f"offline Week Ahead recovery raised: {page_errors}"
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
+157
View File
@@ -260,6 +260,53 @@ console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,ope
assert result["editWeekLabel"] == "Edit week"
def test_week_workflow_marks_a_confirmed_fallback_read_only_and_retries_live_data():
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:'',dataset:{},listeners:{},
addEventListener(name,listener){this.listeners[name]=listener;},focus:()=>{},querySelectorAll:()=>[],querySelector:()=>null});
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;this.editButtons=[...value.matchAll(/data-week-edit-day/g)];},get(){return this.value||'';}});
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',
'#week-offline-snapshot','#retry-week-live','#open-week-capacity-import']) if(!elements.has(selector))elements.set(selector,makeElement());
let offline=true,loads=0,remembered=null;
const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index===0?['issue:r:42:']:[],
capacity_minutes:60,estimates:index===0?{'issue:r:42:':30}:{},planned_minutes:index===0?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:()=>({}),load:async()=>{loads+=1;const value={offline_snapshot:offline,refreshed_at:'2026-08-20T12:34:00.000Z'};offline=false;return value;},
summary:()=> '1 item across 1 day',review:()=>review,pending:()=>false,offline:()=>loads===1,
item:id=>id?{kind:'issue',title:'Cached work title',repository:'r',number:42}:null,
rememberItems:value=>{remembered=value;},move:()=>true,flush:async()=>({})};
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',getItem:()=>null,
openPlanner:()=>{},setReviewMode:()=>{},escapeHtml:value=>value,escapeAttribute:value=>value,
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
await workflow.open({disabled:false});
const fallback={noticeHidden:elements.get('#week-offline-snapshot').hidden,
notice:elements.get('#week-offline-snapshot').textContent,markup:reviewDays.innerHTML,
editWeekHidden:elements.get('#edit-week-plan').hidden,capacityHidden:elements.get('#open-week-capacity-import').hidden};
await elements.get('#retry-week-live').listeners.click({currentTarget:elements.get('#retry-week-live')});
console.log(JSON.stringify({fallback,loads,live:{noticeHidden:elements.get('#week-offline-snapshot').hidden,
editWeekHidden:elements.get('#edit-week-plan').hidden,capacityHidden:elements.get('#open-week-capacity-import').hidden},remembered}));
""")
assert result["fallback"]["noticeHidden"] is False
assert "Offline snapshot" in result["fallback"]["notice"]
assert "Aug" in result["fallback"]["notice"]
assert "Cached work title" in result["fallback"]["markup"]
assert "data-week-edit-day" not in result["fallback"]["markup"]
assert result["fallback"]["editWeekHidden"] is True
assert result["fallback"]["capacityHidden"] is True
assert result["loads"] == 2
assert result["live"] == {"noticeHidden": True, "editWeekHidden": False, "capacityHidden": False}
assert result["remembered"] == {}
def test_week_workflow_opens_review_after_day_seven_and_blocks_duplicate_confirmation():
result = run_controller("""
const createWorkflow=createWeekPlan.Workflow;
@@ -492,6 +539,105 @@ console.log(JSON.stringify({queued,restored,requests,otherError,keys:[...values.
assert result["keys"] == ["stackchain.week-sync.v1.timmy"]
def test_week_controller_falls_back_to_an_account_bound_confirmed_read_snapshot():
result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let login='Timmy',online=true,revision=7;
const options={storage,getLogin:()=>login,now:()=>Date.parse('2026-08-20T12:34:00Z'),
fetchJson:async()=>{if(!online)throw new Error('offline');return {revision,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['issue:r:42:'],capacity_minutes:60,estimates:{'issue:r:42:':30}}
]};},localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
const live=createWeekPlan(options);
const loaded=await live.load();
live.rememberItems({'issue:r:42:':{kind:'issue',title:'Ship offline overview',repository:'r',number:42,secret:'drop'}});
online=false;
const fallback=createWeekPlan(options);
const restored=await fallback.load();
login='alexander';
let otherError='';
try { await createWeekPlan(options).load(); } catch(error) { otherError=error.message; }
login='timmy';online=true;revision=8;
const refreshed=await fallback.load();
console.log(JSON.stringify({loaded,restored,item:fallback.item('issue:r:42:'),otherError,refreshed,
keys:[...values.keys()],snapshot:JSON.parse(values.get('stackchain.week-confirmed.v1.timmy'))}));
""")
assert result["loaded"]["offline_snapshot"] is False
assert result["restored"]["offline_snapshot"] is True
assert result["restored"]["refreshed_at"] == "2026-08-20T12:34:00.000Z"
assert result["restored"]["days"][0]["ids"] == ["issue:r:42:"]
assert result["item"] == {
"kind": "issue", "title": "Ship offline overview", "repository": "r", "number": 42,
}
assert result["otherError"] == "offline"
assert result["refreshed"]["revision"] == 8
assert result["refreshed"]["offline_snapshot"] is False
assert result["keys"] == ["stackchain.week-confirmed.v1.timmy"]
assert "secret" not in result["snapshot"]["items"]["issue:r:42:"]
def test_week_controller_does_not_use_a_confirmed_snapshot_after_authentication_fails():
result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let denied=false;
const options={storage,getLogin:()=> 'timmy',now:()=>1,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',fetchJson:async()=>{
if(denied){const error=new Error('sign in');error.status=401;throw error;}
return {revision:7,timezone:'UTC',days:[]};}};
await createWeekPlan(options).load();denied=true;
let status=0;
try { await createWeekPlan(options).load(); } catch(error) { status=error.status; }
console.log(JSON.stringify({status}));
""")
assert result["status"] == 401
def test_week_controller_never_mutates_an_offline_confirmed_snapshot():
result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let online=true,requests=[];
const options={storage,getLogin:()=> 'timmy',now:()=>1,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',
fetchJson:async(url,request={})=>{requests.push(request.method||'GET');if(!online)throw new Error('offline');return {
revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one'],capacity_minutes:60,estimates:{one:30}}]};}};
await createWeekPlan(options).load();online=false;
const week=createWeekPlan(options);await week.load();
const staged=week.stageDay('2026-08-22',{ids:['two'],capacity_minutes:60,estimates:{two:30}});
let saved=await week.saveDay('2026-08-22',{ids:['two'],capacity_minutes:60,estimates:{two:30}});
const promoted=await week.promote(3);
console.log(JSON.stringify({staged,saved,promoted,requests,pending:week.pending(),state:week.state()}));
""")
assert result["staged"] is False
assert result["saved"] is False
assert result["promoted"] is False
assert result["requests"] == ["GET", "GET"]
assert result["pending"] is False
assert result["state"]["days"][0]["ids"] == ["one"]
def test_week_controller_refreshes_the_confirmed_snapshot_after_a_successful_save():
result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let tick=0;
const fetchJson=async(_url,options={})=>options.method==='PUT'?{revision:8,timezone:'UTC',days:JSON.parse(options.body).days}:
{revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one'],capacity_minutes:60,estimates:{one:30}}]};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',now:()=>++tick,fetchJson,
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
await week.load();week.rememberItems({one:{kind:'issue',title:'One',repository:'r',number:1}});
week.stageDay('2026-08-22',{ids:['two'],capacity_minutes:90,estimates:{two:45}});
await week.flush();
console.log(values.get('stackchain.week-confirmed.v1.timmy'));
""")
assert result["week"]["revision"] == 8
assert [day["ids"] for day in result["week"]["days"]] == [["one"], ["two"]]
assert result["items"]["one"]["title"] == "One"
def test_week_controller_flushes_once_and_removes_only_the_matching_pending_week():
result = run_controller("""
const values=new Map();
@@ -756,6 +902,17 @@ def test_mobile_week_ahead_review_is_rendered_touch_safe_and_confirmed_explicitl
assert ".week-review-mode .mobile-plan-today-nav" in css
def test_mobile_week_ahead_has_a_touch_safe_offline_snapshot_notice_and_retry():
index = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="week-offline-snapshot"' in index
assert 'id="retry-week-live"' in index
assert 'role="status"' in index[index.index('id="week-offline-snapshot"') - 100:index.index('id="week-offline-snapshot"') + 300]
assert ".week-offline-snapshot" in css
assert "#retry-week-live { min-height:44px;" in css
def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices():
index = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()