Add a useDebouncedSave hook and delete Settings.stream

Two items from Stage 2 of `plan/17-refactor.md`.

`frontend/src/hooks/useDebouncedSave.js` replaces three copies of the same
debounce. `PlotPanel` and `ScenarioEditor` held identical per-key timer maps.
`ScriptEditor` held a single shared timer, so editing two fields inside the
same 600 ms window canceled the first save. The hook gives every key its own
timer, which fixes that.

`Settings.stream` was dead state. Nothing read it and every turn streams. This
removes the column, both schema fields, and adds migration 65 to drop it. It is
item S1 in `docs/self-review.md`.

Migration 65 needs a new guard. `_column_already_gone` is the counterpart to
`_column_already_there`: `create_all` builds the current schema, which is
already missing every dropped column, so a fixture that stamps an old version
and replays would fail on a column that is not there.

Verified: 549 backend tests pass, lint and build are clean, and migration 65
runs both ways, once against a database that still has the column and once
against one that does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
This commit is contained in:
parththakkar106
2026-08-29 02:12:32 +05:30
co-authored by Claude Opus 5
parent 2c57b1ceab
commit e0bf2b61d9
8 changed files with 63 additions and 28 deletions
+17
View File
@@ -0,0 +1,17 @@
// Delays a save until its field has been quiet for a moment.
//
// Every `key` gets its own timer. One shared timer cancels the pending save of
// whatever was edited before it, so editing two fields inside the same window
// saves only the second one.
import { useRef } from 'react'
function useDebouncedSave(delay = 600) {
const timers = useRef(new Map())
return (key, fn) => {
clearTimeout(timers.current.get(key))
timers.current.set(key, setTimeout(fn, delay))
}
}
export { useDebouncedSave }
+3 -8
View File
@@ -1,21 +1,16 @@
// The plot panel: the adventure's own copy of the scenario text and cards.
import { useRef, useState } from 'react'
import { useState } from 'react'
import { api } from '../../../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../../../components'
import { useDebouncedSave } from '../../../hooks/useDebouncedSave'
import { RefreshModal } from '../RefreshModal'
function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) {
const toast = useToast()
const [plan, setPlan] = useState(null) // non-null while the modal is open
const [planning, setPlanning] = useState(false)
// One timer per field/card: a single shared timer would cancel the pending
// save of whatever was edited previously within the debounce window.
const saveTimers = useRef(new Map())
const debounceSave = (key, fn) => {
clearTimeout(saveTimers.current.get(key))
saveTimers.current.set(key, setTimeout(fn, 600))
}
const debounceSave = useDebouncedSave()
const setField = (field, value) => {
setAdventure({ ...adventure, [field]: value })
+3 -8
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
import { useDebouncedSave } from '../hooks/useDebouncedSave'
import ArtPicker from '../ArtPicker'
import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor'
@@ -18,13 +19,7 @@ export default function ScenarioEditor() {
const [parsedSchema, setParsedSchema] = useState(null)
const [schemaView, setSchemaView] = useState('form') // 'form' | 'json'
const toast = useToast()
// One timer per field/card: a single shared timer would cancel the pending
// save of whatever was edited previously within the debounce window.
const saveTimers = useRef(new Map())
const debounceSave = (key, fn) => {
clearTimeout(saveTimers.current.get(key))
saveTimers.current.set(key, setTimeout(fn, 600))
}
const debounceSave = useDebouncedSave()
useEffect(() => {
api.getScenario(id).then((s) => {
+5 -5
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
import { api } from '../api'
import { Field, downloadJSON } from '../components'
import { useDebouncedSave } from '../hooks/useDebouncedSave'
const SLOTS = [
{ key: 'library_js', label: 'Library', hint: 'Shared code prepended to all three hooks.' },
@@ -19,7 +20,7 @@ export default function ScriptEditor() {
const [script, setScript] = useState(null)
const [slot, setSlot] = useState('input_js')
const [status, setStatus] = useState('')
const saveTimer = useRef(null)
const debounceSave = useDebouncedSave()
// Test-run state
const [testHook, setTestHook] = useState('input')
@@ -33,12 +34,11 @@ export default function ScriptEditor() {
const setField = (field, value) => {
setScript((prev) => ({ ...prev, [field]: value }))
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(async () => {
debounceSave(field, async () => {
await api.updateScript(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}, 600)
})
}
const runTest = async () => {