refactor(brief): move the action lifecycle into the machine (RD-12)

The action lifecycle (Idle | Busy | Failed) lived in an imperative
store-level signal, set from ten call sites outside the reducer. The
reducer could not enforce which action transitions are legal.

Add `action` to `BriefState.Loaded`, driven by three new messages
(ActionStarted, ActionFinished, ActionFailed) and handled in `reduce`.
Replace every `actionState.set(...)` call in `brief.store.ts` with the
matching `dispatch`. `BriefLoaded` resets `action` to Idle, so a fresh
load clears a stale action error instead of letting it outlive the
reload.

`busy` and `lastError` stay as `computed`s on the store with a
byte-identical public signature — they are the render seam for four
components and two page templates, and the union belongs in the
machine, not the components. `revealBigNummer` still sets only
`Failed`, never `Busy` — an existing asymmetry, not changed here.
`SaveState`, `org-template.store.ts`, and `pendingPublish` are out of
scope (RD-13, RD-14).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 18:27:09 +02:00
co-authored by Claude Sonnet 5
parent 43f62ddfee
commit 02d41536df
6 changed files with 248 additions and 19 deletions
@@ -1,7 +1,7 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { SaveState } from '@shared/application/action-state';
import { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { fromLoadLifecycle } from '@shared/application/remote-data';
@@ -41,11 +41,16 @@ export class BriefStore implements PendingSave {
readonly model = this.store.model;
private actionState = signal<ActionState>({ tag: 'Idle' });
readonly busy = computed(() => this.actionState().tag === 'Busy');
/** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
these stay as plain `computed`s so the render seam (four `busy = input(...)`
components, two page templates) keeps a byte-identical boolean/string API. */
readonly busy = computed(() => {
const s = this.model();
return s.tag === 'Loaded' && s.action.tag === 'Busy';
});
readonly lastError = computed(() => {
const s = this.actionState();
return s.tag === 'Failed' ? s.error : null;
const s = this.model();
return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
});
/** Surfaced autosave state for the indicator + aria-live region. */
@@ -212,7 +217,9 @@ export class BriefStore implements PendingSave {
if (r.ok) {
this.saveState.set({ tag: 'Saved' });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
// The autosave failure legitimately surfaces in two places: the small save
// indicator below (kept as-is) and the action error line (RD-12).
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
this.saveState.set({ tag: 'Error' });
}
}
@@ -224,19 +231,19 @@ 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' });
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel();
const r = await this.adapter.reset();
this.saveState.set({ tag: 'Idle' });
if (r.ok) {
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'ActionFinished' });
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.history.clear();
this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
}
}
@@ -249,13 +256,13 @@ export class BriefStore implements PendingSave {
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
object URL is never revoked. */
async previewLetter() {
this.actionState.set({ tag: 'Busy' });
this.store.dispatch({ tag: 'ActionStarted' });
const r = await this.previewAdapter.preview();
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'ActionFinished' });
this.blobPresenter.open(r.value);
}
@@ -269,7 +276,8 @@ export class BriefStore implements PendingSave {
async revealBigNummer() {
const r = await this.revealAdapter.reveal(true);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
// Never sets Busy — an existing asymmetry (RD-12), not fixed here.
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
@@ -278,15 +286,15 @@ export class BriefStore implements PendingSave {
// A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) {
this.actionState.set({ tag: 'Busy' });
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel();
await this.flushSave();
const r = await action();
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'ActionFinished' });
this.applyServerStatus(r.value);
}