///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
interface SurveyResponseArgs {
responses: Default;
questions: Default;
}
interface SurveyResponseInput {
respondent?: string;
demographic?: string;
answers?: Record;
}
interface SurveyResponse {
respondent: string;
demographic: string;
answers: Record;
}
interface QuestionSummary {
question: string;
total: number;
answered: number;
average: number;
}
interface DemographicSummary {
demographic: string;
responseCount: number;
questionAverages: Record;
overallAverage: number;
}
interface RecordResponseEvent {
respondent?: string;
demographic?: string;
answers?: Record;
}
const defaultDemographic = "general";
const roundAverage = (value: number): number => {
if (!Number.isFinite(value)) return 0;
return Math.round(value * 100) / 100;
};
const normalizeName = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const ensureUnique = (candidate: string, used: Set): string => {
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
let index = 2;
let next = `${candidate}-${index}`;
while (used.has(next)) {
index += 1;
next = `${candidate}-${index}`;
}
used.add(next);
return next;
};
const sanitizeScore = (value: unknown): number => {
if (typeof value !== "number" || Number.isNaN(value)) return 0;
const clamped = Math.min(5, Math.max(0, value));
return roundAverage(clamped);
};
const sanitizeAnswerMap = (
value: Record | undefined,
): Record => {
if (!value || typeof value !== "object") return {};
const entries: [string, number][] = [];
for (const [key, raw] of Object.entries(value)) {
const question = normalizeName(key);
if (!question) continue;
const score = sanitizeScore(raw);
entries.push([question, score]);
}
entries.sort((left, right) => left[0].localeCompare(right[0]));
const record: Record = {};
for (const [question, score] of entries) {
record[question] = score;
}
return record;
};
const sanitizeResponseEntry = (
value: SurveyResponseInput | undefined,
fallbackIndex: number,
used: Set,
): SurveyResponse => {
const fallbackRespondent = `respondent-${Math.max(1, fallbackIndex)}`;
const respondentName = normalizeName(value?.respondent) ?? fallbackRespondent;
const uniqueRespondent = ensureUnique(respondentName, used);
const demographic = normalizeName(value?.demographic) ?? defaultDemographic;
const answers = sanitizeAnswerMap(
value?.answers as Record | undefined,
);
return {
respondent: uniqueRespondent,
demographic,
answers,
};
};
const sanitizeResponses = (value: unknown): SurveyResponse[] => {
if (!Array.isArray(value)) return [];
const used = new Set();
const sanitized: SurveyResponse[] = [];
for (let index = 0; index < value.length; index++) {
const input = value[index] as SurveyResponseInput | undefined;
sanitized.push(sanitizeResponseEntry(input, index + 1, used));
}
sanitized.sort((left, right) =>
left.respondent.localeCompare(right.respondent)
);
return sanitized;
};
const sanitizeQuestionList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const set = new Set();
for (const entry of value) {
const name = normalizeName(entry);
if (!name) continue;
set.add(name);
}
const list = Array.from(set);
list.sort((left, right) => left.localeCompare(right));
return list;
};
const cloneResponse = (response: SurveyResponse): SurveyResponse => ({
respondent: response.respondent,
demographic: response.demographic,
answers: { ...response.answers },
});
const buildQuestionCatalog = (input: {
provided: readonly string[] | undefined;
responses: readonly SurveyResponse[] | undefined;
}): string[] => {
const set = new Set();
if (Array.isArray(input.provided)) {
for (const question of input.provided) {
if (question.length > 0) set.add(question);
}
}
if (Array.isArray(input.responses)) {
for (const response of input.responses) {
for (const question of Object.keys(response.answers)) {
if (question.length > 0) set.add(question);
}
}
}
const list = Array.from(set);
list.sort((left, right) => left.localeCompare(right));
return list;
};
const buildDemographicCatalog = (
responses: readonly SurveyResponse[],
): string[] => {
const set = new Set();
for (const response of responses) {
if (response.demographic.length > 0) {
set.add(response.demographic);
}
}
if (set.size === 0) set.add(defaultDemographic);
const list = Array.from(set);
list.sort((left, right) => left.localeCompare(right));
return list;
};
const computeQuestionSummaries = (input: {
questions: readonly string[];
responses: readonly SurveyResponse[];
}): QuestionSummary[] => {
const questions = Array.isArray(input.questions) ? input.questions : [];
const responses = Array.isArray(input.responses) ? input.responses : [];
const summaries: QuestionSummary[] = [];
for (const question of questions) {
let total = 0;
let answered = 0;
for (const response of responses) {
const value = response.answers[question];
if (typeof value === "number") {
total += value;
answered += 1;
}
}
const average = answered > 0 ? roundAverage(total / answered) : 0;
summaries.push({ question, total, answered, average });
}
summaries.sort((left, right) => left.question.localeCompare(right.question));
return summaries;
};
const cloneQuestionSummary = (summary: QuestionSummary): QuestionSummary => ({
question: summary.question,
total: summary.total,
answered: summary.answered,
average: summary.average,
});
const computeDemographicSummaries = (input: {
demographics: readonly string[];
questions: readonly string[];
responses: readonly SurveyResponse[];
}): DemographicSummary[] => {
const demographics = Array.isArray(input.demographics)
? input.demographics
: [];
const questions = Array.isArray(input.questions) ? input.questions : [];
const responses = Array.isArray(input.responses) ? input.responses : [];
const summaries: DemographicSummary[] = [];
for (const demographic of demographics) {
const matching = responses.filter((entry) =>
entry.demographic === demographic
);
const questionTotals: Record =
{};
for (const question of questions) {
questionTotals[question] = { total: 0, answered: 0 };
}
for (const response of matching) {
for (const question of questions) {
const value = response.answers[question];
if (typeof value === "number") {
const bucket = questionTotals[question];
bucket.total += value;
bucket.answered += 1;
}
}
}
const questionAverages: Record = {};
let overallTotal = 0;
let overallAnswered = 0;
for (const question of questions) {
const bucket = questionTotals[question];
if (bucket.answered > 0) {
const average = roundAverage(bucket.total / bucket.answered);
questionAverages[question] = average;
overallTotal += bucket.total;
overallAnswered += bucket.answered;
} else {
questionAverages[question] = 0;
}
}
const overallAverage = overallAnswered > 0
? roundAverage(overallTotal / overallAnswered)
: 0;
summaries.push({
demographic,
responseCount: matching.length,
questionAverages,
overallAverage,
});
}
summaries.sort((left, right) =>
left.demographic.localeCompare(right.demographic)
);
return summaries;
};
const cloneDemographicSummary = (
summary: DemographicSummary,
): DemographicSummary => ({
demographic: summary.demographic,
responseCount: summary.responseCount,
questionAverages: { ...summary.questionAverages },
overallAverage: summary.overallAverage,
});
const buildQuestionAverageMap = (
summaries: readonly QuestionSummary[],
): Record => {
const record: Record = {};
for (const summary of summaries) {
record[summary.question] = summary.average;
}
return record;
};
const buildDemographicAverageMap = (
summaries: readonly DemographicSummary[],
): Record> => {
const record: Record> = {};
for (const summary of summaries) {
record[summary.demographic] = { ...summary.questionAverages };
}
return record;
};
const appendSurveyResponse = handler(
(
event: RecordResponseEvent | undefined,
context: {
store: Cell;
base: Cell;
sequence: Cell;
},
) => {
if (!event) return;
const storeValue = context.store.get();
const baseValue = context.base.get();
const baseline = Array.isArray(storeValue) && storeValue.length > 0
? storeValue
: Array.isArray(baseValue)
? baseValue
: [];
const used = new Set();
for (const entry of baseline) {
used.add(entry.respondent);
}
const nextIndex = (context.sequence.get() ?? baseline.length) + 1;
const sanitized = sanitizeResponseEntry(event, nextIndex, used);
const next = baseline.map(cloneResponse);
next.push(sanitized);
next.sort((left, right) => left.respondent.localeCompare(right.respondent));
context.store.set(next.map(cloneResponse));
context.sequence.set(nextIndex);
},
);
export const surveyResponseAnalyzer = recipe(
"Survey Response Analyzer",
({ responses, questions }) => {
const sanitizedArgumentResponses = lift(sanitizeResponses)(responses);
const sanitizedQuestionList = lift(sanitizeQuestionList)(questions);
const responseStore = cell([]);
const responseSequence = cell(0);
const normalizedResponses = lift((input: {
store: SurveyResponse[];
base: SurveyResponse[];
}) => {
const storeEntries = Array.isArray(input.store) ? input.store : [];
if (storeEntries.length > 0) {
return storeEntries.map(cloneResponse);
}
const baseEntries = Array.isArray(input.base) ? input.base : [];
return baseEntries.map(cloneResponse);
})({
store: responseStore,
base: sanitizedArgumentResponses,
});
const questionCatalog = lift((input: {
provided: readonly string[];
responses: readonly SurveyResponse[];
}) => buildQuestionCatalog(input))({
provided: sanitizedQuestionList,
responses: normalizedResponses,
});
const questionSummaries = lift((input: {
questions: readonly string[];
responses: readonly SurveyResponse[];
}) => computeQuestionSummaries(input))({
questions: questionCatalog,
responses: normalizedResponses,
});
const questionSummariesView = lift((summaries: QuestionSummary[]) =>
summaries.map(cloneQuestionSummary)
)(questionSummaries);
const questionAverageMap = lift(buildQuestionAverageMap)(
questionSummaries,
);
const questionAverageMapView = lift((record: Record) => ({
...record,
}))(questionAverageMap);
const demographicCatalog = lift(buildDemographicCatalog)(
normalizedResponses,
);
const demographicSummaries = lift((input: {
demographics: readonly string[];
questions: readonly string[];
responses: readonly SurveyResponse[];
}) => computeDemographicSummaries(input))({
demographics: demographicCatalog,
questions: questionCatalog,
responses: normalizedResponses,
});
const demographicSummariesView = lift((summaries: DemographicSummary[]) =>
summaries.map(cloneDemographicSummary)
)(demographicSummaries);
const demographicAverageMap = lift(buildDemographicAverageMap)(
demographicSummaries,
);
const demographicAverageMapView = lift((
record: Record<
string,
Record<
string,
number
>
>,
) => {
const copy: Record> = {};
for (const key of Object.keys(record)) {
copy[key] = { ...record[key] };
}
return copy;
})(demographicAverageMap);
const overallAverage = lift((summaries: QuestionSummary[]) => {
let total = 0;
let answered = 0;
for (const summary of summaries) {
total += summary.total;
answered += summary.answered;
}
return answered > 0 ? roundAverage(total / answered) : 0;
})(questionSummaries);
const overallAverageLabel = lift((value: number | undefined) => {
if (typeof value !== "number" || Number.isNaN(value)) {
return "0.00";
}
return value.toFixed(2);
})(overallAverage);
const responseCount = lift((entries: readonly SurveyResponse[]) =>
entries.length
)(normalizedResponses);
const questionCount = lift((entries: readonly string[]) => entries.length)(
questionCatalog,
);
const demographicCount = lift((entries: readonly string[]) =>
entries.length
)(demographicCatalog);
const summaryHead =
str`${responseCount} responses · ${questionCount} questions`;
const summary =
str`${summaryHead} · ${demographicCount} demographics · avg ${overallAverageLabel}`;
const responsesView = lift((entries: SurveyResponse[]) =>
entries.map(cloneResponse)
)(normalizedResponses);
return {
responses: responsesView,
questionCatalog,
demographicCatalog,
questionSummaries: questionSummariesView,
questionAverages: questionAverageMapView,
demographicSummaries: demographicSummariesView,
demographicAverages: demographicAverageMapView,
overallAverage,
overallAverageLabel,
summary,
responseCount,
questionCount,
demographicCount,
recordResponse: appendSurveyResponse({
store: responseStore,
base: sanitizedArgumentResponses,
sequence: responseSequence,
}),
};
},
);