/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; type MilestoneStatus = "planned" | "in_progress" | "completed"; interface JourneyMilestone { id: string; title: string; description: string; status: MilestoneStatus; dayOffset: number; durationDays: number; } interface JourneyTimelineEntry { id: string; title: string; status: MilestoneStatus; startDay: number; endDay: number; durationDays: number; } interface JourneyMapArgs { milestones: Default; anchorDay: Default; } interface JourneyUpdateEvent { id?: string; title?: string; description?: string; status?: MilestoneStatus | string; dayOffset?: number; durationDays?: number; } interface JourneyUpdateResult { list: JourneyMilestone[]; updated: JourneyMilestone; index: number; } type StatusCounts = Record; interface SummaryComputationInput { meta: { count: number; start: number; end: number }; progress: number; } const defaultMilestones: JourneyMilestone[] = [ { id: "discover", title: "Discovery", description: "User becomes aware of the product.", status: "completed", dayOffset: 0, durationDays: 1, }, { id: "activate", title: "Activation", description: "User signs up and begins onboarding.", status: "in_progress", dayOffset: 1, durationDays: 2, }, { id: "adopt", title: "Adoption", description: "User adopts core features into workflow.", status: "planned", dayOffset: 3, durationDays: 3, }, ]; const sanitizeText = (value: unknown, fallback: string): string => { if (typeof value !== "string") return fallback; const trimmed = value.trim(); if (trimmed.length === 0) return fallback; return trimmed.replace(/\s+/g, " "); }; const titleFromId = (id: string): string => { const normalized = id.replace(/[-_]+/g, " "); return normalized.replace(/\b\w/g, (char) => char.toUpperCase()); }; const sanitizeId = (value: unknown, fallback: string): string => { if (typeof value !== "string") return fallback; const trimmed = value.trim().toLowerCase(); if (trimmed.length === 0) return fallback; const cleaned = trimmed.replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-"); return cleaned.length > 0 ? cleaned : fallback; }; const parseStatus = (value: unknown): MilestoneStatus | null => { if (typeof value !== "string") return null; const normalized = value.trim().toLowerCase().replace(/\s+/g, "_"); if ( normalized === "planned" || normalized === "in_progress" || normalized === "completed" ) { return normalized as MilestoneStatus; } return null; }; const sanitizeStatus = (value: unknown): MilestoneStatus => { return parseStatus(value) ?? "planned"; }; const sanitizeOptionalStatus = ( value: unknown, ): MilestoneStatus | undefined => { return parseStatus(value) ?? undefined; }; const sanitizeDayOffset = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value)) return 0; const rounded = Math.floor(value); return rounded < 0 ? 0 : rounded; }; const sanitizeOptionalDayOffset = (value: unknown): number | undefined => { if (value === undefined) return undefined; return sanitizeDayOffset(value); }; const sanitizeDuration = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value)) return 1; const rounded = Math.max(1, Math.floor(value)); return rounded; }; const sanitizeOptionalDuration = (value: unknown): number | undefined => { if (value === undefined) return undefined; return sanitizeDuration(value); }; const sanitizeOptionalTitle = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; const trimmed = value.trim(); if (trimmed.length === 0) return undefined; return trimmed.replace(/\s+/g, " "); }; const sanitizeOptionalDescription = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; return value.trim(); }; const ensureUniqueId = ( candidate: string, existing: readonly JourneyMilestone[], ): string => { if (!existing.some((entry) => entry.id === candidate)) { return candidate; } let index = 2; while (existing.some((entry) => entry.id === `${candidate}-${index}`)) { index += 1; } return `${candidate}-${index}`; }; const sanitizeMilestone = ( input: unknown, fallbackIndex: number, ): JourneyMilestone => { const record = typeof input === "object" && input !== null ? input as Record : {}; const fallbackId = `milestone-${fallbackIndex}`; const id = sanitizeId(record["id"], fallbackId); const title = sanitizeText(record["title"], titleFromId(id)); const description = sanitizeText( record["description"], "Milestone description", ); const status = sanitizeStatus(record["status"]); const dayOffset = sanitizeDayOffset(record["dayOffset"]); const durationDays = sanitizeDuration(record["durationDays"]); return { id, title, description, status, dayOffset, durationDays }; }; const sanitizeMilestoneList = ( input: unknown, ): JourneyMilestone[] => { if (!Array.isArray(input) || input.length === 0) { return structuredClone(defaultMilestones); } const sanitized: JourneyMilestone[] = []; let index = 1; for (const entry of input) { const cleaned = sanitizeMilestone(entry, index); const uniqueId = ensureUniqueId(cleaned.id, sanitized); sanitized.push( uniqueId === cleaned.id ? cleaned : { ...cleaned, id: uniqueId }, ); index += 1; } sanitized.sort((left, right) => { if (left.dayOffset === right.dayOffset) { return left.id.localeCompare(right.id); } return left.dayOffset - right.dayOffset; }); return sanitized; }; const buildTimeline = ( entries: readonly JourneyMilestone[], anchorDay: number, ): JourneyTimelineEntry[] => { const result: JourneyTimelineEntry[] = []; const base = Math.max(0, Math.floor(anchorDay)); let cursor = base; for (const entry of entries) { const plannedStart = base + entry.dayOffset; const start = plannedStart > cursor ? plannedStart : cursor; const duration = Math.max(1, entry.durationDays); const end = start + duration; result.push({ id: entry.id, title: entry.title, status: entry.status, startDay: start, endDay: end, durationDays: duration, }); cursor = end; } return result; }; const applyJourneyUpdate = ( list: JourneyMilestone[], event: JourneyUpdateEvent | undefined, ): JourneyUpdateResult => { const entries = [...list]; if (!event) { const fallback = entries[entries.length - 1] ?? entries[0] ?? structuredClone(defaultMilestones[0]); return { list: entries, updated: fallback, index: entries.length - 1 }; } const fallbackId = `milestone-${entries.length + 1}`; const candidateId = sanitizeId(event.id, fallbackId); let id = candidateId; const existingIndex = entries.findIndex((entry) => entry.id === id); if (existingIndex === -1) { id = ensureUniqueId(id, entries); } const nextStatus = sanitizeOptionalStatus(event.status); const nextOffset = sanitizeOptionalDayOffset( Object.hasOwn(event, "dayOffset") ? event.dayOffset : undefined, ); const nextDuration = sanitizeOptionalDuration( Object.hasOwn(event, "durationDays") ? event.durationDays : undefined, ); const nextTitle = sanitizeOptionalTitle( Object.hasOwn(event, "title") ? event.title : undefined, ); const nextDescription = sanitizeOptionalDescription( Object.hasOwn(event, "description") ? event.description : undefined, ); if (existingIndex >= 0) { const current = entries[existingIndex]; const updated: JourneyMilestone = { ...current, title: nextTitle ?? current.title, description: nextDescription ?? current.description, status: nextStatus ?? current.status, dayOffset: nextOffset ?? current.dayOffset, durationDays: nextDuration ?? current.durationDays, }; entries[existingIndex] = updated; } else { const previous = entries[entries.length - 1]; const defaultOffset = previous ? previous.dayOffset + previous.durationDays : 0; const newEntry: JourneyMilestone = { id, title: nextTitle ?? titleFromId(id), description: nextDescription ?? "", status: nextStatus ?? "planned", dayOffset: nextOffset ?? defaultOffset, durationDays: nextDuration ?? 1, }; entries.push(newEntry); } entries.sort((left, right) => { if (left.dayOffset === right.dayOffset) { return left.id.localeCompare(right.id); } return left.dayOffset - right.dayOffset; }); const index = entries.findIndex((entry) => entry.id === id); const updated = entries[index]; return { list: entries, updated, index }; }; const sanitizeAnchor = (value: number | undefined): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return 0; } return Math.max(0, Math.floor(value)); }; export const userJourneyMap = recipe( "User Journey Map", ({ milestones, anchorDay }) => { const changeLog = cell([]); const sequence = cell(0); const milestonesView = lift(sanitizeMilestoneList)(milestones); const anchorView = lift((value: number | undefined) => sanitizeAnchor(value) )(anchorDay); const timeline = lift( ( input: { entries: JourneyMilestone[]; anchor: number }, ): JourneyTimelineEntry[] => { return buildTimeline(input.entries, input.anchor); }, )({ entries: milestonesView, anchor: anchorView }); const statusCounts = lift( (entries: JourneyTimelineEntry[]): StatusCounts => { const counts: StatusCounts = { planned: 0, in_progress: 0, completed: 0, }; for (const entry of entries) { counts[entry.status] += 1; } return counts; }, )(timeline); const progress = lift((counts: StatusCounts) => { const total = counts.planned + counts.in_progress + counts.completed; if (total === 0) return 0; return Math.round((counts.completed / total) * 100); })(statusCounts); const summaryMeta = lift((entries: JourneyTimelineEntry[]) => { if (entries.length === 0) { return { count: 0, start: 0, end: 0 }; } const first = entries[0]; const last = entries[entries.length - 1]; return { count: entries.length, start: first.startDay, end: last.endDay }; })(timeline); const summaryLabel = lift((input: SummaryComputationInput) => { if (input.meta.count === 0) { return "No milestones scheduled"; } const { count, start, end } = input.meta; const span = `${count} milestones from day ${start}`; const completion = ` (${input.progress}% complete)`; return `${span} to day ${end}${completion}`; })({ meta: summaryMeta, progress }); const changeLogView = lift((entries: string[] | undefined) => Array.isArray(entries) ? entries : [] )(changeLog); const updateJourney = handler( ( event: JourneyUpdateEvent | undefined, context: { milestones: Cell; changeLog: Cell; sequence: Cell; anchor: Cell; }, ) => { const current = sanitizeMilestoneList(context.milestones.get()); const result = applyJourneyUpdate(current, event); context.milestones.set(result.list); const anchorValue = sanitizeAnchor(context.anchor.get()); const timelineEntries = buildTimeline(result.list, anchorValue); const entry = timelineEntries[result.index] ?? timelineEntries[0]; const existingLog = context.changeLog.get(); const log = Array.isArray(existingLog) ? existingLog : []; const label = `${entry.title}:${entry.startDay}-${entry.endDay}:` + `${entry.status}`; context.changeLog.set([...log, label]); const sequenceValue = (context.sequence.get() ?? 0) + 1; context.sequence.set(sequenceValue); }, ); return { anchorDay: anchorView, milestones: milestonesView, timeline, statusCounts, progress, label: str`Journey timeline: ${summaryLabel}`, changeLog: changeLogView, updateMilestone: updateJourney({ milestones, changeLog, sequence, anchor: anchorDay, }), }; }, );