fix: serialize snapshot mutations, catch export/plan throws in event handlers
Fixes the three findings from the final whole-branch review:
- SnapshotStore.mutate/replace now serialize behind a promise queue, so
concurrent mutate() calls (e.g. two independent onChange handlers firing
before either write resolves) each clone from the previous commit instead
of racing on a stale snapshot and silently losing an edit. Preserves
write-before-move-snapshot, caller-visible rejections, structuredClone
identity, and non-wedging on a failed write. Adds tests for concurrent
mutations and a failing write mid-queue.
- DataSection's exportJson/exportOds are wrapped in try/catch and report
failures through the existing import error state/rendering, since a throw
(e.g. MissingRateError from buildOds) inside a plain onClick is invisible
to the error boundary. The pre-import JSON backup now explicitly aborts
the destructive replace if the backup export fails, rather than relying on
an uncaught throw to prevent it.
- PlanEditor no longer calls monthOf()/withPlan() with an empty or partial
date from a native <input type="date">, and requires an integer month
count (with step={1}) before calling withPlan/equalSlices, preventing
both domain throws from firing inside plain event handlers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
da0d1ac35c
commit
ed1667f8a0
@@ -16,19 +16,42 @@ interface Props {
|
||||
onChange: (next: PurchaseDraft) => void;
|
||||
}
|
||||
|
||||
// A native <input type="date"> reports "" until the user has filled in every
|
||||
// field, and monthOf() throws on anything that isn't a well-formed
|
||||
// YYYY-MM-DD. This is UI-level input validation, not a domain concern — the
|
||||
// domain is right to throw on malformed input; the caller just must not call
|
||||
// it with malformed input.
|
||||
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function hasValidDate(draft: PurchaseDraft): boolean {
|
||||
return ISO_DATE_PATTERN.test(draft.date);
|
||||
}
|
||||
|
||||
export function PlanEditor({ draft, onChange }: Props) {
|
||||
const balance = planBalance(draft);
|
||||
|
||||
if (!draft.plan) {
|
||||
const dateReady = hasValidDate(draft);
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(withPlan(draft, monthOf(draft.date), 3))}
|
||||
>
|
||||
Spread this over several months
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!dateReady}
|
||||
onClick={() => {
|
||||
if (!dateReady) return;
|
||||
onChange(withPlan(draft, monthOf(draft.date), 3));
|
||||
}}
|
||||
>
|
||||
Spread this over several months
|
||||
</Button>
|
||||
{!dateReady && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick a date for this purchase first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,11 +64,12 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="h-8 w-16 tabular-nums"
|
||||
value={draft.plan.slices.length}
|
||||
onChange={(event) => {
|
||||
const months = Number(event.target.value);
|
||||
if (months >= 1) {
|
||||
if (Number.isInteger(months) && months >= 1) {
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months));
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user