Resolve offline Tomorrow plan conflicts on mobile #1165
|
|
@ -241,6 +241,25 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||
.tomorrow-conflict-review { margin-top:14px; }
|
||||
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.tomorrow-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.tomorrow-conflict-plans h4 { margin:0 0 8px; }
|
||||
.tomorrow-conflict-plan-summary { margin:0 0 8px; color:#bfdbfe; }
|
||||
.tomorrow-conflict-plan-list { margin:0; padding-left:22px; overflow-wrap:anywhere; }
|
||||
.tomorrow-conflict-plan-list li + li { margin-top:6px; }
|
||||
.tomorrow-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.tomorrow-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.tomorrow-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
.plan-today-sheet.tomorrow-conflict-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-fit,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-selected,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-available-work,
|
||||
.plan-today-sheet.tomorrow-conflict-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.tomorrow-conflict-plans { grid-template-columns:1fr; }
|
||||
.tomorrow-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
|
||||
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
|
||||
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
|
||||
|
|
|
|||
|
|
@ -400,15 +400,19 @@
|
|||
return saved;
|
||||
}).catch(error => {
|
||||
const conflict = tomorrowPlan.conflict();
|
||||
if (conflict) renderTomorrowQueueSummary({ids:[],sync_pending:false,conflict:true});
|
||||
qs('#mobile-tomorrow-summary').textContent = conflict ? 'Conflict · review required' : qs('#mobile-tomorrow-summary').textContent;
|
||||
qs('#my-work-action-status').textContent = conflict ?
|
||||
'Another device changed Tomorrow. Your phone plan is preserved; reopen Plan Tomorrow to review it.' :
|
||||
'Another device changed Tomorrow. Both plans are preserved; open Tomorrow to choose one.' :
|
||||
`${error.message || 'Tomorrow sync is unavailable.'} Saved on this phone · sync pending.`;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
async function refreshTomorrowQueueSummary() {
|
||||
try {
|
||||
renderTomorrowQueueSummary(await tomorrowPlan.load());
|
||||
const loaded = await tomorrowPlan.load();
|
||||
if (tomorrowPlan.conflict()) qs('#mobile-tomorrow-summary').textContent = 'Conflict · review required';
|
||||
else renderTomorrowQueueSummary(loaded);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry';
|
||||
|
|
@ -2698,6 +2702,8 @@
|
|||
}
|
||||
planToday.cancel();
|
||||
planningTomorrow = false;
|
||||
qs('#tomorrow-conflict-review').hidden = true;
|
||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||
qs('#plan-today-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
if (planTodayTrigger?.dataset.mobileQueue === 'tomorrow') {
|
||||
|
|
@ -3024,6 +3030,38 @@
|
|||
});
|
||||
|
||||
let pendingPlanActualMinutes = null;
|
||||
function tomorrowConflictPlanMarkup(value) {
|
||||
const ids = value?.ids || [];
|
||||
const estimates = value?.estimates || {};
|
||||
const estimated = ids.reduce((total, id) => total + (Number(estimates[id]) || 0), 0);
|
||||
const capacity = Number(value?.capacity_minutes) || 0;
|
||||
const summary = ids.length ? `${ids.length} planned` +
|
||||
(estimated && capacity ? ` · ${estimated} of ${capacity} min` : '') : 'Nothing planned';
|
||||
const available = [...todayMyWork, ...activeMyWork];
|
||||
const items = ids.map(id => {
|
||||
const item = available.find(candidate => todayWork.identity(candidate) === id);
|
||||
const label = item?.title || item?.key || id;
|
||||
const estimate = Number(estimates[id]) || 0;
|
||||
return `<li><strong>${escapeHtml(label)}</strong>${estimate ? ` <span>· ${estimate} min</span>` : ''}</li>`;
|
||||
}).join('');
|
||||
return `<p class="tomorrow-conflict-plan-summary">${summary}</p>` +
|
||||
(items ? `<ol class="tomorrow-conflict-plan-list">${items}</ol>` : '<p class="muted">No work selected.</p>');
|
||||
}
|
||||
|
||||
function showTomorrowConflict(conflict) {
|
||||
qs('#plan-today-title').textContent = 'Resolve Tomorrow conflict';
|
||||
qs('#tomorrow-conflict-phone-plan').innerHTML = tomorrowConflictPlanMarkup(conflict.local);
|
||||
qs('#tomorrow-conflict-server-plan').innerHTML = tomorrowConflictPlanMarkup(conflict.remote);
|
||||
qs('#tomorrow-conflict-status').textContent = '';
|
||||
qs('#tomorrow-conflict-review').hidden = false;
|
||||
qs('#plan-today-sheet').classList.add('tomorrow-conflict-mode');
|
||||
qs('#plan-today-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
const keep = qs('#keep-phone-tomorrow');
|
||||
keep.focus();
|
||||
requestAnimationFrame(() => keep.focus());
|
||||
}
|
||||
|
||||
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
||||
if (!planningOwnerLogin) {
|
||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||
|
|
@ -3037,6 +3075,13 @@
|
|||
taskOverlayHistory.open('plan-today');
|
||||
return;
|
||||
}
|
||||
const conflict = planningTomorrow ? tomorrowPlan.conflict() : null;
|
||||
if (conflict) {
|
||||
showTomorrowConflict(conflict);
|
||||
return;
|
||||
}
|
||||
qs('#tomorrow-conflict-review').hidden = true;
|
||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
|
||||
pendingPlanActualMinutes = null;
|
||||
const protectProposal = pendingProtectToday;
|
||||
|
|
@ -7758,6 +7803,35 @@
|
|||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||||
qs('#plan-tomorrow').addEventListener('click', event => openTomorrowPlanner(event.currentTarget));
|
||||
qs('#keep-phone-tomorrow').addEventListener('click', async () => {
|
||||
const keep = qs('#keep-phone-tomorrow');
|
||||
const use = qs('#use-server-tomorrow');
|
||||
keep.disabled = true;
|
||||
use.disabled = true;
|
||||
qs('#tomorrow-conflict-status').textContent = 'Saving this phone’s plan…';
|
||||
try {
|
||||
const saved = await tomorrowPlan.keepLocal();
|
||||
renderTomorrowQueueSummary(saved);
|
||||
qs('#my-work-action-status').textContent = 'This phone’s Tomorrow plan is saved to your account. Today was not changed.';
|
||||
closePlanToday();
|
||||
} catch (error) {
|
||||
const conflict = tomorrowPlan.conflict();
|
||||
if (conflict) showTomorrowConflict(conflict);
|
||||
qs('#tomorrow-conflict-status').textContent = error?.status === 409 ?
|
||||
'Tomorrow changed again. Both latest plans are still preserved; choose again.' :
|
||||
`${error.message || 'Tomorrow could not be saved.'} Both plans are still preserved.`;
|
||||
} finally {
|
||||
keep.disabled = false;
|
||||
use.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#use-server-tomorrow').addEventListener('click', () => {
|
||||
const adopted = tomorrowPlan.useRemote();
|
||||
if (!adopted) return;
|
||||
renderTomorrowQueueSummary(adopted);
|
||||
qs('#my-work-action-status').textContent = 'Saved account Tomorrow plan selected. Today was not changed.';
|
||||
closePlanToday();
|
||||
});
|
||||
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
||||
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
||||
|
|
|
|||
|
|
@ -449,6 +449,26 @@
|
|||
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
|
||||
<div class="small">Cross-device change</div>
|
||||
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
|
||||
<p class="small muted">Both plans are preserved until one choice succeeds. Today will not change.</p>
|
||||
<div class="tomorrow-conflict-plans">
|
||||
<section aria-labelledby="tomorrow-conflict-phone-title">
|
||||
<h4 id="tomorrow-conflict-phone-title">This phone</h4>
|
||||
<div id="tomorrow-conflict-phone-plan"></div>
|
||||
</section>
|
||||
<section aria-labelledby="tomorrow-conflict-server-title">
|
||||
<h4 id="tomorrow-conflict-server-title">Saved on your account</h4>
|
||||
<div id="tomorrow-conflict-server-plan"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tomorrow-conflict-actions">
|
||||
<button id="keep-phone-tomorrow" type="button">Keep this phone’s plan</button>
|
||||
<button id="use-server-tomorrow" type="button">Use saved account plan</button>
|
||||
</div>
|
||||
<div class="small tomorrow-conflict-status" id="tomorrow-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
|
||||
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
|
||||
<button type="button" data-plan-today-section="today">Today</button>
|
||||
|
|
|
|||
|
|
@ -63,12 +63,42 @@ function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={})
|
|||
}).catch(async error=>{
|
||||
if(error?.status===409) {
|
||||
const remote=await fetchJson('api/v1/tomorrow');
|
||||
lastConflict={local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
lastConflict={key,local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
}
|
||||
throw error;
|
||||
}).finally(()=>{flushing=null;});
|
||||
return flushing;
|
||||
}
|
||||
async function keepLocal() {
|
||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||
const conflictKey=lastConflict.key;
|
||||
const local={...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}};
|
||||
const body=deliveryBody({...local,base_revision:lastConflict.remote.revision});
|
||||
try {
|
||||
const saved=await fetchJson('api/v1/tomorrow',{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/tomorrow');
|
||||
lastConflict={key:conflictKey,local,remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function useRemote() {
|
||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||
const remote=lastConflict.remote;
|
||||
const key=storageKey();
|
||||
if(key&&storage) storage.removeItem(key);
|
||||
const adopted=adopt(remote);
|
||||
lastConflict=null;
|
||||
return adopted;
|
||||
}
|
||||
async function save(value) {
|
||||
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
|
|
@ -133,9 +163,9 @@ function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={})
|
|||
return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}};
|
||||
}
|
||||
function conflict() {
|
||||
return lastConflict&&{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}},
|
||||
remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}};
|
||||
return lastConflict?.key===storageKey()?{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}},
|
||||
remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}}:null;
|
||||
}
|
||||
return {adopt,load,save,stage,pending,flush,conflict,promote,state,summary,nextLocalDate,startLifecycle};
|
||||
return {adopt,load,save,stage,pending,flush,conflict,keepLocal,useRemote,promote,state,summary,nextLocalDate,startLifecycle};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;
|
||||
|
|
|
|||
142
tests/e2e/test_mobile_tomorrow_conflict_release.py
Normal file
142
tests/e2e/test_mobile_tomorrow_conflict_release.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Tomorrow conflict journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_resolves_cross_device_tomorrow_conflicts_on_mobile(
|
||||
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))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
put_bodies: list[dict] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
def tomorrow_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
put_bodies.append(body)
|
||||
if body.get("base_revision") != 12:
|
||||
route.fulfill(status=409, content_type="application/json", body='{"detail":"changed elsewhere"}')
|
||||
return
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"revision": 13, **body}),
|
||||
)
|
||||
return
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"revision": 12,
|
||||
"ids": ["issue:acme/mobile:server:"],
|
||||
"capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:server:": 30},
|
||||
"plan_date": "2026-08-21",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
page.route("**/api/v1/tomorrow", tomorrow_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Tomorrow 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")
|
||||
|
||||
def stage_phone_plan():
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
localStorage.setItem('stackchain.tomorrow-sync.v1.timmy', JSON.stringify({
|
||||
base_revision:11, revision:11,
|
||||
ids:['issue:acme/mobile:phone:'], capacity_minutes:90,
|
||||
estimates:{'issue:acme/mobile:phone:':45},
|
||||
plan_date:'2026-08-21', timezone:'UTC', sync_pending:true,
|
||||
}));
|
||||
window.dispatchEvent(new Event('online'));
|
||||
}"""
|
||||
)
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_have_text(
|
||||
"Conflict · review required"
|
||||
)
|
||||
|
||||
def open_conflict():
|
||||
queue_sheet = page.locator("#mobile-queue-sheet")
|
||||
if not queue_sheet.evaluate("element => element.open"):
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="tomorrow"]').click()
|
||||
review = page.locator("#tomorrow-conflict-review")
|
||||
expect(review).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text(
|
||||
"Resolve Tomorrow conflict"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-phone-plan")).to_contain_text(
|
||||
"issue:acme/mobile:phone:"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-phone-plan")).to_contain_text(
|
||||
"45 min"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-server-plan")).to_contain_text(
|
||||
"issue:acme/mobile:server:"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-server-plan")).to_contain_text(
|
||||
"30 min"
|
||||
)
|
||||
expect(page.locator("#keep-phone-tomorrow")).to_be_focused()
|
||||
for selector in ("#keep-phone-tomorrow", "#use-server-tomorrow"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
stage_phone_plan()
|
||||
open_conflict()
|
||||
initial_puts = len(put_bodies)
|
||||
page.locator("#use-server-tomorrow").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_contain_text("1 planned")
|
||||
assert len(put_bodies) == initial_puts
|
||||
|
||||
stage_phone_plan()
|
||||
open_conflict()
|
||||
page.locator("#keep-phone-tomorrow").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_contain_text("1 planned · 45 of 90 min")
|
||||
assert put_bodies[-1]["base_revision"] == 12
|
||||
assert put_bodies[-1]["ids"] == ["issue:acme/mobile:phone:"]
|
||||
assert page.evaluate(
|
||||
"localStorage.getItem('stackchain.tomorrow-sync.v1.timmy')"
|
||||
) is None
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
|
@ -110,6 +110,94 @@ console.log(JSON.stringify({requests,message,pending:planner.pending(),conflict:
|
|||
assert result["conflict"]["remote"]["revision"] == 12
|
||||
|
||||
|
||||
def test_tomorrow_planner_keeps_phone_plan_by_rebasing_once_onto_conflict_revision():
|
||||
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 fetchJson=async (_url,options={})=>{
|
||||
requests.push({method:options.method||'GET',body:options.body?JSON.parse(options.body):null});
|
||||
if(options.method==='PUT'&&puts++===0){const error=new Error('changed elsewhere');error.status=409;throw error;}
|
||||
if(options.method==='PUT') return {revision:13,...JSON.parse(options.body)};
|
||||
return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{'issue:r:server:':30},plan_date:'2026-08-20',timezone:'UTC'};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{'issue:r:phone:':45}});
|
||||
try { await planner.flush(); } catch(_error) {}
|
||||
const saved=await planner.keepLocal();
|
||||
console.log(JSON.stringify({saved,requests,pending:planner.pending(),conflict:planner.conflict(),state:planner.state()}));
|
||||
""")
|
||||
|
||||
assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"]
|
||||
assert result["requests"][2]["body"]["base_revision"] == 12
|
||||
assert result["requests"][2]["body"]["ids"] == ["issue:r:phone:"]
|
||||
assert result["saved"]["revision"] == 13
|
||||
assert result["pending"] is False
|
||||
assert result["conflict"] is None
|
||||
assert result["state"]["ids"] == ["issue:r:phone:"]
|
||||
|
||||
|
||||
def test_tomorrow_planner_uses_server_plan_without_a_second_write():
|
||||
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=[];
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
requests.push(options.method||'GET');
|
||||
if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
|
||||
return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{'issue:r:server:':30},plan_date:'2026-08-20',timezone:'UTC'};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{'issue:r:phone:':45}});
|
||||
try { await planner.flush(); } catch(_error) {}
|
||||
const adopted=planner.useRemote();
|
||||
console.log(JSON.stringify({adopted,requests,pending:planner.pending(),conflict:planner.conflict(),state:planner.state()}));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["PUT", "GET"]
|
||||
assert result["adopted"]["revision"] == 12
|
||||
assert result["adopted"]["ids"] == ["issue:r:server:"]
|
||||
assert result["pending"] is False
|
||||
assert result["conflict"] is None
|
||||
assert result["state"] == result["adopted"]
|
||||
|
||||
|
||||
def test_tomorrow_conflict_cannot_be_read_or_resolved_after_account_switch():
|
||||
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';
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
|
||||
return {revision:12,ids:['issue:private:timmy:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=>login,fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:private:phone:'],capacity_minutes:90,estimates:{}});
|
||||
try { await planner.flush(); } catch(_error) {}
|
||||
const before=planner.conflict();
|
||||
login='alexander';
|
||||
const after=planner.conflict();
|
||||
const keep=await planner.keepLocal();
|
||||
const use=planner.useRemote();
|
||||
login='timmy';
|
||||
const restored=planner.conflict();
|
||||
console.log(JSON.stringify({before,after,keep,use,restored}));
|
||||
""")
|
||||
|
||||
assert result["before"]["remote"]["ids"] == ["issue:private:timmy:"]
|
||||
assert result["after"] is None
|
||||
assert result["keep"] is False
|
||||
assert result["use"] is False
|
||||
assert result["restored"]["local"]["ids"] == ["issue:private:phone:"]
|
||||
|
||||
|
||||
def test_tomorrow_planner_loads_and_saves_independently_from_today():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
|
|
@ -269,6 +357,23 @@ console.log(JSON.stringify({empty,planned,unestimated,pending}));
|
|||
}
|
||||
|
||||
|
||||
def test_mobile_tomorrow_conflict_review_exposes_two_touch_safe_resolution_paths():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="tomorrow-conflict-review"' in index
|
||||
assert 'id="tomorrow-conflict-phone-plan"' in index
|
||||
assert 'id="tomorrow-conflict-server-plan"' in index
|
||||
assert 'id="keep-phone-tomorrow"' in index
|
||||
assert 'id="use-server-tomorrow"' in index
|
||||
assert "Conflict · review required" in dashboard
|
||||
assert "if (tomorrowPlan.conflict())" in dashboard
|
||||
assert "tomorrowPlan.keepLocal()" in dashboard
|
||||
assert "tomorrowPlan.useRemote()" in dashboard
|
||||
assert ".tomorrow-conflict-actions button { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_mobile_tomorrow_save_is_admitted_before_background_delivery():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user