///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
type VoteValue = "yes" | "maybe" | "no";
interface ParticipantInput {
id?: string;
name?: string;
}
interface ParticipantDefinition {
id: string;
name: string;
}
interface SlotInput {
id?: string;
label?: string;
}
interface SlotDefinition {
id: string;
label: string;
}
type VoteRecord = Record>;
interface VoteEvent {
participant?: string;
slot?: string;
vote?: string;
}
interface ProposeSlotEvent {
id?: string;
label?: string;
}
interface VoteChange {
participantId: string;
participantName: string;
slotId: string;
slotLabel: string;
vote: VoteValue;
yesCount: number;
maybeCount: number;
noCount: number;
}
interface SlotUpdate {
slotId: string;
label: string;
mode: "added";
}
interface SlotVoteSummary {
slotId: string;
slotLabel: string;
yes: number;
maybe: number;
no: number;
pending: string[];
}
interface ConsensusSnapshot {
slotId: string | null;
slotLabel: string;
yes: number;
maybe: number;
no: number;
outstanding: number;
outstandingNames: string[];
status: "locked" | "pending";
participantCount: number;
}
interface MeetingSchedulerArgs {
participants: Default<
ParticipantInput[],
typeof defaultParticipants
>;
slots: Default;
}
const defaultParticipants: ParticipantDefinition[] = [
{ id: "alex-rivera", name: "Alex Rivera" },
{ id: "blair-chen", name: "Blair Chen" },
{ id: "casey-morgan", name: "Casey Morgan" },
];
const defaultSlots: SlotDefinition[] = [
{ id: "tuesday-0900", label: "Tuesday 09:00" },
{ id: "tuesday-1400", label: "Tuesday 14:00" },
{ id: "wednesday-1000", label: "Wednesday 10:00" },
];
const slugify = (value: string): string => {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
};
const ensureUnique = (value: string, used: Set): string => {
let candidate = value;
if (candidate.length === 0) {
candidate = "slot";
}
let suffix = 2;
while (used.has(candidate)) {
candidate = `${value}-${suffix}`;
suffix += 1;
}
used.add(candidate);
return candidate;
};
const normalizeParticipantName = (
value: unknown,
fallback: string,
): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) return trimmed;
}
return fallback;
};
const sanitizeParticipants = (
value: unknown,
): ParticipantDefinition[] => {
if (!Array.isArray(value) || value.length === 0) {
return structuredClone(defaultParticipants);
}
const used = new Set();
const sanitized: ParticipantDefinition[] = [];
for (let index = 0; index < value.length; index++) {
const entry = value[index] as ParticipantInput | string | undefined;
const fallback = defaultParticipants[index] ?? defaultParticipants[0];
const name = typeof entry === "string"
? normalizeParticipantName(entry, fallback.name)
: normalizeParticipantName(entry?.name, fallback.name);
const baseIdSource = typeof entry === "string"
? entry
: typeof entry?.id === "string" && entry.id.trim().length > 0
? entry.id
: name;
const baseId = slugify(baseIdSource);
const id = ensureUnique(
baseId.length > 0 ? baseId : slugify(fallback.id),
used,
);
sanitized.push({ id, name });
}
if (sanitized.length === 0) {
return structuredClone(defaultParticipants);
}
return sanitized;
};
const normalizeSlotLabel = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) return trimmed;
}
return fallback;
};
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 | string | undefined;
const fallback = defaultSlots[index] ?? defaultSlots[0];
const label = typeof entry === "string"
? normalizeSlotLabel(entry, fallback.label)
: normalizeSlotLabel(entry?.label, fallback.label);
if (label.length === 0) {
continue;
}
const baseIdSource = typeof entry === "string"
? entry
: typeof entry?.id === "string" && entry.id.trim().length > 0
? entry.id
: label;
const baseId = slugify(baseIdSource);
const id = ensureUnique(
baseId.length > 0 ? baseId : slugify(fallback.id),
used,
);
sanitized.push({ id, label });
}
if (sanitized.length === 0) {
return structuredClone(defaultSlots);
}
return sanitized;
};
const normalizeVoteValue = (value: unknown): VoteValue | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim().toLowerCase();
if (trimmed === "yes" || trimmed === "maybe" || trimmed === "no") {
return trimmed;
}
return null;
};
const resolveParticipantId = (
participants: readonly ParticipantDefinition[],
value: unknown,
): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length === 0) return null;
const byId = participants.find((entry) => entry.id === trimmed);
if (byId) return byId.id;
const normalized = trimmed.toLowerCase();
const byName = participants.find((entry) =>
entry.name.toLowerCase() === normalized
);
return byName?.id ?? null;
};
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 normalizeVoteState = (
value: unknown,
slots: readonly SlotDefinition[],
participants: readonly ParticipantDefinition[],
): VoteRecord => {
const participantIds = new Set(participants.map((entry) => entry.id));
const source = value && typeof value === "object"
? value as Record
: {};
const sanitized: VoteRecord = {};
for (const slot of slots) {
const raw = source[slot.id];
const slotVotes: Record = {};
if (raw && typeof raw === "object") {
const entries = raw as Record;
for (const [participantId, voteValue] of Object.entries(entries)) {
if (!participantIds.has(participantId)) continue;
const vote = normalizeVoteValue(voteValue);
if (!vote) continue;
slotVotes[participantId] = vote;
}
}
sanitized[slot.id] = slotVotes;
}
return sanitized;
};
const recordHistoryEntry = (history: Cell, entry: string): void => {
const current = history.get();
const list = Array.isArray(current) ? [...current, entry] : [entry];
history.set(list.slice(-12));
};
const countVoteType = (
votes: Record,
kind: VoteValue,
): number => {
let total = 0;
for (const value of Object.values(votes)) {
if (value === kind) total += 1;
}
return total;
};
const buildVoteTallies = (
slots: readonly SlotDefinition[],
participants: readonly ParticipantDefinition[],
votes: VoteRecord,
): SlotVoteSummary[] => {
const summaries: SlotVoteSummary[] = [];
for (const slot of slots) {
const slotVotes = votes[slot.id] ?? {};
const yes = countVoteType(slotVotes, "yes");
const maybe = countVoteType(slotVotes, "maybe");
const no = countVoteType(slotVotes, "no");
const pending: string[] = [];
for (const participant of participants) {
if (!slotVotes[participant.id]) {
pending.push(participant.name);
}
}
summaries.push({
slotId: slot.id,
slotLabel: slot.label,
yes,
maybe,
no,
pending,
});
}
return summaries;
};
const computeConsensus = (
tallies: readonly SlotVoteSummary[],
participants: readonly ParticipantDefinition[],
): ConsensusSnapshot => {
if (!Array.isArray(tallies) || tallies.length === 0) {
const names = participants.map((entry) => entry.name);
return {
slotId: null,
slotLabel: "No slots proposed",
yes: 0,
maybe: 0,
no: 0,
outstanding: names.length,
outstandingNames: names,
status: "pending",
participantCount: names.length,
};
}
let best = tallies[0];
for (let index = 1; index < tallies.length; index++) {
const candidate = tallies[index];
if (candidate.yes > best.yes) {
best = candidate;
continue;
}
if (candidate.yes === best.yes) {
if (candidate.maybe > best.maybe) {
best = candidate;
continue;
}
if (
candidate.maybe === best.maybe &&
candidate.slotLabel.localeCompare(best.slotLabel) < 0
) {
best = candidate;
}
}
}
const outstanding = best.pending.length;
const status = outstanding === 0 && best.yes >= best.no
? "locked"
: "pending";
return {
slotId: best.slotId,
slotLabel: best.slotLabel,
yes: best.yes,
maybe: best.maybe,
no: best.no,
outstanding,
outstandingNames: [...best.pending],
status,
participantCount: participants.length,
};
};
const castVote = handler(
(
event: VoteEvent | undefined,
context: {
participants: Cell;
slots: Cell;
votes: Cell;
history: Cell;
latestVote: Cell;
},
) => {
const participantList = sanitizeParticipants(context.participants.get());
const slotList = sanitizeSlots(context.slots.get());
if (participantList.length === 0 || slotList.length === 0) {
return;
}
const vote = normalizeVoteValue(event?.vote);
if (!vote) return;
const participantId = resolveParticipantId(
participantList,
event?.participant,
);
if (!participantId) return;
const slotId = resolveSlotId(slotList, event?.slot);
if (!slotId) return;
const participant = participantList.find((entry) =>
entry.id === participantId
);
const slot = slotList.find((entry) => entry.id === slotId);
if (!participant || !slot) return;
const sanitizedState = normalizeVoteState(
context.votes.get(),
slotList,
participantList,
);
const slotVotes = { ...sanitizedState[slotId] };
if (slotVotes[participantId] === vote) {
return;
}
slotVotes[participantId] = vote;
const nextState: VoteRecord = { ...sanitizedState, [slotId]: slotVotes };
context.votes.set(nextState);
const yesCount = countVoteType(slotVotes, "yes");
const maybeCount = countVoteType(slotVotes, "maybe");
const noCount = countVoteType(slotVotes, "no");
recordHistoryEntry(
context.history,
`${participant.name} voted ${vote} for ${slot.label}`,
);
const change: VoteChange = {
participantId,
participantName: participant.name,
slotId,
slotLabel: slot.label,
vote,
yesCount,
maybeCount,
noCount,
};
context.latestVote.set(change);
},
);
const proposeSlot = handler(
(
event: ProposeSlotEvent | undefined,
context: {
participants: Cell;
slots: Cell;
votes: Cell;
history: Cell;
latestSlotUpdate: Cell;
},
) => {
const slotList = sanitizeSlots(context.slots.get());
const label = normalizeSlotLabel(event?.label ?? event?.id, "");
if (label.length === 0) {
return;
}
const baseIdSource =
typeof event?.id === "string" && event.id.trim().length > 0
? event.id
: label;
const baseId = slugify(baseIdSource);
const exists = slotList.some((entry) =>
entry.id === baseId || entry.label.toLowerCase() === label.toLowerCase()
);
if (exists) {
return;
}
const used = new Set(slotList.map((entry) => entry.id));
const slotId = ensureUnique(
baseId.length > 0 ? baseId : slugify(label),
used,
);
const nextSlots = [...slotList, { id: slotId, label }];
context.slots.set(nextSlots.map((entry) => ({ ...entry })));
const participants = sanitizeParticipants(context.participants.get());
const votes = normalizeVoteState(
context.votes.get(),
nextSlots,
participants,
);
context.votes.set(votes);
recordHistoryEntry(context.history, `Proposed slot ${label}`);
context.latestSlotUpdate.set({ slotId, label, mode: "added" });
},
);
/**
* Meeting scheduler that tracks proposed slots, records participant votes, and
* surfaces consensus snapshots for offline planning.
*/
export const meetingSchedulerPattern = recipe(
"Meeting Scheduler Pattern",
({ participants, slots }) => {
const votes = cell({});
const history = cell([]);
const latestVote = cell(null);
const latestSlotUpdate = cell(null);
const participantList = lift(sanitizeParticipants)(participants);
const slotList = lift(sanitizeSlots)(slots);
const voteState = lift(
(
input: {
state: VoteRecord | undefined;
slots: SlotDefinition[];
participants: ParticipantDefinition[];
},
) => normalizeVoteState(input.state, input.slots, input.participants),
)({ state: votes, slots: slotList, participants: participantList });
const slotTallies = lift(
(
input: {
slots: SlotDefinition[];
participants: ParticipantDefinition[];
votes: VoteRecord;
},
) => buildVoteTallies(input.slots, input.participants, input.votes),
)({ slots: slotList, participants: participantList, votes: voteState });
const consensus = lift(
(
input: {
tallies: SlotVoteSummary[];
participants: ParticipantDefinition[];
},
) => computeConsensus(input.tallies, input.participants),
)({ tallies: slotTallies, participants: participantList });
const consensusLabel = lift((entry: ConsensusSnapshot) => entry.slotLabel)(
consensus,
);
const consensusYes = lift((entry: ConsensusSnapshot) => entry.yes)(
consensus,
);
const outstandingNames = lift(
(entry: ConsensusSnapshot) =>
entry.outstandingNames.length > 0
? entry.outstandingNames.join(", ")
: "none",
)(consensus);
const outstandingCount = lift(
(entry: ConsensusSnapshot) => entry.outstanding,
)(consensus);
const historyView = lift((entries: string[] | undefined) => {
return Array.isArray(entries) ? [...entries] : [];
})(history);
const latestVoteView = lift((entry: VoteChange | null | undefined) => {
return entry ? { ...entry } : null;
})(latestVote);
const latestSlotUpdateView = lift(
(entry: SlotUpdate | null | undefined) => {
return entry ? { ...entry } : null;
},
)(latestSlotUpdate);
const consensusSummary =
str`Consensus slot: ${consensusLabel} (${consensusYes} yes)`;
const outstandingSummary =
str`Outstanding voters: ${outstandingCount} (${outstandingNames})`;
return {
participants: participantList,
slots: slotList,
votes: voteState,
slotTallies,
consensus,
consensusSummary,
outstandingSummary,
history: historyView,
latestVote: latestVoteView,
latestSlotUpdate: latestSlotUpdateView,
controls: {
castVote: castVote({
participants,
slots,
votes,
history,
latestVote,
}),
proposeSlot: proposeSlot({
participants,
slots,
votes,
history,
latestSlotUpdate,
}),
},
};
},
);