The app already showed "✓ All changes saved". That badge belongs to the OUTBOX -
it reports whether saved records have reached the project - and it went green
when the queue emptied, whether or not anything in the form had been saved at
all. So the promise B5 says the app does not keep was being made by a component
that could not know whether it was true.
Two indicators now, each speaking for one thing:
DRAFT .wp-draft-status, mounted in the creator's sticky save bar and the
wizard's step navigation. Driven by WPAutosave's status: "No unsaved
changes" / "Unsaved changes" / "Saving draft…" / "Draft saved at HH:MM"
/ "Draft not saved on this device — <reason>" with a Retry.
OUTBOX the existing badge, reworded so every state names the project:
"Sending N changes to the project…", "Everything sent to the project",
"N changes not yet sent to the project — retrying", "rejected by the
project".
"No unsaved changes" rather than "Saved" for an untouched form: those are
different statements and only the first is true before anything is typed. The
component was getting that wrong in the same way the outbox badge was.
Announced per S10 (T4.5's pattern, arriving one task early because this indicator
needs it to exist): role="status" while things are going well, swapping to
role="alert" on failure. A failed autosave means the safety net is not there, and
waiting for a pause in the screen reader's queue to mention that is too late.
The retry button is only rendered in the failed state - a retry offered when
nothing has failed is a button that does nothing.
Styles live in theme-light.css because both form pages mount the same component,
and a second copy in a page sheet is what wave 3 spent itself removing.
VERIFICATION. tests/autosave_check.py grew to 34 checks, all passing. The B5 ones:
- the indicator reports "No unsaved changes" untouched, then a real save with a
timestamp, and is visually distinct in each state
- a simulated storage failure is visually distinct, names the reason, offers a
retry, and switches to role=alert
- the sync badge no longer RENDERS "All changes saved", and every state it does
render names the project
That last check is deliberately scoped to what the badge renders rather than to
the file text: the old phrase still appears in the comment explaining why it was
changed, and asserting on that would be asserting that the reason cannot be
written down.
Note for wave 9: the outbox badge is styled with inline hexes, including #8a6d00
- the ninth amber from BL-009, independently confirming that entry. It is
BL-005's territory, not this task's.
browser_check 71/71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
231 lines
9.4 KiB
JavaScript
231 lines
9.4 KiB
JavaScript
/* 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'); },
|
|
|
|
/* A persistent draft-state indicator — B5 / T4.4.
|
|
|
|
The suite already showed "✓ All changes saved", but that badge belonged to
|
|
the OUTBOX: it reported whether saved records had reached the project, and
|
|
went green when the queue emptied whether or not anything in the form had
|
|
been saved at all. This indicator speaks only for the draft, and the outbox
|
|
badge's wording now names the project explicitly, so neither can be read as
|
|
the other.
|
|
|
|
role="status" so the state is announced politely (S10 / T4.5); a failure
|
|
swaps in role="alert" so it interrupts, because a failed autosave means the
|
|
safety net is not there and waiting for a pause to say so is too late. */
|
|
mountIndicator: function (host, opts) {
|
|
if (!host) return function () {};
|
|
opts = opts || {};
|
|
var el = document.createElement('div');
|
|
el.className = 'wp-draft-status';
|
|
el.id = opts.id || 'wp-draft-status';
|
|
host.appendChild(el);
|
|
|
|
function fmt(ts) {
|
|
try { return new Date(ts).toLocaleTimeString(); } catch (e) { return ''; }
|
|
}
|
|
|
|
var un = window.WPAutosave.onStatus(function (st, dirty) {
|
|
var role = 'status', cls = '', text = '';
|
|
if (st.state === 'failed') {
|
|
role = 'alert';
|
|
cls = 'is-failed';
|
|
text = '⚠ Draft not saved on this device — ' + (st.error || 'storage unavailable');
|
|
} else if (st.state === 'saving') {
|
|
cls = 'is-saving';
|
|
text = '↻ Saving draft…';
|
|
} else if (st.state === 'saved') {
|
|
cls = 'is-saved';
|
|
text = '✓ Draft saved at ' + fmt(st.at);
|
|
} else {
|
|
cls = 'is-idle';
|
|
// "No unsaved changes" is a different statement from "saved", and it is
|
|
// the true one when nothing has been typed.
|
|
text = dirty ? 'Unsaved changes' : 'No unsaved changes';
|
|
}
|
|
el.className = 'wp-draft-status ' + cls;
|
|
el.setAttribute('role', role);
|
|
el.textContent = text;
|
|
// Retry is only offered where it can do something.
|
|
if (st.state === 'failed') {
|
|
var b = document.createElement('button');
|
|
b.type = 'button';
|
|
b.className = 'wp-draft-retry';
|
|
b.textContent = 'Retry';
|
|
b.onclick = function () { window.WPAutosave.flush('retry'); };
|
|
el.appendChild(b);
|
|
}
|
|
});
|
|
return function () { un(); if (el.parentNode) el.parentNode.removeChild(el); };
|
|
},
|
|
|
|
_key: draftKey,
|
|
};
|
|
})(window, document);
|