feat(fp): flush pending autosave before navigation/unload

Close the last-mile autosave gap: a debounced edit made in the final <600ms
before leaving a page was lost — the wizard draft-sync timer is cleared on
destroy without flushing, and root stores keep an armed timer the teardown
ignores.

New `shared/application/pending-saves.ts`: a root `PendingSaves` registry every
autosave owner joins (BriefStore, OrgTemplateStore, each createDraftSync). Two
seams flush through it — `flushPendingGuard` (CanDeactivate, on the five
autosave routes) awaits the pending write before an in-app route change; a
`beforeunload` handler (provideUnloadFlush) fires it best-effort and raises the
browser's native unsaved-changes prompt. ponytail: the HTTP seam is Angular
HttpClient (no keepalive/sendBeacon), so a hard-close flush can't be guaranteed
— hence the prompt; upgrade path noted in a comment. Each owner now nulls its
timer handle on fire so `hasPendingSave()` is accurate, and exposes
`flushPending()`.

Verified live against the running stack: navigating away 91ms after a keystroke
(well inside the debounce) fires one PUT /brief before the route changes; a
dirty reload raises the prompt, a clean reload does not. FE lint / check:tokens
/ 299 tests (+11) / build / build-storybook green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-21 16:29:09 +02:00
co-authored by Claude Opus 4.8
parent e5edae4970
commit 645fad088e
10 changed files with 841 additions and 189 deletions
@@ -17,6 +17,7 @@ import {
reduce,
} from '@brief/domain/org-template.machine';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
@@ -34,7 +35,7 @@ const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesj
* (in the reducer) and triggers a save (here). Mirrors `BriefStore`.
*/
@Injectable({ providedIn: 'root' })
export class OrgTemplateStore {
export class OrgTemplateStore implements PendingSave {
private adapter = inject(OrgTemplateAdapter);
private uploadAdapter = inject(UploadAdapter);
private shell = inject(UploadShellService);
@@ -108,6 +109,8 @@ export class OrgTemplateStore {
if (status === 'resolved' || status === 'local')
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
});
// Flush a pending debounced edit before navigation/unload (see pending-saves.ts).
registerPendingSave(this);
}
async load() {
@@ -130,6 +133,7 @@ export class OrgTemplateStore {
this.selectedSubOrgId.set(subOrgId);
this.saveState.set({ tag: 'Idle' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.store.dispatch({ tag: 'Loading' });
const r = await this.adapter.load(subOrgId);
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
@@ -147,7 +151,21 @@ export class OrgTemplateStore {
if (this.loaded() === null) return;
clearTimeout(this.saveTimer);
// ponytail: 600ms debounce, same as BriefStore; the server is the store of record.
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
this.saveTimer = setTimeout(() => {
this.saveTimer = undefined;
void this.flushSave();
}, 600);
}
/** True while a debounced edit hasn't been written yet (PendingSave). */
hasPendingSave = () => this.saveTimer !== undefined;
/** Flush a pending debounced save now and await it; no-op when nothing is pending. */
async flushPending() {
if (this.saveTimer === undefined) return;
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
await this.flushSave();
}
private async flushSave() {
const s = this.loaded();
@@ -178,6 +196,7 @@ export class OrgTemplateStore {
this.pendingPublish.set(false);
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
await this.flushSave(); // publish the saved draft — flush any pending edit first
const r = await this.adapter.publish(s.subOrgId);
if (!r.ok) {
@@ -193,6 +212,7 @@ export class OrgTemplateStore {
if (!s) return;
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
const r = await this.adapter.rollback(s.subOrgId, version);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
@@ -207,6 +227,7 @@ export class OrgTemplateStore {
if (!s) return;
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
await this.flushSave(); // the proefbrief renders the server's draft
const r = await this.adapter.proefbrief(s.subOrgId);
if (!r.ok) {