T4.3 - S2: autosave, an unsaved-work guard that means it, and draft recovery

The work package form is ~4,700px tall and had no autosave and no unsaved-work
guard. The only beforeunload listener in the app was analytics dwell tracking, so
a mis-click, a closed tab or a crash lost everything typed since the last Save.

html/wp-autosave.js separates three things this app was conflating:

  THE DRAFT   what you have typed. Saved locally, continuously, by this file.
  THE RECORD  what you explicitly Saved, which goes to the project.
  THE OUTBOX  project-data.js, which gets the RECORD to the server reliably.

This module owns the first only and never writes to the server. A draft is
"unfinished work this browser is holding for you"; pushing unfinished work into a
shared project is a different feature with different consequences.

The guard fires only when the form differs from what was loaded. "Do not fire the
guard when nothing has changed" is in the task because a dialog that appears on
every exit gets clicked through within a day, and is then worse than no dialog.

WIRED: the creator's package form and the SOP wizard's state. Both autosave on a
1200ms debounce, on section/step change, and on visibilitychange - the last being
what makes recovery survive a killed tab, since a crash never fires beforeunload.
The wizard's guard is ADDED alongside trackStepDwell, not in place of it; both
fire and the analytics one does not preventDefault.

THREE BUGS FOUND WHILE BUILDING THIS, all by the probe rather than by reading:

  - Dirtiness cannot be "does the form match savedPackages". Those records come
    back from the server through serverToPkg() in a LEANER shape - 264 characters
    against the form's 1,820 - so a freshly loaded, untouched form differed from
    its own record and every single exit would have prompted. Dirtiness is now
    measured against a baseline snapshot taken when the form is populated.
  - currentView is 'Work Package Form', not 'Form'. My first guard compared
    against 'Form' and therefore returned false always: autosave was wired,
    registered, and quietly dead. T4.2 had also introduced currentView='Form' in
    its popstate handler; that is fixed here too, since it would have broken this
    and anything else keyed off the view.
  - settled() has to cancel the pending debounce. A save follows typing, so there
    is nearly always a write already scheduled; without cancelling it the write
    lands a second later and resurrects the draft that was just settled - and the
    next load offers to recover work that is already saved.

VERIFICATION. tests/autosave_check.py, 23 checks, all passing:

  - typing autosaves unprompted; the draft holds what was typed; it is scoped to
    project AND package; and it does NOT appear in the outbox
  - an untouched form is not dirty and arms no guard; a typed-in one does
  - the draft survives a killed tab and is OFFERED back rather than applied
    silently, saying plainly that nothing reached the project, via role=status
  - restoring puts the work back in the form
  - an explicit save settles the draft, and the probe asserts the save actually
    landed first - otherwise the rest of that section proves nothing
  - a simulated QuotaExceededError is reported as 'failed' with its reason, not
    swallowed; a silent autosave failure is a safety net that is not there
  - trackStepDwell still records an event

Two notes for later waves. The fixture's SOP defines no WP types, so
savePackage() legitimately refuses until the probe supplies one - worth knowing
before someone reads that as a bug. And native dialogs hung the headless browser
twice more in this task; with 79 of them in the app, any restore or save path
that reaches one will hang a test rather than fail visibly. S6/S7 in wave 9.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, url_state 23/23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 21:56:40 -05:00
parent b54034db04
commit ab3c9e52d1
6 changed files with 556 additions and 4 deletions

View File

