Files
atomic-design-poc/libs/shared/src/application/pending-saves.ts
T
ehoandClaude Sonnet 5 e7156c5132 feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:01:57 +02:00

81 lines
3.4 KiB
TypeScript

import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';
/**
* A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in
* this app have different lifetimes — root singleton stores (`BriefStore`,
* `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child
* organisms — so both register here instead of the guard/unload handler needing to know
* which page or store owns the pending write.
*/
export interface PendingSave {
/** True while a debounced edit hasn't been written to the backend yet. */
hasPendingSave(): boolean;
/** Flush that pending write now and await it. No-op when nothing is pending. */
flushPending(): Promise<void>;
}
/** Registry of every active autosave owner. The `CanDeactivate` guard and the
`beforeunload` handler flush through this — one seam, both callers. */
@Injectable({ providedIn: 'root' })
export class PendingSaves {
private readonly owners = new Set<PendingSave>();
/** Register an owner; returns an unregister function. */
register(owner: PendingSave): () => void {
this.owners.add(owner);
return () => this.owners.delete(owner);
}
hasPending(): boolean {
return [...this.owners].some((o) => o.hasPendingSave());
}
/** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected
flush is swallowed (a failed autosave surfaces its own error state; navigation must
not be blocked by it). */
async flushAll(): Promise<void> {
await Promise.allSettled(
[...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),
);
}
}
/** Register the current injection context's owner for the life of its `DestroyRef`.
Call from a constructor or field initializer (root store, or `createDraftSync`). */
export function registerPendingSave(owner: PendingSave): void {
const unregister = inject(PendingSaves).register(owner);
inject(DestroyRef).onDestroy(unregister);
}
/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,
then allow navigation. Awaitable, so the write lands before the page tears down (which
would otherwise drop a sub-debounce edit). We never block leaving — the flush is a
guarantee of effort, not a gate. */
export const flushPendingGuard: CanDeactivateFn<unknown> = () => {
const pending = inject(PendingSaves);
return pending.hasPending() ? pending.flushAll().then(() => true) : true;
};
/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.
ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an
async flush can't be guaranteed to finish as the page tears down — we fire it best-effort
AND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce
land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever
needs to be guaranteed. */
export function provideUnloadFlush() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
const pending = inject(PendingSaves);
window.addEventListener('beforeunload', (e) => {
if (!pending.hasPending()) return;
void pending.flushAll();
e.preventDefault();
e.returnValue = '';
});
},
};
}