diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index d0111be..daa4d6d 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -333,8 +333,39 @@ window.addEventListener('DOMContentLoaded',()=>{ } }); }); +// Analytics dwell. S2 adds a SECOND beforeunload listener in wp-autosave.js for the +// unsaved-work guard; both fire, and this one does not preventDefault, so the two do +// not interact. The task says to add the guard "alongside the analytics listener +// rather than replacing it" - this is the listener it means. window.addEventListener('beforeunload', trackStepDwell); +// ── AUTOSAVE (S2) ──────────────────────────────────────────────────────────── +// The wizard already wrote its state to localStorage on save; what it lacked was +// writing it WITHOUT being asked, and telling anyone when that failed. +function sopDraftId(){ + const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || 'none'; + return 'sop-wizard::' + pid; +} +function sopIsDirty(){ + if(currentTool !== 'sop') return false; + try { + collectStepData(); + return JSON.stringify(state) !== _sopSavedFingerprint; + } catch(e){ return false; } +} +let _sopSavedFingerprint = ''; +function sopMarkSaved(){ try { _sopSavedFingerprint = JSON.stringify(state); } catch(e){} } + +document.addEventListener('DOMContentLoaded', function(){ + if(typeof WPAutosave === 'undefined') return; + sopMarkSaved(); + WPAutosave.register({ + id: 'sop-wizard', scope: document, draftId: sopDraftId, + collect: function(){ collectStepData(); return state; }, + isDirty: sopIsDirty, + }); +}); + function initializeWPTypes(){ state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,notes:'',approval:''})); renderWPTypes(); @@ -1173,6 +1204,7 @@ function goToStep(n, opts){ // link to step 3 opened a modal dialog before the page had finished booting. if(!fromUrl && !validateStep(currentStep)) return; currentStep = n; + if(typeof WPAutosave !== 'undefined') WPAutosave.flush('step'); // S3: the step you are on survives a refresh and a shared link. if(typeof WPUrl !== 'undefined' && !fromUrl) WPUrl.push({ step: n > 1 ? n : '' }); updateStepUI(); @@ -1395,6 +1427,7 @@ function completeSOP(){ try { localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop)); localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state)); + if(typeof WPAutosave !== 'undefined'){ sopMarkSaved(); WPAutosave.settled(sopDraftId()); } localStorage.setItem(SK('wp_suite_sop_complete'), '1'); } catch(e){} diff --git a/html/work-package-suite.html b/html/work-package-suite.html index 12dffd5..8e1201f 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -11,6 +11,8 @@ + + diff --git a/html/wp-autosave.js b/html/wp-autosave.js new file mode 100644 index 0000000..c03d4ef --- /dev/null +++ b/html/wp-autosave.js @@ -0,0 +1,172 @@ +/* Autosave, unsaved-work guard and draft recovery — S2 / T4.3. + --------------------------------------------------------------------------- + The work package form is roughly 4,700px tall and there was no autosave and no + unsaved-work guard on it. The only beforeunload listener in the app was + analytics dwell tracking (work-package-suite-app.js), so a mis-click, a closed + tab or a crash lost everything typed since the last explicit Save. + + Three separate things, often confused: + + THE DRAFT what you have typed, saved here, locally, continuously. + THE RECORD what you have explicitly Saved, which goes to the server. + THE OUTBOX project-data.js, which gets the RECORD to the server reliably. + + This file owns the first only. It never writes to the server: a draft is + "unfinished work this browser is holding for you", and pushing unfinished work + to a shared project is a different feature with different consequences. + + The guard fires only when the form actually differs from the record. A dialog + that appears on every exit gets clicked through within a day and is worse than + no dialog, which is why `Do not: fire the guard when nothing has changed` is + part of the task rather than a nicety. +*/ +(function (window, document) { + 'use strict'; + + var DRAFT_PREFIX = 'wp_draft::'; + var DEBOUNCE_MS = 1200; + + var reg = null; // the single registered surface for this page + var timer = null; + var status = { state: 'idle', at: null, error: null }; + var statusSubs = []; + var guardInstalled = false; + + function now() { return new Date().toISOString(); } + + function emit() { + statusSubs.forEach(function (fn) { + try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {} + }); + } + + function setStatus(state, extra) { + status = Object.assign({ state: state, at: status.at, error: null }, extra || {}); + emit(); + } + + function draftKey(id) { return DRAFT_PREFIX + id; } + + function readDraft(id) { + try { return JSON.parse(window.localStorage.getItem(draftKey(id)) || 'null'); } + catch (e) { return null; } + } + + function writeDraft(id, payload) { + // A failed write is the one case that MUST be surfaced rather than swallowed: + // it means the safety net is not there, and the user is the only one who can + // act on that (close a tab, free some quota, save explicitly now). + window.localStorage.setItem(draftKey(id), JSON.stringify(payload)); + } + + function clearDraft(id) { + try { window.localStorage.removeItem(draftKey(id)); } catch (e) {} + } + + function save(reason) { + if (!reg) return false; + if (!reg.isDirty()) { setStatus('idle'); return false; } + setStatus('saving'); + try { + writeDraft(reg.draftId(), { + v: 1, at: now(), reason: reason || 'debounce', + entity: reg.id, data: reg.collect(), + }); + setStatus('saved', { at: now() }); + return true; + } catch (e) { + // QuotaExceededError, private-mode storage, a locked profile. + setStatus('failed', { error: (e && e.message) || String(e) }); + return false; + } + } + + function schedule(reason) { + if (!reg) return; + clearTimeout(timer); + timer = setTimeout(function () { save(reason || 'debounce'); }, DEBOUNCE_MS); + } + + function installGuard() { + if (guardInstalled) return; + guardInstalled = true; + // ADDED alongside the analytics dwell listener, never replacing it. Both fire; + // beforeunload supports multiple listeners and the analytics one does not + // preventDefault, so the two do not interact. + window.addEventListener('beforeunload', function (e) { + if (!reg || !reg.isDirty()) return undefined; // nothing unsaved: stay silent + save('unload'); // one last draft write + e.preventDefault(); + e.returnValue = ''; // required by Chrome + return ''; + }); + // A crash or a killed tab never fires beforeunload. `visibilitychange` to + // hidden does, and it is the last reliable moment to write the draft - which + // is what makes the recovery survive "kill the tab and reopen". + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') save('hidden'); + }); + } + + window.WPAutosave = { + /* Register the page's editable surface. + + id stable name for the surface, e.g. 'wp-form' + scope element to watch for input/change (defaults to document) + draftId () => storage id, usually project + entity so two projects do + not share one draft + collect () => a JSON-serialisable snapshot of the form + isDirty () => does the form differ from the last explicitly saved record + restore (data) => put a recovered snapshot back into the form + */ + register: function (opts) { + reg = { + id: opts.id, + scope: opts.scope || document, + draftId: opts.draftId || function () { return opts.id; }, + collect: opts.collect, + isDirty: opts.isDirty, + restore: opts.restore, + }; + reg.scope.addEventListener('input', function () { schedule('input'); }); + reg.scope.addEventListener('change', function () { schedule('change'); }); + installGuard(); + return window.WPAutosave; + }, + + // Autosave now rather than on the debounce - for a step or section change, + // where the user has visibly moved on and expects the previous part kept. + flush: function (reason) { clearTimeout(timer); return save(reason || 'flush'); }, + + isDirty: function () { return !!(reg && reg.isDirty()); }, + status: function () { return Object.assign({}, status); }, + onStatus: function (fn) { + statusSubs.push(fn); + try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {} + return function () { + var i = statusSubs.indexOf(fn); + if (i >= 0) statusSubs.splice(i, 1); + }; + }, + + /* Recovery. Returns the stored draft for an id, or null. The caller decides + whether to offer it - only it knows whether the draft is actually newer + than the record, and offering to restore work that is already saved is its + own kind of alarming. */ + peek: function (id) { return readDraft(id); }, + discard: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); }, + + /* Called after an explicit Save succeeded: the record now holds this work, so + the draft is no longer protecting anything and keeping it would make the + next load offer to "recover" work that is already saved. + + clearTimeout FIRST. A save typically follows typing, so there is usually a + debounced write already scheduled; without cancelling it, that write lands + a second after the draft was cleared and resurrects it — and the next load + offers to recover work that is already saved, which is the exact thing this + method exists to prevent. */ + settled: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); }, + + _key: draftKey, + }; +})(window, document); diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index a2511ad..bcda1cf 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -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 = `