@@ -1158,6 +1158,10 @@ function savePackage(view){
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
wpMarkFormClean();
// The record now holds this work, so the draft is no longer protecting anything.
// Left in place it would make the next load offer to "recover" work already saved.
if(typeof WPAutosave!=='undefined') WPAutosave.settled(wpDraftId());
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server
document.getElementById('loadingOverlay').classList.add('active');
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
@@ -1261,6 +1265,8 @@ function printPackage(){
// ── VIEWS ────────────────────────────────────────────────────────────────────
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
// Populating the form establishes the clean state it is later compared against.
function wpFormPopulated(){ setTimeout(wpMarkFormClean, 0); }
function showForm(){
hideDashboard();
// This clears every card's inline display, which also clears the "hidden" set by
@@ -1706,7 +1712,7 @@ function loadPackageIntoForm(p){
pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows();
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
pkgHolds=(p.holds||[]).map(h=>({...h}));
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm();
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
}
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
tmp.forEach(s=>{ const c=pkgConstraints.find(x=>x.name===s.name); if(c){ c.status=s.status; c.comment=s.comment; }});
@@ -1769,7 +1775,7 @@ function newPackage(){
pkgHolds=[]; pkgOverrides={};
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft';
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); renderWpNav(); track('new_package');
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
}
function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); }
function exportPackages(){
@@ -2129,6 +2135,96 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
onStatusChange(v);
}));
// ── AUTOSAVE / UNSAVED-WORK GUARD / RECOVERY (S2) ────────────────────────────
// A draft is per project AND per package, so two projects cannot overwrite each
// other's recovery and a new package does not inherit the last one's draft.
function wpDraftId(){ return 'wp-form::' + (activeProjectId || 'none') + '::' + (editingId || 'new'); }
// Volatile fields move on their own (timestamps, the id assigned at collect time)
// and would make an untouched form look edited, which is exactly the false-positive
// that makes an unsaved-work dialog worthless.
function wpFormFingerprint(pkg){
if(!pkg) return '';
const copy = Object.assign({}, pkg);
['id','updatedAt','createdAt','issuedAt'].forEach(k=>delete copy[k]);
try { return JSON.stringify(copy); } catch(e){ return ''; }
}
// The baseline is taken when the form is populated and again when it is saved, so
// "dirty" means "changed since then". Comparing the form against savedPackages
// instead does not work: those records come back from the server through
// serverToPkg() in a leaner shape, so a freshly loaded, untouched form differed
// from its own record and every exit would have prompted - which is precisely the
// dialog-that-gets-clicked-through the task forbids.
let _wpFormBaseline = null;
function wpMarkFormClean(){
try { _wpFormBaseline = wpFormFingerprint(collectPackage()); } catch(e){ _wpFormBaseline = null; }
}
function wpFormIsDirty(){
// Only the form can be dirty. On the dashboard or the printed view nothing is
// being edited, so there is nothing to warn about.
if(currentView !== 'Work Package Form') return false;
if(_wpFormBaseline === null) return false;
const el = document.getElementById('wp_subject');
if(!el) return false;
let live;
try { live = collectPackage(); } catch(e){ return false; }
return wpFormFingerprint(live) !== _wpFormBaseline;
}
function initAutosave(){
if(typeof WPAutosave === 'undefined') return;
WPAutosave.register({
id: 'wp-form',
scope: document,
draftId: wpDraftId,
collect: collectPackage,
isDirty: wpFormIsDirty,
});
// Section changes are a "you have visibly moved on" moment, so flush rather than
// wait out the debounce.
document.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click', ()=>WPAutosave.flush('section')));
offerDraftRecovery();
}
// Recovery. Offered only when the draft is genuinely ahead of the record - being
// asked to recover work that is already saved is its own kind of alarming.
function offerDraftRecovery(){
if(typeof WPAutosave === 'undefined') return;
const id = wpDraftId();
const d = WPAutosave.peek(id);
if(!d || !d.data) return;
const saved = editingId ? savedPackages.find(p=>p.id===editingId) : null;
if(saved && wpFormFingerprint(d.data) === wpFormFingerprint(saved)){ WPAutosave.discard(id); return; }
if(!d.data.subject && !d.data.number && !d.data.type){ WPAutosave.discard(id); return; }
showDraftRecoveryBar(id, d);
}
function showDraftRecoveryBar(id, d){
const host = document.querySelector('.main');
if(!host) return;
const old = document.getElementById('draft-recovery'); if(old) old.remove();
const when = (function(){ try { return new Date(d.at).toLocaleString(); } catch(e){ return d.at; } })();
const bar = document.createElement('div');
bar.id = 'draft-recovery';
bar.className = 'dash-panel';
bar.setAttribute('role','status');
bar.innerHTML = `<div class="dash-panel-title">Unsaved work from ${esc(when)}</div>
<div class="field-hint">This browser was holding changes to <strong>${esc(d.data.number || d.data.subject || 'a work package')}</strong> that were never saved. Nothing has been sent to the project.</div>
<div class="material-actions">
<button class="btn btn-primary" id="draft-restore">Restore them</button>
<button class="btn btn-ghost" id="draft-discard">Discard</button>
</div>`;
host.insertBefore(bar, host.firstChild);
document.getElementById('draft-restore').onclick = ()=>{
loadPackageIntoForm(Object.assign({}, d.data, {id: (editingId || d.data.id)}));
bar.remove(); toast('Unsaved work restored — Save draft to keep it.');
};
document.getElementById('draft-discard').onclick = ()=>{ WPAutosave.discard(id); bar.remove(); };
}
// ── BOOT ─────────────────────────────────────────────────────────────────────
// Resolve the active project BEFORE loading the store so namespaced keys resolve.
(function seedProject(){
@@ -2236,14 +2332,16 @@ function bootData(){
const want = state.wp || '';
if(want){
const ix = savedPackages.findIndex(x => x.id === want);
if(ix >= 0){ hideDashboard(); currentView='Form'; editingId=savedPackages[ix].id;
if(ix >= 0){ hideDashboard(); editingId=savedPackages[ix].id;
loadPackageIntoForm(savedPackages[ix]); renderWpNav(); }
} else if(currentView === 'Dashboard'){
hideDashboard(); setFormChrome(true); currentView='Form';
hideDashboard(); setFormChrome(true); currentView='Work Package Form';
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='');
}
});
}
initAutosave();
wpMarkFormClean();
track('app_open');
// The embedding shell needs to know when the packages are actually in hand: the
// frame's `load` event fires long before pullProject() resolves, so anything that