Implement the 023-clear-encounter feature that adds a clear encounter button with confirmation dialog to remove all combatants and reset round/turn counters, with the cleared state persisting across page refreshes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-09 13:43:42 +01:00
parent 11c4c0237e
commit 24198c25f1
19 changed files with 703 additions and 16 deletions

View File

@@ -13,6 +13,7 @@ export function App() {
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
@@ -94,6 +95,7 @@ export function App() {
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
/>
</div>

View File

@@ -1,17 +1,19 @@
import type { Encounter } from "@initiative/domain";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react";
import { Button } from "./ui/button";
interface TurnNavigationProps {
encounter: Encounter;
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
}
export function TurnNavigation({
encounter,
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
}: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
@@ -44,16 +46,27 @@ export function TurnNavigation({
)}
</div>
<Button
variant="outline"
size="sm"
className="hover:bg-muted"
onClick={onAdvanceTurn}
disabled={!hasCombatants}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="hover:bg-muted"
onClick={onAdvanceTurn}
disabled={!hasCombatants}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={onClearEncounter}
disabled={!hasCombatants}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import {
addCombatantUseCase,
adjustHpUseCase,
advanceTurnUseCase,
clearEncounterUseCase,
editCombatantUseCase,
removeCombatantUseCase,
retreatTurnUseCase,
@@ -223,6 +224,21 @@ export function useEncounter() {
[makeStore],
);
const clearEncounter = useCallback(() => {
if (!window.confirm("Clear the entire encounter? This cannot be undone.")) {
return;
}
const result = clearEncounterUseCase(makeStore());
if (isDomainError(result)) {
return;
}
nextId.current = 0;
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const addFromBestiary = useCallback(
(creature: Creature) => {
const store = makeStore();
@@ -280,6 +296,7 @@ export function useEncounter() {
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,

View File

@@ -97,12 +97,17 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull();
});
it("returns null for invalid encounter data (empty combatants)", () => {
it("returns empty encounter for cleared state (empty combatants)", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
);
expect(loadEncounter()).toBeNull();
const result = loadEncounter();
expect(result).toEqual({
combatants: [],
activeIndex: 0,
roundNumber: 1,
});
});
it("returns null for out-of-bounds activeIndex", () => {
@@ -198,12 +203,17 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull();
});
it("returns null for zero combatants (domain validation)", () => {
it("returns empty encounter for zero combatants (cleared state)", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
);
expect(loadEncounter()).toBeNull();
const result = loadEncounter();
expect(result).toEqual({
combatants: [],
activeIndex: 0,
roundNumber: 1,
});
});
it("round-trip preserves combatant AC value", () => {

View File

@@ -35,6 +35,16 @@ export function loadEncounter(): Encounter | null {
if (typeof obj.roundNumber !== "number") return null;
const combatants = obj.combatants as unknown[];
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
if (combatants.length === 0) {
return {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
}
for (const c of combatants) {
if (typeof c !== "object" || c === null || Array.isArray(c)) return null;
const entry = c as Record<string, unknown>;