Skip to content

Commit a44193b

Browse files
Persist local action trail in browser storage
1 parent 217671e commit a44193b

3 files changed

Lines changed: 116 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## 2026-05-30 — Local Action Persistence v1
4+
5+
### Changed
6+
7+
- Persisted frontend-only staged action trail records in browser localStorage.
8+
- Restored local staged action history after page refresh.
9+
- Restored staged break state from persisted action trail records.
10+
- Added a clear control for the current break's staged action history.
11+
- Preserved action preview, candidate decision guardrails, hide-staged queue behavior, and evidence review workflow.
12+
13+
### Design Notes
14+
15+
- This milestone makes the alpha workbench feel more like a persistent review tool.
16+
- Staged actions remain browser-local and are not written to a backend.
17+
- This prepares the project for a future real action-log API or file-backed persistence layer.
18+
319
## 2026-05-30 — Action Log Payload Contract v1
420

521
### Added

frontend/components/BreakResolutionWorkbench.tsx

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
"use client";
22

33
import { Button } from "@salt-ds/core";
4-
import { type LocalActionRecord } from "@/lib/actionLog";
4+
import {
5+
ACTION_TRAIL_STORAGE_KEY,
6+
isLocalActionRecordArray,
7+
type LocalActionRecord,
8+
} from "@/lib/actionLog";
59
import { useEffect, useMemo, useState } from "react";
610
import {
711
candidatesByExceptionId as fallbackCandidatesByExceptionId,
@@ -874,15 +878,32 @@ function buildActionPreview({
874878
}
875879

876880

877-
function LocalActionTrail({ records }: { records: LocalActionRecord[] }) {
881+
function LocalActionTrail({
882+
records,
883+
onClear,
884+
}: {
885+
records: LocalActionRecord[];
886+
onClear: () => void;
887+
}) {
878888
return (
879889
<div className="mt-4 rounded-2xl border border-slate-200 bg-white p-3">
880890
<div className="flex items-center justify-between gap-3">
881891
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">
882892
Staged action history
883893
</div>
884-
<div className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">
885-
{records.length} staged
894+
<div className="flex items-center gap-2">
895+
<div className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">
896+
{records.length} staged
897+
</div>
898+
{records.length > 0 ? (
899+
<button
900+
type="button"
901+
onClick={onClear}
902+
className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-bold text-slate-600 hover:bg-slate-50"
903+
>
904+
Clear
905+
</button>
906+
) : null}
886907
</div>
887908
</div>
888909

@@ -935,6 +956,7 @@ export default function BreakResolutionWorkbench() {
935956
const [hideStaged, setHideStaged] = useState(false);
936957
const [stagedExceptionIds, setStagedExceptionIds] = useState<string[]>([]);
937958
const [actionTrail, setActionTrail] = useState<LocalActionRecord[]>([]);
959+
const [hasLoadedActionTrail, setHasLoadedActionTrail] = useState(false);
938960
const [queueFilter, setQueueFilter] = useState<QueueFilter>("All");
939961
const [dataSource, setDataSource] = useState("static fallback");
940962
const [isLoadingData, setIsLoadingData] = useState(true);
@@ -977,6 +999,41 @@ export default function BreakResolutionWorkbench() {
977999
void loadWorkbenchData();
9781000
}, []);
9791001

1002+
useEffect(() => {
1003+
try {
1004+
const storedActionTrail = window.localStorage.getItem(ACTION_TRAIL_STORAGE_KEY);
1005+
1006+
if (!storedActionTrail) {
1007+
return;
1008+
}
1009+
1010+
const parsedActionTrail = JSON.parse(storedActionTrail) as unknown;
1011+
1012+
if (isLocalActionRecordArray(parsedActionTrail)) {
1013+
setActionTrail(parsedActionTrail);
1014+
setStagedExceptionIds(
1015+
Array.from(new Set(parsedActionTrail.map((record) => record.exceptionId))),
1016+
);
1017+
}
1018+
} catch {
1019+
setActionTrail([]);
1020+
setStagedExceptionIds([]);
1021+
} finally {
1022+
setHasLoadedActionTrail(true);
1023+
}
1024+
}, []);
1025+
1026+
useEffect(() => {
1027+
if (!hasLoadedActionTrail) {
1028+
return;
1029+
}
1030+
1031+
window.localStorage.setItem(
1032+
ACTION_TRAIL_STORAGE_KEY,
1033+
JSON.stringify(actionTrail),
1034+
);
1035+
}, [actionTrail, hasLoadedActionTrail]);
1036+
9801037
const filteredPriorityQueue = useMemo(
9811038
() =>
9821039
filterPriorityQueue(
@@ -1596,7 +1653,17 @@ export default function BreakResolutionWorkbench() {
15961653
</div>
15971654

15981655

1599-
<LocalActionTrail records={currentBreakActionTrail} />
1656+
<LocalActionTrail
1657+
records={currentBreakActionTrail}
1658+
onClear={() => {
1659+
setActionTrail((current) =>
1660+
current.filter((record) => record.exceptionId !== selectedBreak.exceptionId),
1661+
);
1662+
setStagedExceptionIds((current) =>
1663+
current.filter((exceptionId) => exceptionId !== selectedBreak.exceptionId),
1664+
);
1665+
}}
1666+
/>
16001667
</aside>
16011668
</section>
16021669

frontend/lib/actionLog.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export const ACTION_TRAIL_STORAGE_KEY = "cash-reconciliation-workbench.action-trail.v1";
2+
13
export type DecisionType = "Action recommendation" | "Candidate decision";
24

35
export type CandidateSource = "Rule-based" | "Splink" | "Split-payment";
@@ -63,3 +65,29 @@ export function toActionLogPayloadV1(
6365
: undefined,
6466
};
6567
}
68+
69+
export function isLocalActionRecordArray(
70+
value: unknown,
71+
): value is LocalActionRecord[] {
72+
return (
73+
Array.isArray(value) &&
74+
value.every((item) => {
75+
if (!item || typeof item !== "object") {
76+
return false;
77+
}
78+
79+
const record = item as Partial<LocalActionRecord>;
80+
81+
return (
82+
typeof record.id === "string" &&
83+
typeof record.exceptionId === "string" &&
84+
typeof record.actionType === "string" &&
85+
typeof record.proposedStatus === "string" &&
86+
typeof record.dispositionCode === "string" &&
87+
typeof record.actor === "string" &&
88+
typeof record.timestamp === "string"
89+
);
90+
})
91+
);
92+
}
93+

0 commit comments

Comments
 (0)