import type { AnswerSelection, NormalizedQuestion, QuestionAnswer, } from "./schema.ts"; export type QuestionRow = | { kind: "option"; optionIndex: number } | { kind: "other" } | { kind: "continue" }; export interface QuestionnaireState { questions: readonly NormalizedQuestion[]; activeTab: number; focusByQuestion: number[]; selectionsByQuestion: AnswerSelection[][]; editorQuestionIndex: number | null; reviewFocus: 0 | 1; } export function createQuestionnaireState( questions: readonly NormalizedQuestion[], ): QuestionnaireState { return { questions, activeTab: 0, focusByQuestion: questions.map(() => 0), selectionsByQuestion: questions.map(() => []), editorQuestionIndex: null, reviewFocus: 0, }; } export function rowsForQuestion(question: NormalizedQuestion): QuestionRow[] { const rows: QuestionRow[] = question.options.map((_option, optionIndex) => ({ kind: "option", optionIndex, })); rows.push({ kind: "other" }); if (question.multiSelect) rows.push({ kind: "continue" }); return rows; } export function isReviewTab(state: QuestionnaireState): boolean { return state.activeTab === state.questions.length; } export function isQuestionAnswered( state: QuestionnaireState, questionIndex: number, ): boolean { return (state.selectionsByQuestion[questionIndex]?.length ?? 0) > 0; } export function canSubmit(state: QuestionnaireState): boolean { return state.questions.every((_question, index) => isQuestionAnswered(state, index), ); } export function moveFocus( state: QuestionnaireState, delta: number, ): QuestionnaireState { if (isReviewTab(state)) { return { ...state, reviewFocus: state.reviewFocus === 0 ? 1 : 0, }; } const questionIndex = state.activeTab; const rowCount = rowsForQuestion(state.questions[questionIndex]).length; if (rowCount === 0) return state; const current = state.focusByQuestion[questionIndex] ?? 0; const next = (current + delta + rowCount) % rowCount; const focusByQuestion = [...state.focusByQuestion]; focusByQuestion[questionIndex] = next; return { ...state, focusByQuestion }; } export function moveTab( state: QuestionnaireState, delta: number, ): QuestionnaireState { const tabCount = state.questions.length + 1; const activeTab = (state.activeTab + delta + tabCount) % tabCount; return { ...state, activeTab, editorQuestionIndex: null, }; } export function advanceTab(state: QuestionnaireState): QuestionnaireState { return { ...state, activeTab: Math.min(state.questions.length, state.activeTab + 1), editorQuestionIndex: null, }; } export function returnFromReview( state: QuestionnaireState, ): QuestionnaireState { const firstUnanswered = state.questions.findIndex( (_question, index) => !isQuestionAnswered(state, index), ); return { ...state, activeTab: firstUnanswered >= 0 ? firstUnanswered : Math.max(0, state.questions.length - 1), editorQuestionIndex: null, }; } function replaceSelections( state: QuestionnaireState, questionIndex: number, selections: AnswerSelection[], ): QuestionnaireState { const selectionsByQuestion = state.selectionsByQuestion.map( (existing, index) => (index === questionIndex ? selections : existing), ); return { ...state, selectionsByQuestion }; } function toggleOption( state: QuestionnaireState, questionIndex: number, optionIndex: number, ): QuestionnaireState { const question = state.questions[questionIndex]; const option = question.options[optionIndex]; const current = state.selectionsByQuestion[questionIndex] ?? []; const selectedIndex = current.findIndex( (selection) => !selection.custom && selection.value === option.value, ); const selections = selectedIndex >= 0 ? current.filter((_selection, index) => index !== selectedIndex) : [ ...current, { value: option.value, label: option.label, custom: false }, ]; return replaceSelections(state, questionIndex, selections); } export function activateFocusedRow( state: QuestionnaireState, ): QuestionnaireState { if (isReviewTab(state)) return state; const questionIndex = state.activeTab; const question = state.questions[questionIndex]; const rows = rowsForQuestion(question); const row = rows[state.focusByQuestion[questionIndex] ?? 0]; if (!row) return state; if (row.kind === "other") { return { ...state, editorQuestionIndex: questionIndex }; } if (row.kind === "continue") { return advanceTab(state); } if (question.multiSelect) { return toggleOption(state, questionIndex, row.optionIndex); } const option = question.options[row.optionIndex]; return advanceTab( replaceSelections(state, questionIndex, [ { value: option.value, label: option.label, custom: false }, ]), ); } export function toggleFocusedOption( state: QuestionnaireState, ): QuestionnaireState { if (isReviewTab(state)) return state; const questionIndex = state.activeTab; const question = state.questions[questionIndex]; if (!question.multiSelect) return state; const row = rowsForQuestion(question)[state.focusByQuestion[questionIndex] ?? 0]; if (!row || row.kind !== "option") return state; return toggleOption(state, questionIndex, row.optionIndex); } export function cancelCustomAnswer( state: QuestionnaireState, ): QuestionnaireState { return { ...state, editorQuestionIndex: null }; } export function submitCustomAnswer( state: QuestionnaireState, rawValue: string, ): QuestionnaireState { const questionIndex = state.editorQuestionIndex; if (questionIndex === null) return state; const value = rawValue.trim(); const withoutEditor = { ...state, editorQuestionIndex: null }; if (!value) { const selections = (state.selectionsByQuestion[questionIndex] ?? []).filter( (selection) => !selection.custom, ); return replaceSelections(withoutEditor, questionIndex, selections); } const question = state.questions[questionIndex]; const custom: AnswerSelection = { value, label: value, custom: true, }; if (!question.multiSelect) { return advanceTab( replaceSelections(withoutEditor, questionIndex, [custom]), ); } const current = state.selectionsByQuestion[questionIndex] ?? []; const selections = [ ...current.filter((selection) => !selection.custom), custom, ]; return replaceSelections(withoutEditor, questionIndex, selections); } export function buildAnswers(state: QuestionnaireState): QuestionAnswer[] { return state.questions.map((question, index) => ({ questionId: question.id, question: question.question, selections: [...(state.selectionsByQuestion[index] ?? [])], })); }