import json
import subprocess
from pathlib import Path


ROOT = Path(__file__).parents[1]
TODAY_PROGRESS = ROOT / "frontend" / "today-progress.js"


def run_node(script: str) -> dict:
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    return json.loads(result.stdout)


def test_progress_drafts_are_account_and_item_scoped_bounded_and_durable():
    script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map();
const storage = {{
  getItem:key => values.has(key) ? values.get(key) : null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let login = 'Timmy';
const progress = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
const issue = {{identity:'issue:stackchain/dashboard:12:', kind:'issue', repository:'stackchain/dashboard', number:12}};
const pull = {{identity:'pull:stackchain/dashboard:13:', kind:'pull', repository:'stackchain/dashboard', number:13}};
const saved = progress.save(issue.identity, '  Investigated latency  ');
progress.save(pull.identity, 'Reviewing tests');
const restored = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
login = 'alexander';
const otherAccount = restored.load(issue.identity);
login = 'timmy';
process.stdout.write(JSON.stringify({{
  saved,
  issue:restored.load(issue.identity),
  pull:restored.load(pull.identity),
  otherAccount,
  tooLong:restored.save(issue.identity, 'x'.repeat(21)),
  keys:[...values.keys()],
}}));
"""
    output = run_node(script)
    assert output["saved"] is True
    assert output["issue"] == "Investigated latency"
    assert output["pull"] == "Reviewing tests"
    assert output["otherAccount"] == ""
    assert output["tooLong"] is False
    assert output["keys"] == ["stackchain.today-progress.v1.timmy"]


def test_post_admits_exact_comment_once_and_clears_only_after_durable_admission():
    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 calls=[]; let fail=true;
const progress=createProgress({{
  storage, getLogin:()=> 'timmy', makeId:()=> 'progress-op-1',
  admit:async message => {{ calls.push(message); if (fail) throw new Error('offline store unavailable'); return {{background:true,item:{{id:message.operationId}}}}; }},
}});
const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
progress.save(target.identity, 'Shipped the first slice');
let error='';
try {{ await progress.post(target); }} catch (caught) {{ error=caught.message; }}
const retained=progress.load(target.identity);
fail=false;
const admitted=await progress.post(target);
process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls,admitted}}));
"""
    output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
    assert output["error"] == "offline store unavailable"
    assert output["retained"] == "Shipped the first slice"
    assert output["after"] == ""
    assert len(output["calls"]) == 2
    assert output["calls"][0] == output["calls"][1] == {
        "kind": "issue-comment",
        "repository": "stackchain/dashboard",
        "number": 12,
        "body": "Shipped the first slice",
        "operationId": "progress-op-1",
    }
    assert output["admitted"]["background"] is True


def test_photo_only_progress_is_durably_admitted_and_retained_until_success():
    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 photo = {{filename:'result.jpg',contentType:'image/jpeg',blob:{{size:42}},operationId:'photo-op-1'}};
const calls=[]; let fail=true;
const progress=createProgress({{
  storage, getLogin:()=> 'timmy', makeId:()=> 'progress-photo-op-1',
  admit:async message => {{ calls.push(message); if (fail) throw new Error('photo store unavailable'); return {{background:true}}; }},
}});
const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
let error='';
try {{ await progress.post(target, '', [photo]); }} catch (caught) {{ error=caught.message; }}
const retained=progress.load(target.identity);
fail=false;
await progress.post(target, undefined, [photo]);
process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls}}));
"""
    output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
    assert output["error"] == "photo store unavailable"
    assert output["retained"] == ""
    assert output["after"] == ""
    assert len(output["calls"]) == 2
    assert output["calls"][0] == output["calls"][1] == {
        "kind": "issue-comment",
        "repository": "stackchain/dashboard",
        "number": 12,
        "body": "",
        "operationId": "progress-photo-op-1",
        "attachments": [{
            "filename": "result.jpg",
            "contentType": "image/jpeg",
            "blob": {"size": 42},
            "operationId": "photo-op-1",
        }],
    }


def test_photo_payload_changes_rotate_the_progress_operation_identity():
    script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map(); const ids = ['progress-1', 'progress-2', 'progress-3'];
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const progress=createProgress({{storage,getLogin:()=> 'timmy',makeId:()=>ids.shift(),admit:async()=>({{}})}});
const identity='issue:stackchain/dashboard:12:';
const before={{filename:'proof.jpg',contentType:'image/jpeg',note:'Before',operationId:'photo-1'}};
const replacement={{filename:'proof.jpg',contentType:'image/jpeg',note:'After',operationId:'photo-2'}};
progress.save(identity, 'Repair complete', [before]);
const first=JSON.parse(values.values().next().value).drafts[identity].operation_id;
progress.save(identity, 'Repair complete', [before]);
const unchanged=JSON.parse(values.values().next().value).drafts[identity].operation_id;
progress.save(identity, 'Repair complete', [replacement]);
const replaced=JSON.parse(values.values().next().value).drafts[identity].operation_id;
replacement.note='Redacted replacement';
progress.save(identity, 'Repair complete', [replacement]);
const recaptioned=JSON.parse(values.values().next().value).drafts[identity].operation_id;
process.stdout.write(JSON.stringify({{first,unchanged,replaced,recaptioned}}));
"""
    output = run_node(script)
    assert output == {
        "first": "progress-1",
        "unchanged": "progress-1",
        "replaced": "progress-2",
        "recaptioned": "progress-3",
    }


