import type { UserInputQuestion } from "@t3tools/contracts"; export interface PendingUserInputDraftAnswer { selectedOptionLabels?: string[]; customAnswer?: string;} export interface PendingUserInputProgress { questionIndex: number; activeQuestion: UserInputQuestion | null; activeDraft: PendingUserInputDraftAnswer | undefined; selectedOptionLabels: string[]; customAnswer: string; resolvedAnswer: string | string[] | null; usingCustomAnswer: boolean; answeredQuestionCount: number; isLastQuestion: boolean; isComplete: boolean; canAdvance: boolean;} function normalizeDraftAnswer(value: string | undefined): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null;} function normalizeSelectedOptionLabels(value: string[] | undefined): string[] { if (!Array.isArray(value)) { return []; } const normalized: string[] = []; for (const entry of value) { if (typeof entry !== "string") continue; const trimmed = entry.trim(); if (trimmed.length > 0) { normalized.push(trimmed); } } return Array.from(new Set(normalized));} export function resolvePendingUserInputAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined,): string | string[] | null { const customAnswer = normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); if (question.multiSelect) { return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; } return selectedOptionLabels[0] ?? null;} export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string,): PendingUserInputDraftAnswer { const selectedOptionLabels = customAnswer.trim().length > 0 ? undefined : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); return { customAnswer, ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), };} export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, optionLabel: string,): PendingUserInputDraftAnswer { if (question.multiSelect) { const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); const nextSelectedOptionLabels = selectedOptionLabels.includes(optionLabel) ? selectedOptionLabels.filter((label) => label !== optionLabel) : [...selectedOptionLabels, optionLabel]; return { customAnswer: "", ...(nextSelectedOptionLabels.length > 0 ? { selectedOptionLabels: nextSelectedOptionLabels } : {}), }; } return { customAnswer: "", selectedOptionLabels: [optionLabel], };} export function buildPendingUserInputAnswers( questions: ReadonlyArray<UserInputQuestion>, draftAnswers: Record<string, PendingUserInputDraftAnswer>,): Record<string, string | string[]> | null { const answers: Record<string, string | string[]> = {}; for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) { return null; } answers[question.id] = answer; } return answers;} export function countAnsweredPendingUserInputQuestions( questions: ReadonlyArray<UserInputQuestion>, draftAnswers: Record<string, PendingUserInputDraftAnswer>,): number { return questions.reduce((count, question) => { return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) ? count + 1 : count; }, 0);} export function findFirstUnansweredPendingUserInputQuestionIndex( questions: ReadonlyArray<UserInputQuestion>, draftAnswers: Record<string, PendingUserInputDraftAnswer>,): number { const unansweredIndex = questions.findIndex( (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), ); return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex;} export function derivePendingUserInputProgress( questions: ReadonlyArray<UserInputQuestion>, draftAnswers: Record<string, PendingUserInputDraftAnswer>, questionIndex: number,): PendingUserInputProgress { const normalizedQuestionIndex = questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1)); const activeQuestion = questions[normalizedQuestionIndex] ?? null; const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; const resolvedAnswer = activeQuestion ? resolvePendingUserInputAnswer(activeQuestion, activeDraft) : null; const customAnswer = activeDraft?.customAnswer ?? ""; const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); const isLastQuestion = questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; return { questionIndex: normalizedQuestionIndex, activeQuestion, activeDraft, selectedOptionLabels: normalizeSelectedOptionLabels(activeDraft?.selectedOptionLabels), customAnswer, resolvedAnswer, usingCustomAnswer: customAnswer.trim().length > 0, answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, canAdvance: Boolean(resolvedAnswer), };}
import type { UserInputQuestion } from "@t3tools/contracts";
export interface PendingUserInputDraftAnswer {
selectedOptionLabels?: string[];
customAnswer?: string;
}
export interface PendingUserInputProgress {
questionIndex: number;
activeQuestion: UserInputQuestion | null;
activeDraft: PendingUserInputDraftAnswer | undefined;
selectedOptionLabels: string[];
customAnswer: string;
resolvedAnswer: string | string[] | null;
usingCustomAnswer: boolean;
answeredQuestionCount: number;
isLastQuestion: boolean;
isComplete: boolean;
canAdvance: boolean;
}
function normalizeDraftAnswer(value: string | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeSelectedOptionLabels(value: string[] | undefined): string[] {
if (!Array.isArray(value)) {
return [];
}
const normalized: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") continue;
const trimmed = entry.trim();
if (trimmed.length > 0) {
normalized.push(trimmed);
}
}
return Array.from(new Set(normalized));
}
export function resolvePendingUserInputAnswer(
question: UserInputQuestion,
draft: PendingUserInputDraftAnswer | undefined,
): string | string[] | null {
const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
if (customAnswer) {
return customAnswer;
}
const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
if (question.multiSelect) {
return selectedOptionLabels.length > 0 ? selectedOptionLabels : null;
}
return selectedOptionLabels[0] ?? null;
}
export function setPendingUserInputCustomAnswer(
draft: PendingUserInputDraftAnswer | undefined,
customAnswer: string,
): PendingUserInputDraftAnswer {
const selectedOptionLabels =
customAnswer.trim().length > 0
? undefined
: normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
return {
customAnswer,
...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}),
};
}
export function togglePendingUserInputOptionSelection(
question: UserInputQuestion,
draft: PendingUserInputDraftAnswer | undefined,
optionLabel: string,
): PendingUserInputDraftAnswer {
if (question.multiSelect) {
const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
const nextSelectedOptionLabels = selectedOptionLabels.includes(optionLabel)
? selectedOptionLabels.filter((label) => label !== optionLabel)
: [...selectedOptionLabels, optionLabel];
return {
customAnswer: "",
...(nextSelectedOptionLabels.length > 0
? { selectedOptionLabels: nextSelectedOptionLabels }
: {}),
};
}
return {
customAnswer: "",
selectedOptionLabels: [optionLabel],
};
}
export function buildPendingUserInputAnswers(
questions: ReadonlyArray<UserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
): Record<string, string | string[]> | null {
const answers: Record<string, string | string[]> = {};
for (const question of questions) {
const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
if (!answer) {
return null;
}
answers[question.id] = answer;
}
return answers;
}
export function countAnsweredPendingUserInputQuestions(
questions: ReadonlyArray<UserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
): number {
return questions.reduce((count, question) => {
return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) ? count + 1 : count;
}, 0);
}
export function findFirstUnansweredPendingUserInputQuestionIndex(
questions: ReadonlyArray<UserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
): number {
const unansweredIndex = questions.findIndex(
(question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]),
);
return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex;
}
export function derivePendingUserInputProgress(
questions: ReadonlyArray<UserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
questionIndex: number,
): PendingUserInputProgress {
const normalizedQuestionIndex =
questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));
const activeQuestion = questions[normalizedQuestionIndex] ?? null;
const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;
const resolvedAnswer = activeQuestion
? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
: null;
const customAnswer = activeDraft?.customAnswer ?? "";
const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
const isLastQuestion =
questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1;
return {
questionIndex: normalizedQuestionIndex,
activeQuestion,
activeDraft,
selectedOptionLabels: normalizeSelectedOptionLabels(activeDraft?.selectedOptionLabels),
customAnswer,
resolvedAnswer,
usingCustomAnswer: customAnswer.trim().length > 0,
answeredQuestionCount,
isLastQuestion,
isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,
canAdvance: Boolean(resolvedAnswer),
};
}