refactor(fp): WP-31 — shared store helpers (dedupe brief/org-template/stamdata)

Audit "apply high-value": extract four shared helpers into shared/application/ and
rewire the editor stores (behaviour unchanged, existing specs are the gate):
- action-state.ts: ActionState/SaveState (were duplicated in both brief stores).
- history.ts: createHistory<T> (extracted from BriefStore's WP-27 undo/redo; WP-32 reuses).
- debounced-save.ts: createDebouncedSave (the 600ms timer/PendingSave dance, was 2×+).
- machine-remote-data.ts: machineRemoteData (the loading/failed/loaded→RemoteData switch, 3×).
Each helper has a co-located spec. Deferred DDD findings (contracts/ inconsistency, a
parse* traverse combinator, the 6× Seed boilerplate) are reported in the WP file, not built.

npm run ci green; 323 tests (+13 helper specs); brief/org-template/stamdata specs unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-22 15:40:02 +02:00
co-authored by Claude Opus 4.8
parent 13b3e5e663
commit ac3e9a9399
11 changed files with 380 additions and 138 deletions
+37 -80
View File
@@ -1,7 +1,10 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import {
Brief,
CaseContext,
@@ -19,17 +22,6 @@ import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.a
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. */
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
/** Debounced-autosave indicator, shown in a small status line near the toolbar —
a separate concern from ActionState (a stale autosave error doesn't block
submit/approve/reject), but tag-aligned with it for one consistent idiom. */
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
/**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
* read-model, and the commands (effects) that call the adapter and dispatch the
@@ -57,17 +49,14 @@ export class BriefStore implements PendingSave {
/** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future
`Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.
Only CONTENT edits are recorded (they flow through `edit()`); status transitions
never enter history, or undo would replay workflow state. Capped so a long session
can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
(they flow through `edit()`); status transitions never enter history, or undo would
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */
private static readonly HISTORY_CAP = 50;
private past = signal<readonly Brief[]>([]);
private future = signal<readonly Brief[]>([]);
readonly canUndo = computed(() => this.past().length > 0);
readonly canRedo = computed(() => this.future().length > 0);
private history = createHistory<Brief>(50);
readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo;
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
@@ -104,17 +93,7 @@ export class BriefStore implements PendingSave {
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */
readonly remoteData = computed<RemoteData<Error | undefined, LoadedBriefState>>(() => {
const s = this.model();
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
case 'loaded':
return { tag: 'Success', value: s };
}
});
readonly remoteData = computed(() => machineRemoteData(this.model()));
private brief = computed<Brief | null>(() => {
const s = this.model();
@@ -145,7 +124,7 @@ export class BriefStore implements PendingSave {
if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.clearHistory();
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
@@ -159,34 +138,26 @@ export class BriefStore implements PendingSave {
const before = this.brief();
this.store.dispatch(msg);
const after = this.brief();
if (before && after && after !== before) {
this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));
this.future.set([]);
}
this.scheduleSave();
// Record only a real change: a no-op edit (e.g. a locked section) returns the same
// value and leaves no dead history step.
if (before && after && after !== before) this.history.record(before);
this.debouncedSave.schedule();
}
/** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current
onto the redo stack, then autosave. Redo is the mirror image. */
/** Undo/redo: restore a snapshot via the existing `Seed` Msg, then autosave. */
undo() {
this.step(this.past, this.future);
this.restore((current) => this.history.undo(current));
}
redo() {
this.step(this.future, this.past);
this.restore((current) => this.history.redo(current));
}
private step(from: typeof this.past, to: typeof this.future) {
private restore(step: (current: Brief) => Brief | undefined) {
const s = this.model();
const target = from().at(-1);
if (s.tag !== 'loaded' || !target) return;
from.update((x) => x.slice(0, -1));
to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));
if (s.tag !== 'loaded') return;
const target = step(s.brief);
if (target === undefined) return;
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
this.scheduleSave();
}
private clearHistory() {
this.past.set([]);
this.future.set([]);
this.debouncedSave.schedule();
}
constructor() {
@@ -195,27 +166,15 @@ export class BriefStore implements PendingSave {
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.
// 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();
}
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
private debouncedSave = createDebouncedSave({
canSave: () => this.canEdit(),
flush: () => this.flushSave(),
});
/** PendingSave: delegate to the debounce helper so the guard/unload can flush. */
hasPendingSave = () => this.debouncedSave.hasPendingSave();
flushPending = () => this.debouncedSave.flushPending();
private async flushSave() {
const b = this.brief();
if (!b) return;
@@ -237,15 +196,14 @@ export class BriefStore implements PendingSave {
/** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() {
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.debouncedSave.cancel();
const r = await this.adapter.reset();
this.saveState.set({ tag: 'Idle' });
if (r.ok) {
this.actionState.set({ tag: 'Idle' });
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.clearHistory();
this.history.clear();
this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
@@ -289,8 +247,7 @@ export class BriefStore implements PendingSave {
// the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) {
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.debouncedSave.cancel();
await this.flushSave();
const r = await action();
if (!r.ok) {