///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
interface SlotInput {
id?: string;
label?: string;
requiredAgents?: number;
}
interface SlotDefinition {
id: string;
label: string;
requiredAgents: number;
}
interface AgentInput {
id?: string;
name?: string;
}
interface AgentDefinition {
id: string;
name: string;
}
interface AssignmentInput {
slot?: string;
agent?: string;
}
interface AssignmentRecord {
slot: string;
agent: string;
}
interface SlotCoverage {
slot: string;
label: string;
required: number;
assigned: string[];
assignedCount: number;
remaining: number;
hasGap: boolean;
}
interface ScheduleEvent {
slot?: string;
agent?: string;
action?: "assign" | "unschedule";
}
interface LatestChange {
sequence: number;
slot: string;
label: string;
action: "assign" | "unschedule";
agentId: string;
agentName: string;
gapCount: number;
remaining: number;
}
interface CallCenterScheduleArgs {
slots: Default;
agents: Default;
assignments: Default;
}
const defaultSlots: SlotDefinition[] = [
{ id: "08:00-10:00", label: "Morning Block", requiredAgents: 1 },
{ id: "10:00-12:00", label: "Midday Block", requiredAgents: 1 },
{ id: "12:00-14:00", label: "Lunch Block", requiredAgents: 1 },
{ id: "14:00-16:00", label: "Afternoon Block", requiredAgents: 1 },
];
const defaultAgents: AgentDefinition[] = [
{ id: "alex-rivera", name: "Alex Rivera" },
{ id: "blair-chen", name: "Blair Chen" },
{ id: "casey-james", name: "Casey James" },
{ id: "drew-patel", name: "Drew Patel" },
];
const defaultAssignments: AssignmentRecord[] = [
{ slot: "08:00-10:00", agent: "alex-rivera" },
{ slot: "12:00-14:00", agent: "blair-chen" },
{ slot: "14:00-16:00", agent: "casey-james" },
];
const slugify = (value: string): string => {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
};
const normalizeLabel = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) return trimmed;
}
return fallback;
};
const normalizeSlotId = (
value: unknown,
fallback: string,
): string | null => {
if (typeof value === "string") {
const trimmed = value.trim();
if (/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(trimmed)) {
return trimmed;
}
}
if (typeof fallback === "string" && fallback.length > 0) {
return fallback;
}
return null;
};
const normalizeRequired = (value: unknown, fallback: number): number => {
if (typeof value === "number" && Number.isFinite(value)) {
const normalized = Math.floor(value);
return normalized > 0 ? normalized : fallback;
}
return fallback;
};
const ensureUnique = (value: string, used: Set): string => {
let candidate = value;
let suffix = 2;
while (used.has(candidate)) {
candidate = `${value}-${suffix}`;
suffix += 1;
}
used.add(candidate);
return candidate;
};
const sanitizeSlots = (value: unknown): SlotDefinition[] => {
if (!Array.isArray(value) || value.length === 0) {
return structuredClone(defaultSlots);
}
const used = new Set();
const sanitized: SlotDefinition[] = [];
for (let index = 0; index < value.length; index++) {
const entry = value[index] as SlotInput | undefined;
const fallback = defaultSlots[index] ?? defaultSlots[0];
const label = normalizeLabel(entry?.label, fallback.label);
const idCandidate = entry?.id ?? label;
const slotId = normalizeSlotId(idCandidate, fallback.id ?? label);
if (!slotId) continue;
const required = normalizeRequired(
entry?.requiredAgents,
fallback.requiredAgents,
);
const uniqueId = ensureUnique(slotId, used);
sanitized.push({ id: uniqueId, label, requiredAgents: required });
}
if (sanitized.length === 0) {
return structuredClone(defaultSlots);
}
return sanitized;
};
const normalizeAgentName = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) return trimmed;
}
return fallback;
};
const sanitizeAgents = (value: unknown): AgentDefinition[] => {
if (!Array.isArray(value) || value.length === 0) {
return structuredClone(defaultAgents);
}
const used = new Set();
const sanitized: AgentDefinition[] = [];
for (let index = 0; index < value.length; index++) {
const entry = value[index] as AgentInput | undefined;
const fallback = defaultAgents[index] ?? defaultAgents[0];
const name = normalizeAgentName(entry?.name, fallback.name);
const rawId = typeof entry?.id === "string" ? entry.id.trim() : "";
const id = ensureUnique(
slugify(rawId.length > 0 ? rawId : name),
used,
);
sanitized.push({ id, name });
}
if (sanitized.length === 0) {
return structuredClone(defaultAgents);
}
return sanitized;
};
const resolveSlotId = (
slots: readonly SlotDefinition[],
value: unknown,
): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length === 0) return null;
const byId = slots.find((entry) => entry.id === trimmed);
if (byId) return byId.id;
const normalized = trimmed.toLowerCase();
const byLabel = slots.find((entry) =>
entry.label.toLowerCase() === normalized
);
return byLabel?.id ?? null;
};
const resolveAgentId = (
agents: readonly AgentDefinition[],
value: unknown,
): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length === 0) return null;
const byId = agents.find((entry) => entry.id === trimmed);
if (byId) return byId.id;
const normalized = trimmed.toLowerCase();
const byName = agents.find((entry) =>
entry.name.toLowerCase() === normalized
);
return byName?.id ?? null;
};
const produceAssignments = (
entries: readonly AssignmentInput[] | undefined,
slots: readonly SlotDefinition[],
agents: readonly AgentDefinition[],
): AssignmentRecord[] => {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
const order = new Map();
slots.forEach((slot, index) => order.set(slot.id, index));
const agentOrder = new Map();
agents.forEach((agent, index) => agentOrder.set(agent.id, index));
const seen = new Set();
const sanitized: AssignmentRecord[] = [];
for (const entry of entries) {
const slotId = resolveSlotId(slots, entry?.slot);
const agentId = resolveAgentId(agents, entry?.agent);
if (!slotId || !agentId) continue;
const key = `${slotId}::${agentId}`;
if (seen.has(key)) continue;
sanitized.push({ slot: slotId, agent: agentId });
seen.add(key);
}
sanitized.sort((left, right) => {
const slotDiff = (order.get(left.slot) ?? 0) - (order.get(right.slot) ?? 0);
if (slotDiff !== 0) return slotDiff;
return (agentOrder.get(left.agent) ?? 0) -
(agentOrder.get(right.agent) ?? 0);
});
return sanitized;
};
const buildCoverageEntries = (
slots: readonly SlotDefinition[],
assignments: readonly AssignmentRecord[],
agents: readonly AgentDefinition[],
): SlotCoverage[] => {
const agentNames = new Map();
for (const agent of agents) {
agentNames.set(agent.id, agent.name);
}
const slotMap = new Map();
for (const slot of slots) {
slotMap.set(slot.id, {
slot: slot.id,
label: slot.label,
required: slot.requiredAgents,
assigned: [],
assignedCount: 0,
remaining: slot.requiredAgents,
hasGap: slot.requiredAgents > 0,
});
}
for (const record of assignments) {
const coverage = slotMap.get(record.slot);
if (!coverage) continue;
const name = agentNames.get(record.agent) ?? record.agent;
coverage.assigned.push(name);
}
const coverageList: SlotCoverage[] = [];
for (const slot of slots) {
const coverage = slotMap.get(slot.id);
if (!coverage) continue;
coverage.assignedCount = coverage.assigned.length;
const remaining = slot.requiredAgents - coverage.assignedCount;
coverage.remaining = remaining > 0 ? remaining : 0;
coverage.hasGap = coverage.remaining > 0;
coverageList.push({ ...coverage, assigned: [...coverage.assigned] });
}
return coverageList;
};
const updateSchedule = handler(
(
event: ScheduleEvent | undefined,
context: {
assignments: Cell;
baseSchedule: Cell;
slots: Cell;
agents: Cell;
history: Cell;
latestChange: Cell;
sequence: Cell;
},
) => {
const slots = context.slots.get() ?? [];
const agents = context.agents.get() ?? [];
if (slots.length === 0 || agents.length === 0) return;
const slotId = resolveSlotId(slots, event?.slot);
if (!slotId) return;
const action = event?.action === "unschedule" ? "unschedule" : "assign";
const storedRecords = context.assignments.get();
const baseRecords = context.baseSchedule.get();
const current = Array.isArray(storedRecords) && storedRecords.length > 0
? [...storedRecords]
: Array.isArray(baseRecords)
? [...baseRecords]
: [];
const slotOrder = new Map();
slots.forEach((slot, index) => slotOrder.set(slot.id, index));
const agentOrder = new Map();
agents.forEach((agent, index) => agentOrder.set(agent.id, index));
let agentId = resolveAgentId(agents, event?.agent);
if (action === "assign") {
if (!agentId) return;
if (
current.some((entry) =>
entry.slot === slotId && entry.agent === agentId
)
) {
return;
}
current.push({ slot: slotId, agent: agentId });
} else {
if (agentId) {
const index = current.findIndex((entry) =>
entry.slot === slotId && entry.agent === agentId
);
if (index === -1) return;
current.splice(index, 1);
} else {
const index = current.findIndex((entry) => entry.slot === slotId);
if (index === -1) return;
const removed = current.splice(index, 1)[0];
agentId = removed.agent;
}
}
current.sort((left, right) => {
const slotDiff = (slotOrder.get(left.slot) ?? 0) -
(slotOrder.get(right.slot) ?? 0);
if (slotDiff !== 0) return slotDiff;
return (agentOrder.get(left.agent) ?? 0) -
(agentOrder.get(right.agent) ?? 0);
});
const normalized = current.map((entry) => ({
slot: entry.slot,
agent: entry.agent,
}));
context.assignments.set(normalized);
const coverage = buildCoverageEntries(slots, normalized, agents);
const gapCount = coverage.reduce(
(count, entry) => count + (entry.hasGap ? 1 : 0),
0,
);
const coverageEntry = coverage.find((entry) => entry.slot === slotId);
const slotLabel = coverageEntry?.label ?? slotId;
const remaining = coverageEntry?.remaining ?? 0;
const agentName = agentId
? agents.find((entry) => entry.id === agentId)?.name ?? agentId
: "";
const historyValue = context.history.get();
const history = Array.isArray(historyValue) ? historyValue : [];
const message = action === "assign"
? `Assigned ${agentName} to ${slotLabel}`
: `Removed ${agentName} from ${slotLabel}`;
context.history.set([...history, message]);
const sequenceValue = context.sequence.get();
const nextSequence = typeof sequenceValue === "number"
? sequenceValue + 1
: 1;
context.sequence.set(nextSequence);
if (agentId) {
context.latestChange.set({
sequence: nextSequence,
slot: slotId,
label: slotLabel,
action,
agentId,
agentName,
gapCount,
remaining,
});
}
},
);
export const callCenterSchedulePattern = recipe(
"Call Center Schedule",
({ slots, agents, assignments }) => {
const slotsList = lift(sanitizeSlots)(slots);
const agentsList = lift(sanitizeAgents)(agents);
const baseSchedule = lift((input: {
entries: AssignmentInput[] | undefined;
slotList: SlotDefinition[];
agentList: AgentDefinition[];
}) => {
const sanitized = produceAssignments(
input.entries,
input.slotList,
input.agentList,
);
if (sanitized.length > 0) {
return sanitized;
}
const fallback = produceAssignments(
defaultAssignments,
input.slotList,
input.agentList,
);
if (fallback.length > 0) {
return fallback;
}
if (input.slotList.length > 0 && input.agentList.length > 0) {
const generated: AssignmentRecord[] = [];
for (let index = 0; index < input.slotList.length; index++) {
const agent = input.agentList[index % input.agentList.length];
if (!agent) break;
generated.push({
slot: input.slotList[index].id,
agent: agent.id,
});
}
return generated;
}
return [];
})({
entries: assignments,
slotList: slotsList,
agentList: agentsList,
});
const assignmentStore = cell([]);
const schedule = lift((input: {
stored: AssignmentRecord[];
base: AssignmentRecord[];
}) => {
const stored = Array.isArray(input.stored) ? input.stored : [];
if (stored.length > 0) {
return stored.map((entry) => ({ ...entry }));
}
const base = Array.isArray(input.base) ? input.base : [];
return base.map((entry) => ({ ...entry }));
})({
stored: assignmentStore,
base: baseSchedule,
});
const coverage = lift((input: {
slotList: SlotDefinition[];
records: AssignmentRecord[];
agentList: AgentDefinition[];
}) => buildCoverageEntries(input.slotList, input.records, input.agentList))(
{
slotList: slotsList,
records: schedule,
agentList: agentsList,
},
);
const gapLabels = lift((entries: SlotCoverage[]) =>
entries.filter((entry) => entry.hasGap).map((entry) => entry.label)
)(coverage);
const gapIds = lift((entries: SlotCoverage[]) =>
entries.filter((entry) => entry.hasGap).map((entry) => entry.slot)
)(coverage);
const totalSlots = lift((entries: SlotDefinition[]) => entries.length)(
slotsList,
);
const coveredSlots = lift((entries: SlotCoverage[]) =>
entries.filter((entry) => !entry.hasGap).length
)(coverage);
const gapCount = lift((entries: SlotCoverage[]) =>
entries.filter((entry) => entry.hasGap).length
)(coverage);
const coverageStatus =
str`${coveredSlots}/${totalSlots} slots covered; open gaps ${gapCount}`;
const gapSummary = lift((labels: string[]) => {
if (!Array.isArray(labels) || labels.length === 0) {
return "All slots covered";
}
return `Coverage gaps: ${labels.join(", ")}`;
})(gapLabels);
const remainingAgents = lift((entries: SlotCoverage[]) =>
entries.reduce((sum, entry) => sum + entry.remaining, 0)
)(coverage);
const history = cell([]);
const latestChange = cell(null);
const sequence = cell(0);
const historyView = lift((entries: string[] | undefined) => {
return Array.isArray(entries) ? [...entries] : [];
})(history);
const latestChangeView = lift(
(entry: LatestChange | null | undefined) => {
return entry ? { ...entry } : null;
},
)(latestChange);
const controls = {
updateShift: updateSchedule({
assignments: assignmentStore,
baseSchedule,
slots: slotsList,
agents: agentsList,
history,
latestChange,
sequence,
}),
};
return {
slots: slotsList,
agents: agentsList,
assignments: schedule,
coverage,
coverageGaps: gapIds,
gapSummary,
coverageStatus,
remainingCoverage: remainingAgents,
history: historyView,
latestChange: latestChangeView,
controls,
};
},
);