Merge pull request 'Recover admitted Today blockers across reloads' (#1069) from timmy/1068-recover-today-blocker-reload into main
This commit is contained in:
commit
027ded1f8f
|
|
@ -291,6 +291,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.today-progress-blocker { display:grid; gap:8px; margin-top:14px; padding:12px; border:1px solid #875f2a; border-radius:12px; background:#21180d; }
|
.today-progress-blocker { display:grid; gap:8px; margin-top:14px; padding:12px; border:1px solid #875f2a; border-radius:12px; background:#21180d; }
|
||||||
.today-progress-blocker input, .today-progress-blocker button { box-sizing:border-box; width:100%; min-height:44px; }
|
.today-progress-blocker input, .today-progress-blocker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||||
.today-progress-blocker p { margin:0; }
|
.today-progress-blocker p { margin:0; }
|
||||||
|
.today-progress-blocker .today-progress-blocker-recovery { padding:8px; border-radius:8px; background:#3a270d; color:#fde68a; font-weight:700; }
|
||||||
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
|
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
|
||||||
.today-progress-actions button { width:100%; }
|
.today-progress-actions button { width:100%; }
|
||||||
.today-progress-panel .voice-conversation { margin-top:10px; }
|
.today-progress-panel .voice-conversation { margin-top:10px; }
|
||||||
|
|
|
||||||
|
|
@ -1603,6 +1603,7 @@
|
||||||
<p class="small muted">Save privately on this device, or post to the exact Gitea item. Today keeps running either way.</p>
|
<p class="small muted">Save privately on this device, or post to the exact Gitea item. Today keeps running either way.</p>
|
||||||
<section class="today-progress-blocker" aria-labelledby="today-progress-blocker-title">
|
<section class="today-progress-blocker" aria-labelledby="today-progress-blocker-title">
|
||||||
<strong id="today-progress-blocker-title">Blocked?</strong>
|
<strong id="today-progress-blocker-title">Blocked?</strong>
|
||||||
|
<p id="today-progress-blocker-recovery" class="today-progress-blocker-recovery small" role="status" hidden></p>
|
||||||
<label for="today-blocker-return-at">Return this item to Later at</label>
|
<label for="today-blocker-return-at">Return this item to Later at</label>
|
||||||
<input id="today-blocker-return-at" type="datetime-local" />
|
<input id="today-blocker-return-at" type="datetime-local" />
|
||||||
<button id="post-today-blocker" type="button">Post blocker & move on</button>
|
<button id="post-today-blocker" type="button">Post blocker & move on</button>
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,16 @@ maxLength = 2000, maxItems = 20 }) {
|
||||||
write(drafts);
|
write(drafts);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
until = record.blocker_until;
|
const replacementUntil = String(until || '').trim();
|
||||||
|
until = replacementUntil || record.blocker_until;
|
||||||
|
if (replacementUntil && replacementUntil !== record.blocker_until) {
|
||||||
|
const drafts = read();
|
||||||
|
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||||
|
drafts[target.identity] = { ...drafts[target.identity], blocker_until:replacementUntil };
|
||||||
|
write(drafts);
|
||||||
|
record = drafts[target.identity];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (typeof completeEvidence === 'function') await completeEvidence();
|
if (typeof completeEvidence === 'function') await completeEvidence();
|
||||||
|
|
@ -176,7 +185,13 @@ maxLength = 2000, maxItems = 20 }) {
|
||||||
return admission;
|
return admission;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { load, save, discard:identity => save(identity, ''), post, postBlocker };
|
function blockerRecovery(identity) {
|
||||||
|
if (!validIdentity(identity)) return null;
|
||||||
|
const record = read()[identity];
|
||||||
|
return record?.blocker_pending === true ? { pending:true, until:String(record.blocker_until || '') } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, save, discard:identity => save(identity, ''), post, postBlocker, blockerRecovery };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
|
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
|
||||||
|
|
@ -299,8 +314,31 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
|
||||||
const body = qs('#today-progress-body');
|
const body = qs('#today-progress-body');
|
||||||
const status = qs('#today-progress-status');
|
const status = qs('#today-progress-status');
|
||||||
const launcher = qs('[data-mobile-today-update]');
|
const launcher = qs('[data-mobile-today-update]');
|
||||||
|
const blockerButton = qs('#post-today-blocker');
|
||||||
|
const blockerReturn = qs('#today-blocker-return-at');
|
||||||
|
const blockerRecovery = qs('#today-progress-blocker-recovery');
|
||||||
|
const saveButton = qs('#save-today-progress');
|
||||||
|
const postButton = qs('#post-today-progress');
|
||||||
let openedTarget = null;
|
let openedTarget = null;
|
||||||
|
|
||||||
|
const localDateTime = value => {
|
||||||
|
const date = new Date(value || '');
|
||||||
|
if (!Number.isFinite(date.getTime())) return '';
|
||||||
|
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
const showBlockerRecovery = recovery => {
|
||||||
|
const pending = recovery?.pending === true;
|
||||||
|
body.disabled = pending;
|
||||||
|
saveButton.disabled = pending;
|
||||||
|
postButton.disabled = pending;
|
||||||
|
if (blockerButton) blockerButton.textContent = pending ? 'Finish moving on' : 'Post blocker & move on';
|
||||||
|
if (blockerReturn) blockerReturn.value = pending ? localDateTime(recovery.until) : '';
|
||||||
|
if (blockerRecovery) {
|
||||||
|
blockerRecovery.hidden = !pending;
|
||||||
|
blockerRecovery.textContent = pending ? 'Blocker queued—finish moving on. The posted update cannot be changed.' : '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const target = currentTarget();
|
const target = currentTarget();
|
||||||
launcher.hidden = !target;
|
launcher.hidden = !target;
|
||||||
|
|
@ -334,6 +372,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
|
||||||
openedTarget = target;
|
openedTarget = target;
|
||||||
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
|
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
|
||||||
body.value = progress.load(target.identity);
|
body.value = progress.load(target.identity);
|
||||||
|
showBlockerRecovery(progress.blockerRecovery?.(target.identity));
|
||||||
status.textContent = 'Restoring saved photo evidence…';
|
status.textContent = 'Restoring saved photo evidence…';
|
||||||
sheet.showModal();
|
sheet.showModal();
|
||||||
try {
|
try {
|
||||||
|
|
@ -357,14 +396,13 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
|
||||||
announce('Progress update saved privately to this Today item.');
|
announce('Progress update saved privately to this Today item.');
|
||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
const blockerButton = qs('#post-today-blocker');
|
|
||||||
blockerButton?.addEventListener('click', async () => {
|
blockerButton?.addEventListener('click', async () => {
|
||||||
const target = currentTarget();
|
const target = currentTarget();
|
||||||
if (!target || target.identity !== openedTarget?.identity) {
|
if (!target || target.identity !== openedTarget?.identity) {
|
||||||
status.textContent = 'The active Today item changed. Close and open its update again.';
|
status.textContent = 'The active Today item changed. Close and open its update again.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const returnInput = qs('#today-blocker-return-at');
|
const returnInput = blockerReturn;
|
||||||
const wakeAt = new Date(returnInput?.value || '');
|
const wakeAt = new Date(returnInput?.value || '');
|
||||||
if (!body.value.trim()) {
|
if (!body.value.trim()) {
|
||||||
status.textContent = 'Describe what is blocking this Today item.';
|
status.textContent = 'Describe what is blocking this Today item.';
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
fake_thread.join(timeout=5)
|
fake_thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
def test_release_artifact_posts_blocker_defers_and_opens_next_mobile_issue(tmp_path: Path):
|
def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path):
|
||||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||||
assert len(archives) == 1
|
assert len(archives) == 1
|
||||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||||
|
|
@ -296,8 +296,44 @@ def test_release_artifact_posts_blocker_defers_and_opens_next_mobile_issue(tmp_p
|
||||||
page.locator("[data-mobile-today-update]").click()
|
page.locator("[data-mobile-today-update]").click()
|
||||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||||
page.locator("#today-progress-body").fill("Blocked waiting for the design owner")
|
page.locator("#today-progress-body").fill("Blocked waiting for the design owner")
|
||||||
|
page.locator("#post-today-progress").click()
|
||||||
|
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||||
|
page.locator("[data-today-break-open]").click()
|
||||||
|
page.locator('[data-today-break-minutes="5"]').click()
|
||||||
|
expect(page.locator("#resume-today-break")).to_be_visible()
|
||||||
|
|
||||||
|
page.evaluate("""() => {
|
||||||
|
localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
drafts: {
|
||||||
|
'issue:acme/mobile:41:': {
|
||||||
|
body: 'Blocked waiting for the design owner',
|
||||||
|
operation_id: 'already-admitted-blocker',
|
||||||
|
blocker_pending: true,
|
||||||
|
blocker_until: '2000-01-02T03:04:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}""")
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||||
|
page.locator("#close-issue-sheet").click()
|
||||||
|
if page.locator("#plan-today-sheet").is_visible():
|
||||||
|
page.locator("#cancel-plan-today").click()
|
||||||
|
expect(page.locator("#resume-today-break")).to_be_visible()
|
||||||
|
page.locator("#resume-today-break").click()
|
||||||
|
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||||
|
page.locator("#close-issue-sheet").click()
|
||||||
|
page.locator("[data-mobile-today-update]").click()
|
||||||
|
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||||
|
expect(page.locator("#today-progress-blocker-recovery")).to_be_visible()
|
||||||
|
expect(page.locator("#today-progress-blocker-recovery")).to_contain_text("Blocker queued")
|
||||||
|
expect(page.locator("#today-progress-body")).to_be_disabled()
|
||||||
|
expect(page.locator("#today-blocker-return-at")).to_have_value("2000-01-02T03:04")
|
||||||
page.locator("#today-blocker-return-at").fill("2099-08-19T09:00")
|
page.locator("#today-blocker-return-at").fill("2099-08-19T09:00")
|
||||||
blocker = page.locator("#post-today-blocker")
|
blocker = page.locator("#post-today-blocker")
|
||||||
|
expect(blocker).to_have_text("Finish moving on")
|
||||||
bounds = blocker.bounding_box()
|
bounds = blocker.bounding_box()
|
||||||
assert bounds and bounds["height"] >= 44
|
assert bounds and bounds["height"] >= 44
|
||||||
blocker.click()
|
blocker.click()
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,46 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert output["stored"] == []
|
assert output["stored"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_admitted_blocker_recovery_restores_and_replaces_its_planning_time_without_readmission():
|
||||||
|
script = f"""
|
||||||
|
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
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 admissions=[]; const transitions=[]; let transitionFails=true;
|
||||||
|
const options={{
|
||||||
|
storage,getLogin:()=> 'timmy',makeId:()=> 'stable-blocker-op',
|
||||||
|
admit:async message=>{{admissions.push(message);return {{background:true}};}},
|
||||||
|
}};
|
||||||
|
let progress=createProgress(options);
|
||||||
|
const target={{identity:'issue:stackchain/dashboard:1068:',kind:'issue',repository:'stackchain/dashboard',number:1068}};
|
||||||
|
const expired='2026-08-18T08:00:00.000Z';
|
||||||
|
const replacement='2026-08-19T09:30:00.000Z';
|
||||||
|
const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}};
|
||||||
|
try {{await progress.postBlocker(target,'Waiting for production access',[],null,expired,transition);}} catch (_error) {{}}
|
||||||
|
progress=createProgress(options);
|
||||||
|
const restored=progress.blockerRecovery(target.identity);
|
||||||
|
transitionFails=false;
|
||||||
|
const recovered=await progress.postBlocker(target,'changed text must not be admitted',[],null,replacement,transition);
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
restored,recovered,admissions,transitions,after:progress.blockerRecovery(target.identity),body:progress.load(target.identity),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
||||||
|
assert output["restored"] == {
|
||||||
|
"pending": True,
|
||||||
|
"until": "2026-08-18T08:00:00.000Z",
|
||||||
|
}
|
||||||
|
assert len(output["admissions"]) == 1
|
||||||
|
assert output["admissions"][0]["body"] == "Waiting for production access"
|
||||||
|
assert output["transitions"] == [
|
||||||
|
["issue:stackchain/dashboard:1068:", "2026-08-18T08:00:00.000Z"],
|
||||||
|
["issue:stackchain/dashboard:1068:", "2026-08-19T09:30:00.000Z"],
|
||||||
|
]
|
||||||
|
assert output["recovered"]["alreadyAdmitted"] is True
|
||||||
|
assert output["after"] is None
|
||||||
|
assert output["body"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
|
def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
|
@ -373,6 +413,59 @@ createView({{
|
||||||
assert output["closed"] is True
|
assert output["closed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_sheet_restores_admitted_blocker_recovery_and_accepts_a_new_planning_time():
|
||||||
|
script = f"""
|
||||||
|
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
class Element {{
|
||||||
|
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.disabled=false;this.listeners={{}}; }}
|
||||||
|
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||||
|
click() {{ return this.listeners.click?.(); }}
|
||||||
|
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
|
||||||
|
}}
|
||||||
|
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
|
||||||
|
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
|
||||||
|
'#today-blocker-return-at','#post-today-blocker','#today-progress-blocker-recovery'];
|
||||||
|
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||||
|
const target={{identity:'issue:stackchain/dashboard:1068:',kind:'issue',repository:'stackchain/dashboard',number:1068,label:'#1068',title:'Recover blocker'}};
|
||||||
|
const calls=[];
|
||||||
|
const progress={{
|
||||||
|
load:()=> 'Waiting for production access',save:()=>true,
|
||||||
|
blockerRecovery:()=>({{pending:true,until:'2000-01-02T03:04:00.000Z'}}),
|
||||||
|
postBlocker:async(...args)=>{{calls.push(args);return {{background:true,alreadyAdmitted:true}};}},
|
||||||
|
}};
|
||||||
|
createView({{
|
||||||
|
progress,currentTarget:()=>target,qs:selector=>elements[selector],
|
||||||
|
photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},moveOn:async()=>true,
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
await elements['[data-mobile-today-update]'].click();
|
||||||
|
const restored={{
|
||||||
|
body:elements['#today-progress-body'].value,bodyDisabled:elements['#today-progress-body'].disabled,
|
||||||
|
until:elements['#today-blocker-return-at'].value,recoveryHidden:elements['#today-progress-blocker-recovery'].hidden,
|
||||||
|
recoveryText:elements['#today-progress-blocker-recovery'].textContent,button:elements['#post-today-blocker'].textContent,
|
||||||
|
saveDisabled:elements['#save-today-progress'].disabled,postDisabled:elements['#post-today-progress'].disabled,
|
||||||
|
}};
|
||||||
|
elements['#today-blocker-return-at'].value='2099-08-19T09:30';
|
||||||
|
await elements['#post-today-blocker'].click();
|
||||||
|
process.stdout.write(JSON.stringify({{restored,posted:{{body:calls[0][1],until:calls[0][4]}},closed:!elements['#today-progress-sheet'].open}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
assert output["restored"] == {
|
||||||
|
"body": "Waiting for production access",
|
||||||
|
"bodyDisabled": True,
|
||||||
|
"until": "2000-01-02T03:04",
|
||||||
|
"recoveryHidden": False,
|
||||||
|
"recoveryText": "Blocker queued—finish moving on. The posted update cannot be changed.",
|
||||||
|
"button": "Finish moving on",
|
||||||
|
"saveDisabled": True,
|
||||||
|
"postDisabled": True,
|
||||||
|
}
|
||||||
|
assert output["posted"]["body"] == "Waiting for production access"
|
||||||
|
assert output["posted"]["until"].startswith("2099-08-19T09:30")
|
||||||
|
assert output["closed"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
|
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
|
||||||
script = f"""
|
script = f"""
|
||||||
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user