///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
interface CourseModule {
id: string;
title: string;
durationWeeks: number;
}
interface TimelineEntry {
id: string;
title: string;
startWeek: number;
endWeek: number;
}
interface ReorderEvent {
from?: number;
to?: number;
}
const defaultModules: CourseModule[] = [
{ id: "orientation", title: "Orientation", durationWeeks: 1 },
{ id: "foundations", title: "Core Foundations", durationWeeks: 2 },
{ id: "project", title: "Capstone Project", durationWeeks: 3 },
];
const sanitizeText = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const sanitizeDuration = (value: unknown): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 1;
const integer = Math.round(value);
return integer > 0 ? integer : 1;
};
const buildModuleId = (value: string): string => {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
};
const sanitizeModule = (
module: CourseModule | undefined,
): CourseModule | null => {
const title = sanitizeText(module?.title);
if (!title) return null;
const idSource = sanitizeText(module?.id) ?? title;
const id = buildModuleId(idSource);
if (id.length === 0) return null;
const durationWeeks = sanitizeDuration(module?.durationWeeks);
return { id, title, durationWeeks };
};
const sanitizeModules = (
value: readonly CourseModule[] | undefined,
): CourseModule[] => {
if (!Array.isArray(value)) return structuredClone(defaultModules);
const seen = new Set();
const sanitized: CourseModule[] = [];
for (const item of value) {
const entry = sanitizeModule(item);
if (!entry || seen.has(entry.id)) continue;
seen.add(entry.id);
sanitized.push(entry);
}
return sanitized.length > 0 ? sanitized : structuredClone(defaultModules);
};
const sanitizeStartWeek = (value: unknown): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 1;
const rounded = Math.round(value);
return rounded > 0 ? rounded : 1;
};
const buildTimeline = (
modules: readonly CourseModule[],
startWeek: number,
): TimelineEntry[] => {
const result: TimelineEntry[] = [];
let currentWeek = startWeek;
for (const module of modules) {
const duration = sanitizeDuration(module.durationWeeks);
const start = currentWeek;
const end = currentWeek + duration - 1;
result.push({
id: module.id,
title: module.title,
startWeek: start,
endWeek: end,
});
currentWeek = end + 1;
}
return result;
};
const formatTimeline = (entries: readonly TimelineEntry[]): string => {
if (entries.length === 0) return "No modules scheduled";
const segments = entries.map((entry) =>
`${entry.title} (W${entry.startWeek}-W${entry.endWeek})`
);
return segments.join(" → ");
};
const clampIndex = (value: unknown, length: number): number => {
if (length <= 0) return 0;
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
const index = Math.round(value);
if (index < 0) return 0;
if (index >= length) return length - 1;
return index;
};
interface EducationCoursePlannerArgs {
modules: Default;
startWeek: Default;
}
export const educationCoursePlanner = recipe(
"Education Course Planner",
({ modules, startWeek }) => {
const reorderCount = cell(0);
const lastAction = cell("initialized");
const modulesView = lift(sanitizeModules)(modules);
const startWeekView = lift(sanitizeStartWeek)(startWeek);
const timeline = lift((inputs: {
modules: CourseModule[];
start: number;
}) => buildTimeline(inputs.modules, inputs.start))({
modules: modulesView,
start: startWeekView,
});
const totalDuration = lift((entries: CourseModule[] | undefined) => {
if (!Array.isArray(entries)) return 0;
return entries.reduce(
(sum, entry) => sum + sanitizeDuration(entry.durationWeeks),
0,
);
})(modulesView);
const moduleOrder = lift((entries: CourseModule[] | undefined) => {
if (!Array.isArray(entries)) return [] as string[];
return entries.map((entry) => entry.id);
})(modulesView);
const reorderCountView = lift((count: number | undefined) => {
if (typeof count !== "number" || !Number.isFinite(count)) return 0;
return Math.max(0, Math.trunc(count));
})(reorderCount);
const timelineSummary = lift((entries: TimelineEntry[] | undefined) =>
Array.isArray(entries) ? formatTimeline(entries) : "No modules scheduled"
)(timeline);
const label = str`Course timeline: ${timelineSummary}`;
const context = {
modules,
reorderCount,
lastAction,
} as const;
const reorderModules = handler(
(
event: ReorderEvent | undefined,
manager: {
modules: Cell;
reorderCount: Cell;
lastAction: Cell;
},
) => {
const currentModules = sanitizeModules(manager.modules.get());
if (currentModules.length < 2) {
return;
}
const from = clampIndex(event?.from, currentModules.length);
const to = clampIndex(event?.to, currentModules.length);
if (from === to) return;
const updated = currentModules.slice();
const [moved] = updated.splice(from, 1);
updated.splice(to, 0, moved);
manager.modules.set(updated);
const count = (manager.reorderCount.get() ?? 0) + 1;
manager.reorderCount.set(count);
const position = to + 1;
manager.lastAction.set(`Moved ${moved.title} to position ${position}`);
},
);
return {
modules,
startWeek,
modulesView,
moduleOrder,
timeline,
timelineSummary,
totalDuration,
label,
reorderCount: reorderCountView,
lastAction,
reorder: reorderModules(context as never),
};
},
);