def test_photo_cleanup_failure_resumes_cleanup_without_readmitting_delivery():
    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 calls=[]; let cleanupFails=true; let cleanupCalls=0;
const options={{storage,getLogin:()=> 'timmy',makeId:()=> 'stable-progress-op',admit:async message=>{{calls.push(message);return {{background:true}};}}}};
let progress=createProgress(options);
const target={{identity:'pull:stackchain/dashboard:22:',kind:'pull',repository:'stackchain/dashboard',number:22}};
const photo={{filename:'proof.webp',contentType:'image/webp',blob:{{size:9}},operationId:'photo-op'}};
const cleanup=async()=>{{cleanupCalls++;if(cleanupFails)throw new Error('draft cleanup blocked')}};
let error=''; let deliveryAdmitted=false;
try {{await progress.post(target,'',[photo],cleanup);}} catch(caught){{error=caught.message;deliveryAdmitted=caught.deliveryAdmitted===true;}}
progress=createProgress(options);
cleanupFails=false;
const recovered=await progress.post(target,'edited text must not be admitted',[{{...photo,operationId:'replacement'}}],cleanup);
process.stdout.write(JSON.stringify({{error,deliveryAdmitted,calls,cleanupCalls,recovered,after:progress.load(target.identity)}}));
"""
    output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
    assert output["error"] == "Progress update is already queued; photo cleanup is pending."
    assert output["deliveryAdmitted"] is True
    assert len(output["calls"]) == 1
    assert output["cleanupCalls"] == 2
    assert output["recovered"]["alreadyAdmitted"] is True
    assert output["after"] == ""


def test_blocker_retry_moves_on_without_admitting_the_comment_twice():
    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:()=> 'blocker-op-1',
  admit:async message=>{{admissions.push(message);return {{background:true}};}},
}};
let progress=createProgress(options);
const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066}};
const until='2026-08-19T09:00:00.000Z';
const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}};
let firstError=''; let admitted=false;
try {{await progress.postBlocker(target,'Waiting for the API owner',[],null,until,transition);}}
catch(caught){{firstError=caught.message;admitted=caught.deliveryAdmitted===true;}}
progress=createProgress(options);
transitionFails=false;
const recovered=await progress.postBlocker(target,undefined,[],null,until,transition);
process.stdout.write(JSON.stringify({{
  firstError,admitted,admissions,transitions,recovered,after:progress.load(target.identity),stored:[...values.values()],
}}));
"""
    output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
    assert output["firstError"] == "Blocker was posted, but Today could not move on. Retry the planning step."
    assert output["admitted"] is True
    assert len(output["admissions"]) == 1
    assert output["admissions"][0] == {
        "kind": "issue-comment",
        "repository": "stackchain/dashboard",
        "number": 1066,
        "body": "Waiting for the API owner",
        "operationId": "blocker-op-1",
    }
    assert output["transitions"] == [
        ["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
        ["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
    ]
    assert output["recovered"]["alreadyAdmitted"] is True
    assert output["after"] == ""
    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():
    script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values=new Map(); let admitted=0;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const progress=createProgress({{storage,getLogin:()=> 'timmy',admit:async()=>{{admitted++;}}}});
const results=[];
for (const target of [null, {{identity:'review:r:1:',kind:'review',repository:'r',number:1}}, {{identity:'issue:r:0:',kind:'issue',repository:'r',number:0}}]) {{
  try {{ await progress.post(target, 'note'); results.push('accepted'); }} catch (error) {{ results.push(error.message); }}
}}
process.stdout.write(JSON.stringify({{results,admitted,keys:[...values.keys()]}}));
"""
    output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
    assert output["admitted"] == 0
    assert output["keys"] == []
    assert output["results"] == [
        "An active Today issue or pull request is required.",
        "An active Today issue or pull request is required.",
        "An active Today issue or pull request is required.",
    ]


def test_progress_sheet_scopes_voice_to_the_open_item_and_aborts_on_close():
    script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
  constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=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'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const target={{identity:'issue:stackchain/dashboard:1058:',kind:'issue',repository:'stackchain/dashboard',number:1058,label:'#1058',title:'Voice progress'}};
const calls=[];
const voice={{open:async identity=>calls.push(['open',identity]),cancel:()=>calls.push(['cancel'])}};
const activity={{open:async value=>calls.push(['activity-open',value.identity]),close:()=>calls.push(['activity-close'])}};
const view=createView({{
  progress:{{load:()=> 'Saved text',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],voice,activity,
  photos:{{open:async()=>calls.push(['photos'])}},
}});
(async()=>{{
  await elements['[data-mobile-today-update]'].click();
  const opened={{body:elements['#today-progress-body'].value,sheet:elements['#today-progress-sheet'].open,calls:[...calls]}};
  await elements['#cancel-today-progress'].click();
  process.stdout.write(JSON.stringify({{opened,closed:!elements['#today-progress-sheet'].open,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    assert run_node(script) == {
        "opened": {
            "body": "Saved text",
            "sheet": True,
            "calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"], ["activity-open", "issue:stackchain/dashboard:1058:"]],
        },
        "closed": True,
        "calls": [
            ["open", "issue:stackchain/dashboard:1058:"],
            ["photos"],
            ["activity-open", "issue:stackchain/dashboard:1058:"],
            ["cancel"],
            ["activity-close"],
        ],
    }


def test_blocked_hud_opens_a_focused_flow_with_quick_return_choices():
    script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
  constructor() {{
    this.hidden=false;this.value='';this.textContent='';this.open=false;this.focused=false;this.listeners={{}};
    const names=new Set();
    this.classList={{add:name=>names.add(name),remove:name=>names.delete(name),contains:name=>names.has(name)}};
  }}
  addEventListener(name,callback) {{ this.listeners[name]=callback; }}
  click() {{ return this.listeners.click?.(); }}
  showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{ this.focused=true; }}
}}
const selectors=['#today-progress-sheet','.today-progress-panel','#today-progress-title','#today-progress-body',
  '#today-progress-status','[data-mobile-today-update]','[data-mobile-today-blocked]',
  '#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
  '#today-blocker-return-at','#post-today-blocker','[data-today-blocker-preset="later-today"]',
  '[data-today-blocker-preset="tomorrow"]','[data-today-blocker-custom]'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const target={{identity:'issue:stackchain/dashboard:1070:',kind:'issue',repository:'stackchain/dashboard',number:1070,label:'#1070',title:'Focused blocker'}};
const calls=[];
const view=createView({{
  progress:{{load:()=> 'Waiting for access',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
  voice:{{open:async()=>calls.push('voice')}},photos:{{open:async()=>calls.push('photos')}},
  activity:{{open:async()=>calls.push('activity')}},now:()=>new Date('2026-08-18T10:15:00Z'),
}});
(async()=>{{
  const availability={{update:elements['[data-mobile-today-update]'].hidden,blocked:elements['[data-mobile-today-blocked]'].hidden}};
  await elements['[data-mobile-today-blocked]'].click();
  const opened={{
    title:elements['#today-progress-title'].textContent,target:elements['#today-progress-target'].textContent,
    focused:elements['#today-progress-body'].focused,mode:elements['.today-progress-panel'].classList.contains('blocker-mode'),
    calls:[...calls],sheet:elements['#today-progress-sheet'].open,
  }};
  await elements['[data-today-blocker-preset="later-today"]'].click();
  const laterToday=elements['#today-blocker-return-at'].value;
  await elements['[data-today-blocker-preset="tomorrow"]'].click();
  const tomorrow=elements['#today-blocker-return-at'].value;
  elements['#today-blocker-return-at'].focused=false;
  await elements['[data-today-blocker-custom]'].click();
  process.stdout.write(JSON.stringify({{availability,opened,laterToday,tomorrow,customFocused:elements['#today-blocker-return-at'].focused}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    assert run_node(script) == {
        "availability": {"update": False, "blocked": False},
        "opened": {
            "title": "Report blocker",
            "target": "#1070 · Focused blocker",
            "focused": True,
            "mode": True,
            "calls": [],
            "sheet": True,
        },
        "laterToday": "2026-08-18T14:15",
        "tomorrow": "2026-08-19T09:00",
        "customFocused": True,
    }


def test_progress_sheet_dismisses_mentions_when_opened_switched_or_closed():
    script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
  constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=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'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const issue={{identity:'issue:stackchain/dashboard:1064:',kind:'issue',repository:'stackchain/dashboard',number:1064,label:'#1064',title:'Mentions'}};
const pull={{identity:'pull:stackchain/dashboard:77:',kind:'pull',repository:'stackchain/dashboard',number:77,label:'#77',title:'Other'}};
let target=issue; let dismissals=0;
const view=createView({{
  progress:{{load:()=>'',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
  mentions:{{dismiss:()=>dismissals++}},
}});
(async()=>{{
  await elements['[data-mobile-today-update]'].click();
  const afterOpen=dismissals;
  target=pull; view.update();
  const afterSwitch=dismissals;
  await elements['#cancel-today-progress'].click();
  process.stdout.write(JSON.stringify({{afterOpen,afterSwitch,afterClose:dismissals,closed:!elements['#today-progress-sheet'].open}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    assert run_node(script) == {
        "afterOpen": 1,
        "afterSwitch": 2,
        "afterClose": 3,
        "closed": True,
    }


def test_progress_sheet_posts_blocker_then_defers_and_continues_exact_active_item():
    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'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const item={{kind:'issue',repository:'stackchain/dashboard',number:1066,title:'Blocked flow'}};
const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066,label:'#1066',title:'Blocked flow',item}};
const calls=[]; const announcements=[];
const progress={{
  load:()=>'',save:()=>true,
  postBlocker:async(...args)=>{{calls.push(args);return {{background:true}};}},
}};
createView({{
  progress,currentTarget:()=>target,qs:selector=>elements[selector],
  photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},
  moveOn:async(value,until)=>{{calls.push(['move-on',value.identity,until]);return true;}},
  announce:value=>announcements.push(value),
}});
(async()=>{{
  await elements['[data-mobile-today-update]'].click();
  elements['#today-progress-body'].value='Waiting for API access';
  elements['#today-blocker-return-at'].value='2099-08-19T09:00';
  await elements['#post-today-blocker'].click();
  process.stdout.write(JSON.stringify({{
    posted:calls[0].slice(0,3),transitionResult:await calls[0][5](target,calls[0][4]),
    moveCall:calls[1],announcements,closed:!elements['#today-progress-sheet'].open,
  }}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = run_node(script)
    assert output["posted"][0]["identity"] == "issue:stackchain/dashboard:1066:"
    assert output["posted"][1] == "Waiting for API access"
    assert output["posted"][2] == []
    assert output["moveCall"][0:2] == ["move-on", "issue:stackchain/dashboard:1066:"]
    assert output["moveCall"][2].startswith("2099-08-19T09:00")
    assert output["transitionResult"] is True
    assert output["announcements"] == ["Blocker queued, deferred to Later, and Today moved on."]
    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():
    script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[];
const page={{comments:[{{id:2,body:'Latest',user:{{login:'alex'}},created_at:'2026-08-18T01:00:00Z'}}],page:3,older_page:2,total:41}};
const activity=createActivity({{
  fetchJson:async path=>{{calls.push(path);return page;}},
  createPager:({{loadPage}})=>({{reset:value=>value,loadOlder:()=>loadPage(2)}}),
  paint:value=>paints.push(value), setStatus:value=>calls.push('status:'+value),
}});
const target={{identity:'issue:stackchain/dashboard:1060:',kind:'issue',repository:'stackchain/dashboard',number:1060}};
(async()=>{{await activity.open(target);process.stdout.write(JSON.stringify({{calls,paints}}));}})()
  .catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = run_node(script)
    assert output["calls"] == [
        "status:Loading recent activity…",
        "api/v1/repos/stackchain/dashboard/issues/1060/comments?limit=20",
        "status:",
    ]
    assert output["paints"] == [{
        "comments": [{
            "id": 2,
            "body": "Latest",
            "user": {"login": "alex"},
            "created_at": "2026-08-18T01:00:00Z",
        }],
        "page": 3,
        "older_page": 2,
        "total": 41,
    }]


def test_recent_activity_rejects_a_slow_response_after_close():
    script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
let resolve; const paints=[]; const statuses=[];
const activity=createActivity({{
  fetchJson:()=>new Promise(done=>{{resolve=done;}}),
  createPager:()=>({{reset:value=>value}}), paint:value=>paints.push(value), setStatus:value=>statuses.push(value),
}});
const target={{identity:'pull:stackchain/dashboard:9:',kind:'pull',repository:'stackchain/dashboard',number:9}};
(async()=>{{const pending=activity.open(target);activity.close();resolve({{comments:[{{id:1}}]}});const result=await pending;process.stdout.write(JSON.stringify({{result,paints,statuses}}));}})()
  .catch(error=>{{console.error(error);process.exit(1);}});
"""
    assert run_node(script) == {
        "result": False,
        "paints": [],
        "statuses": ["Loading recent activity…", ""],
    }


def test_recent_activity_retry_recovers_the_same_pull_after_failure():
    script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[]; let fail=true;
const activity=createActivity({{
  fetchJson:async path=>{{calls.push(path);if(fail)throw new Error('offline');return {{comments:[],page:1,older_page:null,total:0}};}},
  createPager:()=>({{reset:value=>value}}), paint:value=>paints.push(value),
}});
const target={{identity:'pull:stackchain/dashboard:9:',kind:'pull',repository:'stackchain/dashboard',number:9}};
(async()=>{{const opened=await activity.open(target);fail=false;const retried=await activity.retry();process.stdout.write(JSON.stringify({{opened,retried,calls,paints}}));}})()
  .catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = run_node(script)
    assert output["opened"] is False
    assert output["retried"] is True
    assert output["calls"] == [
        "api/v1/repos/stackchain/dashboard/pulls/9/comments?limit=20",
        "api/v1/repos/stackchain/dashboard/pulls/9/comments?limit=20",
    ]
    assert output["paints"] == [{"comments": [], "page": 1, "older_page": None, "total": 0}]


def test_recent_activity_load_older_paints_the_deduplicated_page():
    script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[];
const activity=createActivity({{
  fetchJson:async path=>{{calls.push(path);return {{comments:[],page:2,older_page:null,total:20}};}},
  createPager:({{loadPage}})=>({{reset:value=>value,loadOlder:async()=>{{await loadPage(2);return {{comments:[{{id:1}}],page:2,older_page:null,total:21}};}}}}),
  paint:value=>paints.push(value),
}});
const target={{identity:'issue:stackchain/dashboard:1060:',kind:'issue',repository:'stackchain/dashboard',number:1060}};
(async()=>{{await activity.open(target);const loaded=await activity.loadOlder();process.stdout.write(JSON.stringify({{loaded,calls,paints}}));}})()
  .catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = run_node(script)
    assert output["loaded"] is True
    assert output["calls"][-1] == "api/v1/repos/stackchain/dashboard/issues/1060/comments?page=2&limit=20"
    assert output["paints"][-1]["comments"] == [{"id": 1}]


def test_recent_activity_mount_renders_owned_actions_on_the_exact_comment_card():
    script = f"""
const {{mountActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const createPager=require({json.dumps(str(ROOT / 'frontend' / 'conversation.js'))});
class Element {{
  constructor(){{this.innerHTML='';this.textContent='';this.hidden=false;this.listeners={{}};this.scrollHeight=0;this.scrollTop=0;}}
  addEventListener(name,callback){{this.listeners[name]=callback;}}
}}
const selectors=['#today-progress-activity-list','#today-progress-activity-count','#load-older-today-progress-activity',
  '#today-progress-activity-status','#retry-today-progress-activity'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const actions={{actionHtml:comment=>comment.author==='timmy'?'<button data-comment-action="edit">Edit</button>':''}};
const activity=mountActivity(selector=>elements[selector],async()=>({{
  comments:[{{id:17,author:'timmy',body:'Needs correction',created_at:'2026-08-18T00:00:00Z'}}],page:1,older_page:null,total:1,
}}),{{getActions:async()=>actions,createPager,escape:value=>String(value),render:value=>String(value)}});
(async()=>{{
  await activity.open({{identity:'issue:stackchain/dashboard:1062:',kind:'issue',repository:'stackchain/dashboard',number:1062}});
  process.stdout.write(JSON.stringify({{html:elements['#today-progress-activity-list'].innerHTML,count:elements['#today-progress-activity-count'].textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = run_node(script)
    assert 'class="today-progress-activity-item issue-comment"' in output["html"]
    assert 'data-comment-id="17"' in output["html"]
    assert 'data-comment-action="edit"' in output["html"]
    assert output["count"] == "1 of 1 messages"


def test_recent_activity_edit_control_preserves_a_failed_correction_for_retry():
    script = f"""
const {{mountActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const createPager=require({json.dumps(str(ROOT / 'frontend' / 'conversation.js'))});
const createActions=require({json.dumps(str(ROOT / 'frontend' / 'comment-actions.js'))});
class Element {{constructor(){{this.innerHTML='';this.textContent='';this.hidden=false;this.listeners={{}};this.scrollHeight=0;this.scrollTop=0;}}addEventListener(n,c){{this.listeners[n]=c;}}}}
const selectors=['#today-progress-activity-list','#today-progress-activity-count','#load-older-today-progress-activity','#today-progress-activity-status','#retry-today-progress-activity'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
let state={{comments:[{{id:17,author:'timmy',body:'Wrong'}}],page:1,older_page:null,total:1}};
let attempted='';
const actions=createActions({{
  getLogin:()=> 'timmy',
  fetchJson:async(_path,options)=>{{attempted=JSON.parse(options.body).body;throw new Error('network unavailable');}},
}});
const activity=mountActivity(selector=>elements[selector],async()=>state,{{getActions:async()=>actions,createPager,escape:value=>String(value),render:value=>String(value),isOffline:()=>false}});
const textarea={{value:'Corrected transcript',focuses:0,focus(){{this.focuses++;}}}};
const save={{disabled:false,listeners:{{}},addEventListener(n,c){{this.listeners[n]=c;}}}};
const cancel={{listeners:{{}},addEventListener(n,c){{this.listeners[n]=c;}}}};
const card={{dataset:{{commentId:'17'}},innerHTML:'',querySelector:selector=>({{'.comment-edit-textarea':textarea,'[data-comment-edit-save]':save,'[data-comment-edit-cancel]':cancel}})[selector]}};
const button={{dataset:{{commentAction:'edit'}},closest:selector=>selector==='[data-comment-action]'?button:card}};
(async()=>{{
  await activity.open({{identity:'issue:stackchain/dashboard:1062:',kind:'issue',repository:'stackchain/dashboard',number:1062}});
  await elements['#today-progress-activity-list'].listeners.click({{target:button}});
  await save.listeners.click({{currentTarget:save}});
  process.stdout.write(JSON.stringify({{attempted,value:textarea.value,focuses:textarea.focuses,disabled:save.disabled,status:elements['#today-progress-activity-status'].textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = run_node(script)
    assert output == {
        "attempted": "Corrected transcript",
        "value": "Corrected transcript",
        "focuses": 2,
        "disabled": False,
        "status": "network unavailable",
    }


def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
    html = (ROOT / "frontend" / "index.html").read_text()
    css = (ROOT / "frontend" / "dashboard.css").read_text()
    dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
    bundle = (ROOT / "src" / "frontend_bundle.py").read_text()

    assert 'data-mobile-today-update' in html
    assert 'id="today-progress-sheet"' in html
    assert 'aria-labelledby="today-progress-title"' in html
    assert 'id="today-progress-body"' in html
    assert 'id="today-progress-activity"' in html
    assert 'id="today-progress-activity-list"' in html
    assert 'id="today-progress-activity-status"' in html
    assert 'id="retry-today-progress-activity"' in html
    assert 'id="load-older-today-progress-activity"' in html
    for control in (
        "voice-today-progress",
        "start-voice-today-progress",
        "stop-voice-today-progress",
        "voice-today-progress-review",
        "voice-today-progress-transcript",
        "append-voice-today-progress",
        "replace-with-voice-today-progress",
        "discard-voice-today-progress",
        "voice-today-progress-status",
    ):
        assert f'id="{control}"' in html
    assert 'for="voice-today-progress-transcript"' in html
    assert 'data-draft-label="progress update"' in html
    assert 'id="take-today-progress-photo"' in html
    assert 'id="today-progress-attachment"' in html
    assert 'id="today-progress-attachment-preview"' in html
    assert 'maxlength="2000"' in html
    assert 'id="save-today-progress"' in html
    assert 'id="post-today-progress"' in html
    assert 'id="today-blocker-return-at"' in html
    assert 'id="post-today-blocker"' in html
    assert 'Post blocker &amp; move on' in html
    assert '<script src="static/today-progress.js"></script>' in html
    assert '"static/today-progress.js"' in bundle
    assert "workSession.target('continue')" in dashboard
    assert "authoredOutbox.enqueueDurably" in dashboard
    assert "createTodayProgressPhotos" in dashboard
    assert "scope:'today-progress'" in TODAY_PROGRESS.read_text()
    assert "lanes:{ today:" in TODAY_PROGRESS.read_text()
    assert "photos:todayProgressPhotos" in dashboard
    assert "['today-progress', '#today-progress-body']" in dashboard
    assert "voice:todayProgressVoice" in dashboard
    assert "mountTodayProgressActivity" in dashboard
    assert "activity:mountTodayProgressActivity" in dashboard
    assert "moveOn:(target,until) => detailDefer.deferUntil(target.item,until)" in dashboard
    assert "fetchReviewJson,actionHydrator" in dashboard
    assert ".today-progress-activity-list" in css
    assert ".today-progress-activity-actions button" in css
    assert "min-height:44px" in css
    assert "const controller = issueAttachment.mount" in TODAY_PROGRESS.read_text()
    assert "const checkpointAttachments = await photos?.serialize?.() || [];" in TODAY_PROGRESS.read_text()
    assert "progress.save(openedTarget.identity, body.value, checkpointAttachments)" in TODAY_PROGRESS.read_text()
    assert "error.deliveryAdmitted" in TODAY_PROGRESS.read_text()
    assert ".today-progress-panel" in css
    assert "env(safe-area-inset-bottom)" in css
    assert ".today-progress-actions button" in css and "min-height:44px" in css
    assert ".today-progress-panel .voice-conversation-controls button" in css
