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
+25 -2
View File
@@ -17,6 +17,7 @@ import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { uploadContentUrl } from '@shared/upload/upload.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
instead of a busy boolean + a nullable error sitting side by side. */
@@ -38,7 +39,7 @@ type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
*/
@Injectable({ providedIn: 'root' })
export class BriefStore {
export class BriefStore implements PendingSave {
private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter);
private revealAdapter = inject(RevealBigNummerAdapter);
@@ -188,12 +189,32 @@ export class BriefStore {
this.future.set([]);
}
constructor() {
// Register so the CanDeactivate guard / beforeunload handler can flush a pending
// debounced edit before navigation or unload (see pending-saves.ts).
registerPendingSave(this);
}
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (!this.canEdit()) return;
clearTimeout(this.saveTimer);
// ponytail: 600ms debounce like the wizard draft-sync; 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 b = this.brief();
@@ -217,6 +238,7 @@ export class BriefStore {
async resetDemo() {
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
const r = await this.adapter.reset();
this.saveState.set({ tag: 'Idle' });
if (r.ok) {
@@ -268,6 +290,7 @@ export class BriefStore {
private async transition(action: () => Promise<Result<string, BriefView>>) {
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
await this.flushSave();
const r = await action();
if (!r.ok) {