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>
81 lines
3.4 KiB
TypeScript
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 = '';
|
|
});
|
|
},
|
|
};
|
|
}
|