feat: reconcile Week Ahead edits per day (Closes #1184)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 5m36s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-20 15:43:35 +00:00
parent 43066a0822
commit e0f7c711ed
18 changed files with 344 additions and 52 deletions

View File

@ -273,6 +273,15 @@ textarea { resize: vertical; min-height: 120px; }
.week-conflict-day strong { display:block; color:#bfdbfe; } .week-conflict-day strong { display:block; color:#bfdbfe; }
.week-conflict-actions { display:flex; gap:8px; margin-top:14px; } .week-conflict-actions { display:flex; gap:8px; margin-top:14px; }
.week-conflict-actions button { min-height:44px; flex:1 1 0; } .week-conflict-actions button { min-height:44px; flex:1 1 0; }
.week-conflict-days { display:grid; gap:12px; }
.week-conflict-date { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.week-conflict-date h4 { margin:0 0 8px; color:#bfdbfe; }
.week-conflict-choice { min-height:44px; display:flex; align-items:center; gap:10px; padding:8px; border-radius:8px; }
.week-conflict-choice:has(input:checked) { background:#17365a; outline:1px solid #60a5fa; }
.week-conflict-choice input { min-width:20px; min-height:20px; }
.week-conflict-choice span { display:grid; min-width:0; }
.week-conflict-choice small { color:#a9bdd3; }
#save-merged-week { width:100%; min-height:44px; margin-top:14px; }
.week-conflict-status { min-height:1.4em; margin-top:8px; } .week-conflict-status { min-height:1.4em; margin-top:8px; }
.plan-today-sheet.week-conflict-mode .week-plan-dates, .plan-today-sheet.week-conflict-mode .week-plan-dates,
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav, .plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,

View File

@ -3063,30 +3063,15 @@
requestAnimationFrame(() => keep.focus()); requestAnimationFrame(() => keep.focus());
} }
function weekConflictMarkup(value) {
const days=value?.days||[];
if(!days.length)return '<p class="muted">Nothing planned.</p>';
return days.map(day=>{
const estimates=day.estimates||{};
const minutes=(day.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0);
const capacity=Number(day.capacity_minutes)||0;
const detail=(day.ids||[]).length+' item'+((day.ids||[]).length===1?'':'s')+
(minutes&&capacity?' · '+minutes+' of '+capacity+' min':'');
return '<p class="week-conflict-day"><strong>'+escapeHtml(day.plan_date)+'</strong>'+escapeHtml(detail)+'</p>';
}).join('');
}
function showWeekConflict(conflict) { function showWeekConflict(conflict) {
qs('#plan-today-title').textContent='Resolve Week Ahead conflict'; qs('#plan-today-title').textContent='Resolve Week Ahead conflict';
qs('#week-conflict-phone-plan').innerHTML=weekConflictMarkup(conflict.local); const focus=weekWorkflow.renderConflict(conflict);
qs('#week-conflict-server-plan').innerHTML=weekConflictMarkup(conflict.remote);
qs('#week-conflict-status').textContent=''; qs('#week-conflict-status').textContent='';
qs('#week-conflict-review').hidden=false; qs('#week-conflict-review').hidden=false;
qs('#plan-today-sheet').classList.add('week-conflict-mode'); qs('#plan-today-sheet').classList.add('week-conflict-mode');
qs('#plan-today-sheet').hidden=false; qs('#plan-today-sheet').hidden=false;
document.body.classList.add('task-overlay-open'); document.body.classList.add('task-overlay-open');
const keep=qs('#keep-phone-week'); focus.focus();requestAnimationFrame(()=>focus.focus());
keep.focus();requestAnimationFrame(()=>keep.focus());
} }
function openPlanToday(trigger, navigate = true, actualMinutes = null) { function openPlanToday(trigger, navigate = true, actualMinutes = null) {
@ -7890,6 +7875,26 @@
qs('#my-work-action-status').textContent='Saved account Week Ahead plan selected. Today was not changed.'; qs('#my-work-action-status').textContent='Saved account Week Ahead plan selected. Today was not changed.';
closePlanToday(); closePlanToday();
}); });
qs('#save-merged-week').addEventListener('click', async () => {
const save=qs('#save-merged-week');
save.disabled=true;
qs('#week-conflict-status').textContent='Saving combined Week Ahead plan…';
try {
await weekPlan.saveMerged();
qs('#mobile-week-summary').textContent=weekPlan.summary();
qs('#my-work-action-status').textContent='Combined Week Ahead changes are saved. Today was not changed.';
closePlanToday();
} catch(error) {
const conflict=weekPlan.conflict();
if(conflict)showWeekConflict(conflict);
qs('#week-conflict-status').textContent=error?.status===409?
'Week Ahead changed again. The latest days are preserved; review the remaining choices.':
`${error.message||'The combined week could not be saved.'} Your phone plan is still preserved.`;
} finally {
const conflict=weekPlan.conflict();
save.disabled=Boolean(conflict?.conflicts?.some(item=>!item.choice));
}
});
qs('#cancel-plan-today').addEventListener('click', closePlanToday); qs('#cancel-plan-today').addEventListener('click', closePlanToday);
qs('#plan-today-sheet').addEventListener('click', event => { qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday(); if (event.target === qs('#plan-today-sheet')) closePlanToday();

View File

@ -483,9 +483,11 @@
</section> </section>
<section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden> <section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden>
<div class="small">Cross-device change</div> <div class="small">Cross-device change</div>
<h3 id="week-conflict-title">Choose which Week Ahead plan to keep</h3> <h3 id="week-conflict-title">Resolve Week Ahead changes</h3>
<p class="small muted">Both complete weeks stay preserved until one choice succeeds. Today will not change.</p> <p class="small muted">Changes on different days are combined automatically. Choose only where both devices changed the same day.</p>
<div class="week-conflict-plans"> <div class="week-conflict-days" id="week-conflict-days" hidden></div>
<button id="save-merged-week" type="button" hidden disabled>Save combined week</button>
<div class="week-conflict-plans" id="week-conflict-legacy-plans">
<section aria-labelledby="week-conflict-phone-title"> <section aria-labelledby="week-conflict-phone-title">
<h4 id="week-conflict-phone-title">This phone</h4> <h4 id="week-conflict-phone-title">This phone</h4>
<div id="week-conflict-phone-plan"></div> <div id="week-conflict-phone-plan"></div>
@ -495,7 +497,7 @@
<div id="week-conflict-server-plan"></div> <div id="week-conflict-server-plan"></div>
</section> </section>
</div> </div>
<div class="week-conflict-actions"> <div class="week-conflict-actions" id="week-conflict-legacy-actions">
<button id="keep-phone-week" type="button">Keep this phones week</button> <button id="keep-phone-week" type="button">Keep this phones week</button>
<button id="use-server-week" type="button">Use saved account week</button> <button id="use-server-week" type="button">Use saved account week</button>
</div> </div>

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v126'; const CACHE = 'stackchain-dashboard-shell-v127';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -21,6 +21,9 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{ return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null, revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true, days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))}:{}),
...(value.resolutions&&typeof value.resolutions==='object'?{resolutions:{...value.resolutions}}:{}),
}:false; }:false;
} catch(_error) { return false; } } catch(_error) { return false; }
} }
@ -58,7 +61,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
timezone:timeZone(),days:week.days.filter(item=>item.plan_date!==planDate).concat([{ timezone:timeZone(),days:week.days.filter(item=>item.plan_date!==planDate).concat([{
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null, plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})}, estimates:{...(value.estimates||{})},
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))}; }]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
base_days:(week.base_days||week.days).map(cloneDay)};
try { storage.setItem(key,JSON.stringify(queued)); } try { storage.setItem(key,JSON.stringify(queued)); }
catch(_error) { return false; } catch(_error) { return false; }
lastConflict=null; lastConflict=null;
@ -68,6 +72,22 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
function deliveryBody(value) { function deliveryBody(value) {
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)}; return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
} }
function reconcile(local,remote) {
if(!Array.isArray(local.base_days)) return null;
const byDate=(days,date)=>days.find(day=>day.plan_date===date)||
{plan_date:date,ids:[],capacity_minutes:null,estimates:{}};
const same=(left,right)=>JSON.stringify(cloneDay(left))===JSON.stringify(cloneDay(right));
const dates=[...new Set([...local.base_days,...local.days,...remote.days].map(day=>day.plan_date))].sort();
const conflicts=[],days=dates.map(date=>{
const base=byDate(local.base_days,date),phone=byDate(local.days,date),account=byDate(remote.days,date);
const phoneChanged=!same(phone,base),accountChanged=!same(account,base);
if(phoneChanged&&accountChanged&&!same(phone,account)){
conflicts.push({plan_date:date,local:cloneDay(phone),remote:cloneDay(account)});return cloneDay(phone);
}
return cloneDay(phoneChanged?phone:account);
});
return {days,conflicts};
}
function flush() { function flush() {
if(flushing) return flushing; if(flushing) return flushing;
const queued=pending(); const queued=pending();
@ -83,8 +103,18 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
}).catch(async error=>{ }).catch(async error=>{
if(error?.status===409){ if(error?.status===409){
const remote=await fetchJson('api/v1/week'); const remote=await fetchJson('api/v1/week');
lastConflict={key,local:pending(),remote:{revision:remote.revision,timezone:remote.timezone||null, const local=pending();
days:remote.days.map(cloneDay)}}; const merged=local&&reconcile(local,remote);
if(merged&&!merged.conflicts.length){
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
if(!pending()) adopt(saved);
return saved;
}
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
} }
throw error; throw error;
}).finally(()=>{flushing=null;}); }).finally(()=>{flushing=null;});
@ -108,10 +138,54 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
} }
} }
function conflict() { function conflict() {
return lastConflict&&(!lastConflict.key||lastConflict.key===storageKey())?{ const value=lastConflict&&(!lastConflict.key||lastConflict.key===storageKey())?{
local:{...lastConflict.local,days:lastConflict.local.days.map(cloneDay)}, local:{...lastConflict.local,days:lastConflict.local.days.map(cloneDay)},
remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)}, remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)},
}:null; }:null;
if(value&&lastConflict.merged) value.conflicts=lastConflict.merged.conflicts.map(item=>({
plan_date:item.plan_date,local:cloneDay(item.local),remote:cloneDay(item.remote),
choice:lastConflict.choices[item.plan_date]||null,
}));
return value;
}
function chooseDay(planDate,source) {
if(!lastConflict?.merged||!['phone','account'].includes(source)||
!lastConflict.merged.conflicts.some(item=>item.plan_date===planDate)) return false;
const key=storageKey(),queued=pending();
if(key&&queued){
try {
const raw=JSON.parse(storage.getItem(key));
raw.resolutions={...(raw.resolutions||{}),[planDate]:source};
storage.setItem(key,JSON.stringify(raw));
} catch(_error) { return false; }
}
lastConflict.choices[planDate]=source;
return conflict();
}
async function saveMerged() {
if(!lastConflict?.merged||lastConflict.key!==storageKey()) return false;
if(lastConflict.merged.conflicts.some(item=>!lastConflict.choices[item.plan_date])) return false;
const choices=lastConflict.choices;
const days=lastConflict.merged.days.map(day=>{
const item=lastConflict.merged.conflicts.find(value=>value.plan_date===day.plan_date);
return item?cloneDay(choices[day.plan_date]==='account'?item.remote:item.local):cloneDay(day);
});
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days};
try {
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
if(!pending()) adopt(saved);
lastConflict=null;
return saved;
} catch(error) {
if(error?.status===409){
const remote=await fetchJson('api/v1/week'),merged=reconcile(local,remote);
lastConflict={key:storageKey(),local,remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
}
throw error;
}
} }
async function keepLocal() { async function keepLocal() {
if(!lastConflict||lastConflict.key!==storageKey()) return false; if(!lastConflict||lastConflict.key!==storageKey()) return false;
@ -157,12 +231,45 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned'; const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
return label+(pending()?' · sync pending':''); return label+(pending()?' · sync pending':'');
} }
return {adopt,state,dates,day,load,saveDay,stageDay,pending,flush,conflict,keepLocal,useRemote,promote,summary}; return {adopt,state,dates,day,load,saveDay,stageDay,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,summary};
} }
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute, function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
todayWork,refresh,warm}={}) { todayWork,refresh,warm}={}) {
let selectedDate=null; let selectedDate=null;
let blockedReviewOpen=false; let blockedReviewOpen=false;
function conflictDetail(day) {
const estimates=day.estimates||{},ids=day.ids||[];
const minutes=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0),capacity=Number(day.capacity_minutes)||0;
return ids.length+' item'+(ids.length===1?'':'s')+(minutes&&capacity?' · '+minutes+' of '+capacity+' min':'');
}
function conflictMarkup(value) {
const days=value?.days||[];
return days.length?days.map(day=>'<p class="week-conflict-day"><strong>'+escapeHtml(day.plan_date)+'</strong>'+escapeHtml(conflictDetail(day))+'</p>').join(''):
'<p class="muted">Nothing planned.</p>';
}
function renderConflict(conflict) {
qs('#week-conflict-phone-plan').innerHTML=conflictMarkup(conflict.local);
qs('#week-conflict-server-plan').innerHTML=conflictMarkup(conflict.remote);
const perDay=Array.isArray(conflict.conflicts),days=qs('#week-conflict-days'),save=qs('#save-merged-week');
qs('#week-conflict-legacy-plans').hidden=perDay;qs('#week-conflict-legacy-actions').hidden=perDay;
days.hidden=!perDay;save.hidden=!perDay;
if(perDay){
days.innerHTML=conflict.conflicts.map(item=>{
const date=escapeAttribute(item.plan_date),name='week-conflict-'+date;
const choice=source=>'<label class="week-conflict-choice"><input type="radio" name="'+name+
'" data-week-conflict-choice="'+source+'" data-week-conflict-date="'+date+'"'+(item.choice===source?' checked':'')+
'><span><strong>'+(source==='phone'?'This phone':'Saved account')+'</strong><small>'+escapeHtml(conflictDetail(source==='phone'?item.local:item.remote))+'</small></span></label>';
return '<section class="week-conflict-date"><h4>'+escapeHtml(item.plan_date)+'</h4>'+choice('phone')+choice('account')+'</section>';
}).join('');
save.disabled=conflict.conflicts.some(item=>!item.choice);
days.querySelectorAll('[data-week-conflict-choice]').forEach(input=>input.addEventListener('change',event=>{
controller.chooseDay(event.currentTarget.dataset.weekConflictDate,event.currentTarget.dataset.weekConflictChoice);
save.disabled=controller.conflict().conflicts.some(item=>!item.choice);
}));
}
return perDay?days.querySelector('[data-week-conflict-choice]'):qs('#keep-phone-week');
}
function renderDates() { function renderDates() {
const root=qs('#week-plan-dates'); const root=qs('#week-plan-dates');
root.hidden=!selectedDate; root.hidden=!selectedDate;
@ -203,7 +310,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false; qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
} }
} }
return {open,save,promote,renderDates,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate, return {open,save,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate,
day:()=>selectedDate?controller.day(selectedDate):null, day:()=>selectedDate?controller.day(selectedDate):null,
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null, copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
clear(){selectedDate=null;renderDates();}}; clear(){selectedDate=null;renderDates();}};

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import os import os
import threading import threading
from datetime import date, timedelta
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -77,3 +78,80 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
fake.shutdown() fake.shutdown()
fake.server_close() fake.server_close()
thread.join(timeout=5) thread.join(timeout=5)
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
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()
puts: list[dict] = []
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})
page_errors: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
def week_route(route):
if route.request.method == "PUT":
body = json.loads(route.request.post_data or "{}")
puts.append(body)
if len(puts) == 1:
route.fulfill(status=409, content_type="application/json", body='{"message":"changed"}')
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 6, "timezone": body["timezone"], "days": body["days"],
}))
return
if puts:
changed_date = puts[0]["days"][0]["plan_date"]
following_date = (date.fromisoformat(changed_date) + timedelta(days=1)).isoformat()
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 5, "timezone": puts[0]["timezone"], "days": [
{"plan_date": changed_date, "ids": [], "capacity_minutes": 90, "estimates": {}},
{"plan_date": following_date, "ids": [], "capacity_minutes": 30, "estimates": {}},
],
}))
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 4, "timezone": None, "days": [],
}))
page.route("**/api/v1/week", week_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Week conflict release 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()
page.locator("#plan-today-available").fill("60")
page.locator("#save-today-plan").click()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
choices = page.locator("[data-week-conflict-choice]")
expect(choices).to_have_count(2)
for index in range(2):
bounds = choices.nth(index).locator("xpath=..").bounding_box()
assert bounds and bounds["height"] >= 44
page.locator('[data-week-conflict-choice="phone"]').check()
expect(page.locator("#save-merged-week")).to_be_enabled()
page.locator("#save-merged-week").click()
expect(page.locator("#plan-today-sheet")).to_be_hidden()
assert len(puts) == 2
assert [day["capacity_minutes"] for day in puts[-1]["days"]] == [60, 30]
assert not page_errors, f"Week conflict recovery raised: {page_errors}"
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v126" in worker assert "stackchain-dashboard-shell-v127" in worker

View File

@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

View File

@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v126" in worker assert "stackchain-dashboard-shell-v127" in worker

View File

@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v126" in worker assert "stackchain-dashboard-shell-v127" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v126" in worker assert "stackchain-dashboard-shell-v127" in worker
assert ".device-setup-panel" in css assert ".device-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css assert "overflow-x:hidden" in css

View File

@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v126" in worker assert "stackchain-dashboard-shell-v127" in worker
assert "BASE + 'static/mobile-insights.js'" in worker assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v126" in service_worker assert "stackchain-dashboard-shell-v127" in service_worker
@pytest.mark.anyio @pytest.mark.anyio

View File

@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -180,13 +180,20 @@ async function dispatchPush(payload) {{
def test_private_today_action_mailbox_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
@ -195,7 +202,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source
@ -204,7 +211,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source assert "BASE + 'static/issue-attachment.js'" in source
@ -212,14 +219,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -227,7 +234,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -235,7 +242,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -245,14 +252,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/later-picker.js'" in source assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source
@ -261,21 +268,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source
@ -1245,7 +1252,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/queue-today.js'" in source assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v126';" in service_worker assert "const CACHE = 'stackchain-dashboard-shell-v127';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v126" in source assert "stackchain-dashboard-shell-v127" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source

View File

@ -67,6 +67,84 @@ console.log(JSON.stringify({conflict:week.conflict(),state:week.state()}));
assert result["state"]["revision"] == 7 assert result["state"]["revision"] == 7
def test_week_controller_automatically_merges_changes_made_to_different_days():
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)};
const requests=[];let puts=0;
const base={revision:7,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['tuesday-base'],capacity_minutes:60,estimates:{'tuesday-base':30}},
{plan_date:'2026-08-22',ids:['friday-base'],capacity_minutes:90,estimates:{'friday-base':45}}
]};
const remote={revision:8,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['tuesday-account'],capacity_minutes:60,estimates:{'tuesday-account':30}},
{plan_date:'2026-08-22',ids:['friday-base'],capacity_minutes:90,estimates:{'friday-base':45}}
]};
const fetchJson=async(_url,options={})=>{
const body=options.body?JSON.parse(options.body):null;requests.push({method:options.method||'GET',body});
if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;}
if(options.method==='PUT')return {revision:9,timezone:body.timezone,days:body.days};
return remote;
};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt(base);
week.stageDay('2026-08-22',{ids:['friday-phone'],capacity_minutes:90,estimates:{'friday-phone':50}});
const saved=await week.flush();
console.log(JSON.stringify({saved,requests,pending:week.pending(),conflict:week.conflict()}));
""")
assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"]
assert result["requests"][2]["body"]["base_revision"] == 8
assert result["saved"]["days"] == [
{"plan_date": "2026-08-21", "ids": ["tuesday-account"], "capacity_minutes": 60,
"estimates": {"tuesday-account": 30}},
{"plan_date": "2026-08-22", "ids": ["friday-phone"], "capacity_minutes": 90,
"estimates": {"friday-phone": 50}},
]
assert result["pending"] is False
assert result["conflict"] is None
def test_week_controller_resolves_only_the_same_day_conflict_and_keeps_other_account_changes():
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)};
const requests=[];let puts=0;
const base={revision:4,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['same-base'],capacity_minutes:60,estimates:{}},
{plan_date:'2026-08-22',ids:['other-base'],capacity_minutes:60,estimates:{}}
]};
const remote={revision:5,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['same-account'],capacity_minutes:60,estimates:{}},
{plan_date:'2026-08-22',ids:['other-account'],capacity_minutes:60,estimates:{}}
]};
const fetchJson=async(_url,options={})=>{
const body=options.body?JSON.parse(options.body):null;requests.push({method:options.method||'GET',body});
if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;}
if(options.method==='PUT')return {revision:6,timezone:body.timezone,days:body.days};
return remote;
};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt(base);
week.stageDay('2026-08-21',{ids:['same-phone'],capacity_minutes:90,estimates:{}});
try { await week.flush(); } catch(_error) {}
const before=week.conflict();
week.chooseDay('2026-08-21','phone');
const selected=JSON.parse(values.get('stackchain.week-sync.v1.timmy'));
const saved=await week.saveMerged();
console.log(JSON.stringify({before,selected,saved,requests,pending:week.pending(),conflict:week.conflict()}));
""")
assert [item["plan_date"] for item in result["before"]["conflicts"]] == ["2026-08-21"]
assert result["before"]["conflicts"][0]["local"]["ids"] == ["same-phone"]
assert result["before"]["conflicts"][0]["remote"]["ids"] == ["same-account"]
assert result["selected"]["resolutions"] == {"2026-08-21": "phone"}
assert result["requests"][2]["body"]["base_revision"] == 5
assert [day["ids"] for day in result["saved"]["days"]] == [["same-phone"], ["other-account"]]
assert result["pending"] is False
assert result["conflict"] is None
def test_week_controller_restores_an_account_bound_week_before_network_delivery(): def test_week_controller_restores_an_account_bound_week_before_network_delivery():
result = run_controller(""" result = run_controller("""
const values=new Map(); const values=new Map();
@ -294,10 +372,16 @@ def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices(
dashboard = (FRONTEND / "dashboard.js").read_text() dashboard = (FRONTEND / "dashboard.js").read_text()
assert 'id="week-conflict-review"' in index assert 'id="week-conflict-review"' in index
assert 'id="week-conflict-days"' in index
assert 'id="save-merged-week"' in index
assert 'id="keep-phone-week"' in index assert 'id="keep-phone-week"' in index
assert 'id="use-server-week"' in index assert 'id="use-server-week"' in index
assert "storage:localStorage" in dashboard assert "storage:localStorage" in dashboard
assert "controller.stageDay" in CONTROLLER.read_text() assert "controller.stageDay" in CONTROLLER.read_text()
assert "weekPlan.flush" in dashboard assert "weekPlan.flush" in dashboard
assert "week-conflict-mode" in dashboard assert "week-conflict-mode" in dashboard
assert "controller.chooseDay" in CONTROLLER.read_text()
assert "weekPlan.saveMerged" in dashboard
assert 'data-week-conflict-choice' in CONTROLLER.read_text()
assert ".week-conflict-actions button { min-height:44px;" in css assert ".week-conflict-actions button { min-height:44px;" in css
assert ".week-conflict-choice { min-height:44px;" in css