import type { QuestionAnswer, QuestionData, QuestionnaireResult } from "../tool/types.js"; import type { WrappingSelectItem } from "../view/components/wrapping-select.js"; import type { QuestionnaireAction } from "./key-router.js"; import { ROW_INTENT_META } from "./row-intent.js"; import type { QuestionnaireState } from "./state.js"; /** Session-lifetime constants. No live-component reads — peripheral values live on canonical state. */ export interface ApplyContext { questions: readonly QuestionData[]; itemsByTab: ReadonlyArray; } /** * Declarative side-effects emitted by `reduce`. The runtime executes them after * committing the new state, then asks the props-adapter to re-project. Closed set — * adding an effect requires updating both the union AND the runtime's `runEffect` switch * (compiler-enforced exhaustive). No string-keyed escape hatch. */ export type Effect = | { kind: "set_input_buffer"; value: string } | { kind: "clear_input_buffer" } | { kind: "open_input_editor"; value: string } | { kind: "set_notes_value"; value: string } | { kind: "set_notes_focused"; focused: boolean } | { kind: "forward_notes_keystroke"; data: string } | { kind: "done"; result: QuestionnaireResult }; export interface ApplyResult { state: QuestionnaireState; effects: readonly Effect[]; } function orderedAnswers(state: QuestionnaireState, questions: readonly QuestionData[]): QuestionAnswer[] { const out: QuestionAnswer[] = []; for (let i = 0; i < questions.length; i++) { const a = state.answers.get(i); if (a) out.push(a); } return out; } function syncMultiSelectFromAnswers( answers: ReadonlyMap, questions: readonly QuestionData[], tab: number, ): ReadonlySet { const q = questions[tab]; if (!q?.multiSelect) return new Set(); const saved = answers.get(tab); const labels = saved?.selected ?? []; const indices = new Set(); for (let i = 0; i < q.options.length; i++) { if (labels.includes(q.options[i]!.label)) indices.add(i); } return indices; } function persistMultiSelectAnswer(state: QuestionnaireState, ctx: ApplyContext): ReadonlyMap { const q = ctx.questions[state.currentTab]; if (!q?.multiSelect) return state.answers; const selected: string[] = []; for (let i = 0; i < q.options.length; i++) { if (state.multiSelectChecked.has(i)) selected.push(q.options[i]!.label); } const out = new Map(state.answers); if (selected.length === 0) { out.delete(state.currentTab); return out; } const pendingNotes = state.notesByTab.get(state.currentTab); out.set(state.currentTab, { questionIndex: state.currentTab, question: q.question, kind: "multi", answer: null, selected, ...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}), }); return out; } /** * Note text to seed the editor/draft with for a tab. The in-flight side-band store * (`notesByTab`) is authoritative — before an option is confirmed the note lives ONLY * there; `answer.notes` is a mirror written on exit/confirm. */ function notesValueFor(state: QuestionnaireState, tab: number): string { return state.notesByTab.get(tab) ?? state.answers.get(tab)?.notes ?? ""; } function customDraftValueFor(state: QuestionnaireState, tab: number): string { const draft = state.customDraftsByTab.get(tab); if (draft !== undefined) return draft; const answer = state.answers.get(tab); return answer?.kind === "custom" && typeof answer.answer === "string" ? answer.answer : ""; } function setCustomDraft(state: QuestionnaireState, tab: number, value: string): ReadonlyMap { const drafts = new Map(state.customDraftsByTab); drafts.set(tab, value); return drafts; } function withoutCustomDraft(state: QuestionnaireState, tab: number): ReadonlyMap { if (!state.customDraftsByTab.has(tab)) return state.customDraftsByTab; const drafts = new Map(state.customDraftsByTab); drafts.delete(tab); return drafts; } function switchTabResult(state: QuestionnaireState, nextTab: number, ctx: ApplyContext): ApplyResult { const notesValue = notesValueFor(state, nextTab); const transitioned: QuestionnaireState = { ...state, currentTab: nextTab, optionIndex: 0, inputMode: false, notesVisible: false, submitChoiceIndex: 0, multiSelectChecked: syncMultiSelectFromAnswers(state.answers, ctx.questions, nextTab), notesDraft: notesValue, }; return { state: transitioned, effects: [ { kind: "set_notes_focused", focused: false }, { kind: "set_notes_value", value: notesValue }, { kind: "set_input_buffer", value: customDraftValueFor(state, nextTab) }, ], }; } function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolean): ApplyResult { const result: QuestionnaireResult = { answers: orderedAnswers(state, ctx.questions), cancelled }; return { state, effects: [{ kind: "done", result }] }; } /** * Per-kind handler signature: action payload narrows to the matching union member * via `Extract`, so handlers consume fully-typed actions without `as` casts. */ type Handler = ( state: QuestionnaireState, action: Extract, ctx: ApplyContext, ) => ApplyResult; const navHandler: Handler<"nav"> = (state, action, ctx) => { const items = ctx.itemsByTab[state.currentTab] ?? []; const item = items[action.nextIndex]; const inputMode = item ? ROW_INTENT_META[item.kind].activatesInputMode : false; const customDraftsByTab = state.inputMode ? setCustomDraft(state, state.currentTab, action.inputValue) : state.customDraftsByTab; const next: QuestionnaireState = { ...state, optionIndex: action.nextIndex, inputMode, customDraftsByTab }; if (!inputMode) return { state: next, effects: [] }; return { state: next, effects: [{ kind: "set_input_buffer", value: customDraftValueFor(next, state.currentTab) }], }; }; const inputClearHandler: Handler<"input_clear"> = (state, _action, _ctx) => ({ state: { ...state, customDraftsByTab: setCustomDraft(state, state.currentTab, "") }, effects: [{ kind: "clear_input_buffer" }], }); const inputEditHandler: Handler<"input_edit"> = (state, action, _ctx) => ({ state, effects: [{ kind: "open_input_editor", value: action.value }], }); const inputReplaceHandler: Handler<"input_replace"> = (state, action, _ctx) => ({ state: { ...state, customDraftsByTab: setCustomDraft(state, state.currentTab, action.value) }, effects: [{ kind: "set_input_buffer", value: action.value }], }); const tabSwitchHandler: Handler<"tab_switch"> = (state, action, ctx) => switchTabResult(state, action.nextTab, ctx); const confirmHandler: Handler<"confirm"> = (state, action, ctx) => { let answer = action.answer; if (answer.kind === "option" && answer.answer) { const q = ctx.questions[answer.questionIndex]; const matched = q?.options.find((o) => o.label === answer.answer); if (matched?.preview && matched.preview.length > 0) { answer = { ...answer, preview: matched.preview }; } } const pendingNotes = state.notesByTab.get(answer.questionIndex); if (pendingNotes && pendingNotes.length > 0) { answer = { ...answer, notes: pendingNotes }; } const answers = new Map(state.answers); answers.set(answer.questionIndex, answer); // Custom free-text on a multi-select tab is mutually exclusive with checkbox selections: // clear the checked set immediately so [✔] glyphs vanish on Enter. (A custom answer // carries no `selected` array, so syncMultiSelectFromAnswers keeps it empty on tab-back.) const isCustomMulti = answer.kind === "custom" && ctx.questions[answer.questionIndex]?.multiSelect === true; const customDraftsByTab = answer.kind === "custom" ? withoutCustomDraft(state, answer.questionIndex) : state.customDraftsByTab; const next: QuestionnaireState = { ...state, answers, customDraftsByTab, ...(isCustomMulti ? { multiSelectChecked: new Set() } : {}), }; if (action.autoAdvanceTab !== undefined) return switchTabResult(next, action.autoAdvanceTab, ctx); return doneFor(next, ctx, false); }; const toggleHandler: Handler<"toggle"> = (state, action, ctx) => { const checked = new Set(state.multiSelectChecked); if (checked.has(action.index)) checked.delete(action.index); else checked.add(action.index); const intermediate: QuestionnaireState = { ...state, multiSelectChecked: checked }; const answers = persistMultiSelectAnswer(intermediate, ctx); return { state: { ...intermediate, answers }, effects: [] }; }; const multiConfirmHandler: Handler<"multi_confirm"> = (state, action, ctx) => { const q = ctx.questions[state.currentTab]; if (!q) return { state, effects: [] }; const pendingNotes = state.notesByTab.get(state.currentTab); const answers = new Map(state.answers); answers.set(state.currentTab, { questionIndex: state.currentTab, question: q.question, kind: "multi", answer: null, selected: action.selected, ...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}), }); const synced: QuestionnaireState = { ...state, answers, multiSelectChecked: syncMultiSelectFromAnswers(answers, ctx.questions, state.currentTab), }; if (action.autoAdvanceTab !== undefined) return switchTabResult(synced, action.autoAdvanceTab, ctx); return doneFor(synced, ctx, false); }; const notesEnterHandler: Handler<"notes_enter"> = (state, _action, _ctx) => { const value = notesValueFor(state, state.currentTab); return { state: { ...state, notesVisible: true, notesDraft: value }, effects: [ { kind: "set_notes_value", value }, { kind: "set_notes_focused", focused: true }, ], }; }; const notesExitHandler: Handler<"notes_exit"> = (state, _action, _ctx) => { const trimmed = state.notesDraft.trim(); const notes = new Map(state.notesByTab); const answers = new Map(state.answers); if (trimmed.length === 0) { notes.delete(state.currentTab); const prev = answers.get(state.currentTab); if (prev?.notes) { const stripped = { ...prev }; delete (stripped as { notes?: string }).notes; answers.set(state.currentTab, stripped); } } else { notes.set(state.currentTab, trimmed); const prev = answers.get(state.currentTab); if (prev) answers.set(state.currentTab, { ...prev, notes: trimmed }); } return { state: { ...state, notesByTab: notes, answers, notesVisible: false }, effects: [{ kind: "set_notes_focused", focused: false }], }; }; const cancelHandler: Handler<"cancel"> = (s, _a, c) => doneFor(s, c, true); const submitHandler: Handler<"submit"> = (s, _a, c) => doneFor(s, c, false); const submitNavHandler: Handler<"submit_nav"> = (s, a, _c) => ({ state: { ...s, submitChoiceIndex: a.nextIndex }, effects: [], }); const notesForwardHandler: Handler<"notes_forward"> = (s, a, _c) => ({ state: s, effects: [{ kind: "forward_notes_keystroke", data: a.data }], }); const toggleCollapsedHandler: Handler<"toggle_collapsed"> = (s, _a, _c) => ({ state: { ...s, collapsed: !s.collapsed }, effects: [], }); const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: [] }); /** * Compile-time-exhaustive dispatch table. `{ [K in Kind]: Handler }` requires * an entry per union member — adding a new `QuestionnaireAction` variant fails to * compile here until a handler is registered, mirroring the `Record` * pattern used by `ROW_INTENT_META`. */ const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler } = { nav: navHandler, input_clear: inputClearHandler, input_edit: inputEditHandler, input_replace: inputReplaceHandler, tab_switch: tabSwitchHandler, confirm: confirmHandler, toggle: toggleHandler, multi_confirm: multiConfirmHandler, cancel: cancelHandler, notes_enter: notesEnterHandler, notes_exit: notesExitHandler, notes_forward: notesForwardHandler, submit: submitHandler, submit_nav: submitNavHandler, toggle_collapsed: toggleCollapsedHandler, ignore: ignoreHandler, }; /** * Pure reducer: (state, action, ctx) → (state, Effect[]). Mirrors `rpiv-todo`'s `applyTaskMutation`. * Delegates to `HANDLERS` — per-kind handlers above are pure, named, and individually testable. * `ignore` is also handled outside the reducer by `handleIgnoreInline` in the runtime fast path. */ export function reduce(state: QuestionnaireState, action: QuestionnaireAction, ctx: ApplyContext): ApplyResult { const handler = HANDLERS[action.kind] as Handler; return handler(state, action as never, ctx); }