/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; interface OrgMember { id: string; name: string; manager: string | null; } interface OrgChartArgs { members: Default; } interface OrgChartNode { id: string; name: string; reports: OrgChartNode[]; } interface RelocateEvent { employeeId?: string; newManagerId?: string | null; } const defaultMembers: OrgMember[] = [ { id: "ceo", name: "Avery CEO", manager: null }, { id: "cto", name: "Casey CTO", manager: "ceo" }, { id: "eng-lead", name: "Riley Eng Lead", manager: "cto" }, { id: "designer", name: "Sky Designer", manager: "eng-lead" }, { id: "ops", name: "Morgan Ops", manager: "ceo" }, ]; const sanitizeId = (value: unknown): string | null => { if (typeof value !== "string") return null; const normalized = value.trim().toLowerCase(); return normalized.length > 0 ? normalized : null; }; const sanitizeName = (value: unknown, fallback: string): string => { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) return trimmed; } return fallback .split(/[-_\s]+/) .filter((part) => part.length > 0) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); }; const baseMemberList = (value: unknown): OrgMember[] => { if (!Array.isArray(value)) { return structuredClone(defaultMembers); } const seen = new Set(); const members: OrgMember[] = []; for (const entry of value) { const candidate = entry as OrgMember | undefined; const id = sanitizeId(candidate?.id); if (!id || seen.has(id)) continue; const name = sanitizeName(candidate?.name, id); const managerValue = candidate?.manager; const manager = typeof managerValue === "string" ? sanitizeId(managerValue) : managerValue === null ? null : null; members.push({ id, name, manager }); seen.add(id); } if (members.length === 0) { return structuredClone(defaultMembers); } return members; }; const resolveCycles = (members: readonly OrgMember[]): OrgMember[] => { const parent = new Map(); for (const member of members) { parent.set(member.id, member.manager); } for (const member of members) { const origin = member.id; const visited = new Set([origin]); let current = parent.get(origin) ?? null; while (current) { if (!parent.has(current) || visited.has(current)) { parent.set(origin, null); break; } visited.add(current); current = parent.get(current) ?? null; } } return members.map((member) => ({ id: member.id, name: member.name, manager: parent.get(member.id) ?? null, })); }; const sanitizeMembers = (value: unknown): OrgMember[] => { const base = baseMemberList(value); const ids = new Set(base.map((member) => member.id)); const validated = base.map((member) => { if (!member.manager) { return { ...member, manager: null }; } if (!ids.has(member.manager) || member.manager === member.id) { return { ...member, manager: null }; } return member; }); if (!validated.some((member) => member.manager === null)) { validated[0] = { ...validated[0], manager: null }; } const resolved = resolveCycles(validated); return resolved .map((member) => ({ ...member })) .sort((left, right) => left.id.localeCompare(right.id)); }; const buildHierarchy = (members: readonly OrgMember[]): OrgChartNode[] => { const nodes = new Map(); for (const member of members) { nodes.set(member.id, { id: member.id, name: member.name, reports: [], }); } const roots: OrgChartNode[] = []; for (const member of members) { const node = nodes.get(member.id); if (!node) continue; if (member.manager && nodes.has(member.manager)) { nodes.get(member.manager)?.reports.push(node); } else { roots.push(node); } } const sortTree = (list: OrgChartNode[]) => { list.sort((left, right) => left.id.localeCompare(right.id)); for (const entry of list) { sortTree(entry.reports); } }; sortTree(roots); return roots; }; const buildReportingChains = ( members: readonly OrgMember[], ): Record => { const byId = new Map(); for (const member of members) { byId.set(member.id, member); } const cache = new Map(); const resolve = (memberId: string): string[] => { const cached = cache.get(memberId); if (cached) return [...cached]; const names: string[] = []; const visited = new Set(); let current: string | null = memberId; let guard = 0; while (current && guard <= members.length) { if (visited.has(current)) break; visited.add(current); const member = byId.get(current); if (!member) break; names.push(member.name); current = member.manager; guard += 1; } const chain = names.reverse(); cache.set(memberId, chain); return [...chain]; }; const entries: [string, string[]][] = []; for (const member of members) { entries.push([member.id, resolve(member.id)]); } entries.sort((left, right) => left[0].localeCompare(right[0])); return Object.fromEntries(entries.map(([id, chain]) => [id, [...chain]])); }; const createsCycle = ( members: readonly OrgMember[], employeeId: string, candidateManager: string, ): boolean => { const children = new Map(); for (const member of members) { if (!member.manager) continue; const list = children.get(member.manager) ?? []; list.push(member.id); children.set(member.manager, list); } const queue = [...(children.get(employeeId) ?? [])]; while (queue.length > 0) { const current = queue.shift()!; if (current === candidateManager) return true; const next = children.get(current); if (next && next.length > 0) { queue.push(...next); } } return false; }; const relocateMember = handler( ( event: RelocateEvent | undefined, context: { members: Cell; history: Cell }, ) => { const employeeId = sanitizeId(event?.employeeId); if (!employeeId) return; const rawMembers = context.members.get(); const members = sanitizeMembers(rawMembers); const employee = members.find((entry) => entry.id === employeeId); if (!employee) return; let nextManager: string | null; if (event?.newManagerId === null) { nextManager = null; } else if (typeof event?.newManagerId === "string") { const sanitizedManager = sanitizeId(event.newManagerId); if (!sanitizedManager) return; nextManager = sanitizedManager; } else if (event?.newManagerId === undefined) { nextManager = null; } else { return; } if (nextManager === employee.manager) return; const ids = new Set(members.map((member) => member.id)); if (nextManager) { if (!ids.has(nextManager) || nextManager === employeeId) return; if (createsCycle(members, employeeId, nextManager)) return; } const updated = members.map((member) => member.id === employeeId ? { ...member, manager: nextManager } : member ); const canonical = sanitizeMembers(updated); const nameById = new Map(members.map((member) => [member.id, member.name])); const previousManagerName = employee.manager ? nameById.get(employee.manager) ?? "Top Level" : "Top Level"; const targetManagerName = nextManager ? nameById.get(nextManager) ?? "Top Level" : "Top Level"; context.members.set(canonical); const history = context.history.get(); const log = Array.isArray(history) ? history : []; const message = `Relocated ${employee.name} from ${previousManagerName} to ` + `${nextManager ? targetManagerName : "Top Level"}`; context.history.set([...log, message]); }, ); export const orgChartHierarchy = recipe( "Org Chart Hierarchy", ({ members }) => { const history = cell([]); const memberList = lift((value: OrgMember[] | undefined) => sanitizeMembers(value) )(members); const hierarchy = lift(buildHierarchy)(memberList); const reportingChains = lift(buildReportingChains)(memberList); const topLevelNames = lift((nodes: OrgChartNode[]) => nodes.map((node) => node.name) )(hierarchy); const memberCount = lift((entries: OrgMember[]) => entries.length)( memberList, ); const rootCount = lift((nodes: OrgChartNode[]) => nodes.length)(hierarchy); const summary = str`Org has ${memberCount} members across ${rootCount} root nodes`; const chainSummaries = lift((chains: Record) => Object.entries(chains) .map(([id, chain]) => `${id}: ${chain.join(" > ")}`) .sort((left, right) => left.localeCompare(right)) )(reportingChains); return { members, hierarchy, reportingChains, topLevelNames, summary, chainSummaries, history, relocate: relocateMember({ members, history }), }; }, );