/// import { type Cell, Default, handler, lift, recipe, str } from "commontools"; interface MilestoneInput { label?: string; weight?: number; completed?: boolean; } type MilestoneInputRecord = Record; interface MilestoneState { label: string; weight: number; completed: boolean; } type MilestoneRecord = Record; interface TotalsSnapshot { total: number; completed: number; remaining: number; percent: number; } interface CompletionEvent { id?: string; completed?: boolean; } interface ReweightEvent { id?: string; weight?: number; delta?: number; } const defaultMilestones: MilestoneInputRecord = { kickoff: { label: "Kickoff review", weight: 30, completed: true }, design: { label: "Design lock", weight: 40, completed: false }, launch: { label: "Launch readiness", weight: 30, completed: false }, }; interface GoalProgressArgs { milestones: Default; } const roundToTwoDecimals = (value: number): number => { return Math.round(value * 100) / 100; }; const roundToOneDecimal = (value: number): number => { return Math.round(value * 10) / 10; }; const sanitizeWeight = (value: unknown, fallback: number): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return roundToTwoDecimals(Math.max(0, fallback)); } return roundToTwoDecimals(Math.max(0, value)); }; const sanitizeKey = (raw: string, fallback: string): string => { const trimmed = raw.trim(); return trimmed.length > 0 ? trimmed : fallback; }; const fallbackLabelFromKey = (key: string): string => { const parts = key.split(/[-_ ]+/).filter((part) => part.length > 0); if (parts.length === 0) return "Milestone"; return parts .map((part) => part[0].toUpperCase() + part.slice(1)) .join(" "); }; const sanitizeMilestone = ( value: MilestoneInput | undefined, fallbackLabel: string, ): MilestoneState => { const label = typeof value?.label === "string" && value.label.trim().length > 0 ? value.label.trim() : fallbackLabel; const weight = sanitizeWeight(value?.weight, 1); const completed = typeof value?.completed === "boolean" ? value.completed : false; return { label, weight, completed }; }; const sanitizeMilestoneMap = (value: unknown): MilestoneRecord => { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } const rawEntries = Object.entries(value as Record); rawEntries.sort((left, right) => left[0].localeCompare(right[0])); const used = new Set(); const result: MilestoneRecord = {}; for (let index = 0; index < rawEntries.length; index += 1) { const [rawKey, rawValue] = rawEntries[index]; const fallbackKey = `milestone-${index + 1}`; let key = sanitizeKey(rawKey, fallbackKey); if (used.has(key)) { let suffix = 2; while (used.has(`${key}-${suffix}`)) { suffix += 1; } key = `${key}-${suffix}`; } used.add(key); const label = fallbackLabelFromKey(key); const entry = sanitizeMilestone( rawValue as MilestoneInput | undefined, label, ); result[key] = entry; } return result; }; const normalizeEventId = (input: unknown): string | undefined => { if (typeof input !== "string") return undefined; const trimmed = input.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const updateMilestoneCompletion = handler( ( event: CompletionEvent | undefined, context: { milestones: Cell }, ) => { const id = normalizeEventId(event?.id); if (!id) return; const current = sanitizeMilestoneMap(context.milestones.get()); const target = current[id]; if (!target) return; const nextCompleted = typeof event?.completed === "boolean" ? event.completed : !target.completed; const updated: MilestoneInputRecord = { ...current }; updated[id] = { ...target, completed: nextCompleted }; context.milestones.set(updated); }, ); const adjustMilestoneWeight = handler( ( event: ReweightEvent | undefined, context: { milestones: Cell }, ) => { const id = normalizeEventId(event?.id); if (!id) return; const current = sanitizeMilestoneMap(context.milestones.get()); const target = current[id]; if (!target) return; const hasWeight = typeof event?.weight === "number" && Number.isFinite(event.weight); const hasDelta = typeof event?.delta === "number" && Number.isFinite(event.delta); if (!hasWeight && !hasDelta) return; const nextWeight = hasWeight ? sanitizeWeight(event?.weight, target.weight) : sanitizeWeight(target.weight + (event?.delta ?? 0), target.weight); const updated: MilestoneInputRecord = { ...current }; updated[id] = { ...target, weight: nextWeight }; context.milestones.set(updated); }, ); export const goalProgressTracker = recipe( "Goal Progress Tracker", ({ milestones }) => { const sanitized = lift(sanitizeMilestoneMap)(milestones); const totals = lift((records: MilestoneRecord): TotalsSnapshot => { const entries = Object.values(records); let total = 0; let completed = 0; for (const entry of entries) { total += entry.weight; if (entry.completed) { completed += entry.weight; } } const roundedTotal = roundToTwoDecimals(total); const roundedCompleted = roundToTwoDecimals(completed); const remaining = roundToTwoDecimals(roundedTotal - roundedCompleted); const percent = roundedTotal === 0 ? 0 : roundToOneDecimal((roundedCompleted / roundedTotal) * 100); return { total: roundedTotal, completed: roundedCompleted, remaining, percent, }; })(sanitized); const totalWeight = lift((snapshot: TotalsSnapshot) => snapshot.total)( totals, ); const completedWeight = lift((snapshot: TotalsSnapshot) => snapshot.completed )( totals, ); const remainingWeight = lift((snapshot: TotalsSnapshot) => snapshot.remaining )( totals, ); const completionPercent = lift((snapshot: TotalsSnapshot) => snapshot.percent )( totals, ); const milestoneList = lift((inputs: { records: MilestoneRecord; total: number; }) => { const entries = Object.entries(inputs.records).map(([id, data]) => { const percentOfTotal = inputs.total === 0 ? 0 : roundToOneDecimal((data.weight / inputs.total) * 100); const completedShare = data.completed ? percentOfTotal : 0; return { id, label: data.label, weight: data.weight, completed: data.completed, percentOfTotal, completedShare, }; }); entries.sort((left, right) => left.label.localeCompare(right.label)); return entries; })({ records: sanitized, total: totalWeight, }); const formattedPercent = lift((value: number) => value.toFixed(1))( completionPercent, ); const summary = str`${formattedPercent}% complete (${completedWeight}/${totalWeight})`; return { milestones: sanitized, milestoneList, totalWeight, completedWeight, remainingWeight, completionPercent, summary, complete: updateMilestoneCompletion({ milestones }), reweight: adjustMilestoneWeight({ milestones }), }; }, );