/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; type TrendDirection = "steady" | "rising" | "falling"; interface SatisfactionSampleInput { id?: string; date?: string; score?: number; responses?: number; channel?: string; } interface SatisfactionEntry extends SatisfactionSampleInput { id: string; date: string; score: number; responses: number; channel: string; } interface DailySummaryInternal { date: string; weightedSum: number; responseCount: number; average: number; } interface DailySummary { date: string; average: number; responseCount: number; } interface MovingAveragePoint { date: string; dailyAverage: number; trailing3: number; trailing7: number; } interface CustomerSatisfactionArgs { responses: Default; } const defaultResponses: SatisfactionSampleInput[] = [ { id: "seed-1", date: "2024-05-01", score: 4.3, responses: 26, channel: "Email", }, { id: "seed-2", date: "2024-05-02", score: 3.9, responses: 18, channel: "Chat", }, { id: "seed-3", date: "2024-05-03", score: 4.6, responses: 22, channel: "In-App", }, ]; const roundToTwo = (value: number): number => { return Math.round(value * 100) / 100; }; const sanitizeDate = (value: unknown): string => { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) { const parsed = new Date(trimmed); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString().slice(0, 10); } if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { return trimmed; } } } if (value instanceof Date && !Number.isNaN(value.getTime())) { return value.toISOString().slice(0, 10); } return "1970-01-01"; }; const sanitizeScore = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return 3; } const clamped = Math.min(5, Math.max(1, value)); return roundToTwo(clamped); }; const sanitizeResponses = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return 1; } const rounded = Math.round(value); return Math.max(1, rounded); }; const sanitizeChannel = (value: unknown): string => { if (typeof value !== "string") return "general"; const trimmed = value.trim().toLowerCase(); if (trimmed.length === 0) return "general"; return trimmed.replace(/\s+/g, "-"); }; const sanitizeId = ( value: unknown, fallback: string, date: string, channel: string, ): string => { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) return trimmed; } const normalizedChannel = channel.length > 0 ? channel : "general"; return `${date}-${normalizedChannel}-${fallback}`; }; const sanitizeEntry = ( seed: SatisfactionSampleInput | undefined, fallback: string, ): SatisfactionEntry => { const date = sanitizeDate(seed?.date); const channel = sanitizeChannel(seed?.channel); const score = sanitizeScore(seed?.score); const responses = sanitizeResponses(seed?.responses); const id = sanitizeId(seed?.id, fallback, date, channel); return { id, date, score, responses, channel, }; }; const sortEntries = ( left: SatisfactionEntry, right: SatisfactionEntry, ): number => { if (left.date !== right.date) { return left.date.localeCompare(right.date); } return left.id.localeCompare(right.id); }; const normalizeEntries = ( entries: readonly SatisfactionEntry[], ): SatisfactionEntry[] => { const unique = new Map(); for (const entry of entries) { unique.set(entry.id, entry); } return Array.from(unique.values()).sort(sortEntries); }; const sanitizeEntryList = (value: unknown): SatisfactionEntry[] => { if (!Array.isArray(value)) return []; const collected = value.map((raw, index) => sanitizeEntry( raw as SatisfactionSampleInput | undefined, `seed-${index + 1}`, ) ); return normalizeEntries(collected); }; const computeDailySummaries = ( entries: readonly SatisfactionEntry[], ): DailySummaryInternal[] => { const totals = new Map(); for (const entry of entries) { const bucket = totals.get(entry.date) ?? { weighted: 0, responses: 0 }; bucket.weighted += entry.score * entry.responses; bucket.responses += entry.responses; totals.set(entry.date, bucket); } const summaries: DailySummaryInternal[] = []; for (const [date, bucket] of totals.entries()) { const average = bucket.responses === 0 ? 0 : bucket.weighted / bucket.responses; summaries.push({ date, weightedSum: bucket.weighted, responseCount: bucket.responses, average: roundToTwo(average), }); } summaries.sort((left, right) => left.date.localeCompare(right.date)); return summaries; }; const projectDailySummaries = ( summaries: readonly DailySummaryInternal[], ): DailySummary[] => { return summaries.map((summary) => ({ date: summary.date, average: summary.average, responseCount: summary.responseCount, })); }; const computeWindowAverage = ( summaries: readonly DailySummaryInternal[], index: number, windowSize: number, ): number => { let weighted = 0; let responses = 0; for (let offset = 0; offset < windowSize; offset += 1) { const position = index - offset; if (position < 0) break; const summary = summaries[position]; weighted += summary.weightedSum; responses += summary.responseCount; } if (responses === 0) return 0; return roundToTwo(weighted / responses); }; const computeMovingSeries = ( summaries: readonly DailySummaryInternal[], ): MovingAveragePoint[] => { return summaries.map((summary, index) => ({ date: summary.date, dailyAverage: summary.average, trailing3: computeWindowAverage(summaries, index, 3), trailing7: computeWindowAverage(summaries, index, 7), })); }; const computeOverallAverage = ( summaries: readonly DailySummaryInternal[], ): number => { let weighted = 0; let responses = 0; for (const summary of summaries) { weighted += summary.weightedSum; responses += summary.responseCount; } if (responses === 0) return 0; return roundToTwo(weighted / responses); }; const computeChannelAverages = ( entries: readonly SatisfactionEntry[], ): Record => { const totals = new Map(); for (const entry of entries) { const bucket = totals.get(entry.channel) ?? { weighted: 0, responses: 0 }; bucket.weighted += entry.score * entry.responses; bucket.responses += entry.responses; totals.set(entry.channel, bucket); } const sortedChannels = Array.from(totals.entries()) .sort((left, right) => left[0].localeCompare(right[0])); const record: Record = {}; for (const [channel, bucket] of sortedChannels) { record[channel] = bucket.responses === 0 ? 0 : roundToTwo(bucket.weighted / bucket.responses); } return record; }; const determineTrend = ( series: readonly MovingAveragePoint[], ): TrendDirection => { if (series.length < 2) return "steady"; const latest = series[series.length - 1]; const previous = series[series.length - 2]; const delta = latest.trailing3 - previous.trailing3; if (delta > 0.05) return "rising"; if (delta < -0.05) return "falling"; return "steady"; }; const logSurveyResponse = handler( ( event: SatisfactionSampleInput | undefined, context: { responses: Cell; runtimeSeed: Cell; }, ) => { const existing = sanitizeEntryList(context.responses.get()); const currentSeed = context.runtimeSeed.get() ?? 0; const candidate = sanitizeEntry(event, `runtime-${currentSeed + 1}`); const updated = normalizeEntries([...existing, candidate]); context.responses.set(updated); context.runtimeSeed.set(currentSeed + 1); }, ); export const customerSatisfactionTracker = recipe( "Customer Satisfaction Tracker", ({ responses }) => { const runtimeSeed = cell(0); const responseLog = lift(( value: readonly SatisfactionSampleInput[] | undefined, ) => sanitizeEntryList(value))(responses); const dailySummaryInternal = lift((entries: readonly SatisfactionEntry[]) => computeDailySummaries(entries) )(responseLog); const dailySummaries = lift((summaries: readonly DailySummaryInternal[]) => projectDailySummaries(summaries) )(dailySummaryInternal); const movingAverages = lift((summaries: readonly DailySummaryInternal[]) => computeMovingSeries(summaries) )(dailySummaryInternal); const overallAverage = lift((summaries: readonly DailySummaryInternal[]) => computeOverallAverage(summaries) )(dailySummaryInternal); const overallAverageLabel = lift((value: number) => value.toFixed(2))( overallAverage, ); const responseCount = lift((entries: readonly SatisfactionEntry[]) => entries.reduce((sum, entry) => sum + entry.responses, 0) )(responseLog); const dayCount = lift((summaries: readonly DailySummaryInternal[]) => summaries.length )(dailySummaryInternal); const trendDirection = lift((series: readonly MovingAveragePoint[]) => determineTrend(series) )(movingAverages); const channelAverages = lift((entries: readonly SatisfactionEntry[]) => computeChannelAverages(entries) )(responseLog); const summary = str`${responseCount} responses across ${dayCount} days avg ${overallAverageLabel} trend ${trendDirection}`; return { responseLog, dailySummaries, movingAverages, overallAverage, overallAverageLabel, responseCount, dayCount, trendDirection, channelAverages, summary, recordResponse: logSurveyResponse({ responses, runtimeSeed }), }; }, ); export type { DailySummary, MovingAveragePoint, SatisfactionEntry, TrendDirection, };