diff --git a/components/admin/stygian-editor.tsx b/components/admin/stygian-editor.tsx index 701269c..ec8f946 100644 --- a/components/admin/stygian-editor.tsx +++ b/components/admin/stygian-editor.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState, useTransition, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, useTransition, type ReactNode } from "react"; import { GripVerticalIcon, PlusIcon, @@ -72,6 +72,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Spinner } from "@/components/ui/spinner"; import { elementIconFilter } from "@/lib/catalog/element"; +import { AutosaveCoordinator, type AutosaveStatus as CoordinatorStatus } from "@/lib/editor/autosave"; import { supportsWeaponType } from "@/lib/catalog/lunaris"; import { stygianElements, @@ -823,66 +824,79 @@ function BossRecommendationEditor({ team.items.every((item) => item.kind === "character"), ); const [savedFingerprint, setSavedFingerprint] = useState(fingerprint); - const [failedFingerprint, setFailedFingerprint] = useState( - null, - ); - const [savingFingerprint, setSavingFingerprint] = useState( - null, - ); + const savedFingerprintRef = useRef(fingerprint); + const latestDraftRef = useRef({ fingerprint, valid }); + const [saveStatus, setSaveStatus] = useState({ state: "idle" }); + const initialVersionRef = useRef(version); + const coordinatorRef = useRef | null>(null); const saveState = !valid ? "incomplete" - : failedFingerprint === fingerprint - ? "error" - : savingFingerprint !== null - ? "saving" - : fingerprint === savedFingerprint - ? "saved" + : saveStatus.state === "saving" + ? "saving" + : fingerprint === savedFingerprint + ? "saved" + : saveStatus.state === "error" + ? "error" : "unsaved"; useEffect(() => { - if ( - fingerprint === savedFingerprint || - !valid || - fingerprint === failedFingerprint || - savingFingerprint !== null - ) - return; - const timer = window.setTimeout(async () => { - setSavingFingerprint(fingerprint); - const snapshot = JSON.parse(fingerprint) as { - teams: StygianRecommendationItemInput[][]; - units: StygianRecommendationItemInput[]; - }; - const result = await saveStygianRecommendationsAction(scheduleId, { - expectedVersion: version, - level, - bossPosition: boss.position, - ...snapshot, - }); - if (result.status === "error") { - setFailedFingerprint(fingerprint); - setSavingFingerprint(null); - toast.error(result.message); - return; - } - setSavedFingerprint(fingerprint); - setFailedFingerprint(null); - setSavingFingerprint(null); - onVersionChange(result.version); - }, 700); - return () => window.clearTimeout(timer); - }, [ - boss.position, - failedFingerprint, - fingerprint, - level, - onVersionChange, - savedFingerprint, - savingFingerprint, - scheduleId, - valid, - version, - ]); + const coordinator = new AutosaveCoordinator({ + initialVersion: initialVersionRef.current, + debounceMs: 2_500, + maxRetries: 0, + createPayload: (draft: string, expectedVersion) => ({ + fingerprint: draft, + expectedVersion, + ...JSON.parse(draft) as { teams: StygianRecommendationItemInput[][]; units: StygianRecommendationItemInput[] }, + }), + save: async ({ fingerprint: draft, ...payload }) => { + const result = await saveStygianRecommendationsAction(scheduleId, { + ...payload, + level, + bossPosition: boss.position, + }); + return result.status === "saved" + ? { status: "saved" as const, version: result.version, snapshot: draft } + : { status: "transient-error" as const, message: result.message }; + }, + onStatus: (status) => { + setSaveStatus(status); + if (status.state === "error") toast.error(status.message); + }, + onSaved: (draft, savedVersion) => { + if (draft) { + savedFingerprintRef.current = draft; + setSavedFingerprint(draft); + const latest = latestDraftRef.current; + if (latest.valid && latest.fingerprint !== draft) coordinator.schedule(latest.fingerprint); + } + onVersionChange(savedVersion); + }, + }); + coordinatorRef.current = coordinator; + return () => { + coordinator.dispose(); + coordinatorRef.current = null; + }; + }, [boss.position, level, onVersionChange, scheduleId]); + + useEffect(() => { + const coordinator = coordinatorRef.current; + if (!coordinator) return; + coordinator.updateVersion(version); + }, [version]); + + useEffect(() => { + const coordinator = coordinatorRef.current; + if (!coordinator) return; + latestDraftRef.current = { fingerprint, valid }; + if (!valid || fingerprint === savedFingerprintRef.current) coordinator.cancelPending(); + else coordinator.schedule(fingerprint); + }, [fingerprint, valid]); return ( @@ -983,7 +997,7 @@ function BossRecommendationEditor({ ? [] : ["Add at least 1 character to each team before autosave."] } - onSave={() => setFailedFingerprint(null)} + onSave={() => void coordinatorRef.current?.flush()} /> diff --git a/hooks/use-autosave.ts b/hooks/use-autosave.ts index 6c4784f..910b9f9 100644 --- a/hooks/use-autosave.ts +++ b/hooks/use-autosave.ts @@ -25,7 +25,7 @@ export function useAutosave({ valid, initialVersion, persist, - delay = 900, + delay = 2_500, storageKey, localDraft, parseLocalDraft, diff --git a/lib/editor/autosave.test.ts b/lib/editor/autosave.test.ts index 32c9792..3dfb31a 100644 --- a/lib/editor/autosave.test.ts +++ b/lib/editor/autosave.test.ts @@ -53,13 +53,13 @@ describe("AutosaveCoordinator", () => { expect(saves).toEqual([{ value: "สอง", expectedVersion: 1 }]); }); - it("serializes a new edit behind an in-flight save", async () => { + it("waits for the idle delay before saving an edit made during a request", async () => { vi.useFakeTimers(); let resolveFirst: ((value: AutosaveResponse) => void) | undefined; const saves: string[] = []; const coordinator = new AutosaveCoordinator({ initialVersion: 4, - debounceMs: 10, + debounceMs: 100, createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }), save: async ({ value, expectedVersion }) => { saves.push(`${value}:${expectedVersion}`); @@ -72,15 +72,52 @@ describe("AutosaveCoordinator", () => { }, }); coordinator.schedule("แรก"); - await advance(10); + await advance(100); coordinator.schedule("ล่าสุด"); - await advance(10); + await advance(50); expect(saves).toEqual(["แรก:4"]); resolveFirst?.({ status: "saved", version: 5 }); await advance(0); + expect(saves).toEqual(["แรก:4"]); + await advance(49); + expect(saves).toEqual(["แรก:4"]); + await advance(1); expect(saves).toEqual(["แรก:4", "ล่าสุด:5"]); }); + it("uses an externally updated version and drops invalid pending drafts", async () => { + vi.useFakeTimers(); + const { coordinator, saves } = harness(); + coordinator.updateVersion(8); + coordinator.schedule("invalid"); + coordinator.cancelPending(); + await advance(750); + expect(saves).toEqual([]); + coordinator.schedule("valid"); + await advance(750); + expect(saves).toEqual([{ value: "valid", expectedVersion: 8 }]); + }); + + it("does not save the same draft twice when it is queued during a request", async () => { + vi.useFakeTimers(); + let resolveFirst: ((value: AutosaveResponse) => void) | undefined; + const save = vi.fn(() => new Promise((resolve) => { + resolveFirst = resolve; + })); + const coordinator = new AutosaveCoordinator({ + initialVersion: 1, + debounceMs: 100, + createPayload: (draft: string) => draft, + save, + }); + coordinator.schedule("same"); + await advance(100); + coordinator.schedule("same"); + resolveFirst?.({ status: "saved", version: 2 }); + await advance(100); + expect(save).toHaveBeenCalledTimes(1); + }); + it("retries only transient failures with capped exponential delays", async () => { vi.useFakeTimers(); const { coordinator, save } = harness([ diff --git a/lib/editor/autosave.ts b/lib/editor/autosave.ts index ab6f792..89067f1 100644 --- a/lib/editor/autosave.ts +++ b/lib/editor/autosave.ts @@ -48,6 +48,10 @@ export class AutosaveCoordinator { return this.version; } + updateVersion(version: number): void { + this.version = Math.max(this.version, version); + } + schedule(draft: Draft): void { if (this.disposed) return; this.pending = draft; @@ -61,6 +65,14 @@ export class AutosaveCoordinator { ); } + cancelPending(): void { + this.pending = undefined; + this.generation += 1; + this.clearDebounce(); + if (this.retryTimer !== undefined) this.saving = false; + this.clearRetry(); + } + async flush(): Promise { this.clearDebounce(); if (this.disposed || this.saving || this.pending === undefined) return; @@ -101,11 +113,12 @@ export class AutosaveCoordinator { if (this.disposed) return; if (response.status === "saved") { - this.version = response.version; + this.updateVersion(response.version); this.saving = false; - this.options.onSaved?.(response.snapshot, response.version); - this.emit({ state: "saved", version: response.version, savedAt: new Date() }); - if (this.pending !== undefined) void this.flush(); + if (Object.is(this.pending, draft)) this.cancelPending(); + this.options.onSaved?.(response.snapshot, this.version); + this.emit({ state: "saved", version: this.version, savedAt: new Date() }); + if (this.pending !== undefined && this.debounceTimer === undefined) void this.flush(); return; }