/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; interface LibraryItem { id: string; title: string; copiesTotal: number; } interface LoanRecord { sequence: number; itemId: string; memberId: string; } interface HoldRecord { sequence: number; itemId: string; memberId: string; } interface CheckoutEvent { itemId?: string; memberId?: string; } interface HoldEvent { itemId?: string; memberId?: string; } interface CirculationChange { sequence: number; type: "checkout" | "return" | "hold" | "cancel"; itemId: string; memberId: string; note: string; } interface LibraryCheckoutArgs { catalog: Default; loans: Default; holds: Default; } interface ItemAvailability { id: string; title: string; totalCopies: number; activeLoans: number; availableCopies: number; holdsQueued: number; loanMembers: string[]; holdMembers: string[]; nextHold: string | null; status: "available" | "limited" | "on-hold" | "unavailable"; statusLabel: string; } interface CirculationContext { catalog: Cell; loans: Cell; holds: Cell; eventSequence: Cell; lastChange: Cell; } const defaultCatalog: LibraryItem[] = [ { id: "atlas-of-dawn", title: "Atlas of Dawn", copiesTotal: 3 }, { id: "modular-thoughts", title: "Modular Thoughts", copiesTotal: 1 }, { id: "synthesis-primer", title: "Synthesis Primer", copiesTotal: 2 }, ]; const defaultLoans: LoanRecord[] = [ { sequence: 1, itemId: "atlas-of-dawn", memberId: "member-alba" }, { sequence: 2, itemId: "modular-thoughts", memberId: "member-luis" }, ]; const defaultHolds: HoldRecord[] = [ { sequence: 1, itemId: "modular-thoughts", memberId: "member-jade" }, ]; const slugify = (value: unknown): string | null => { if (typeof value !== "string") return null; const trimmed = value.trim().toLowerCase(); if (!trimmed) return null; const slug = trimmed.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return slug.length > 0 ? slug : null; }; const sanitizeItemId = (value: unknown): string | null => { const slug = slugify(value); return slug ?? null; }; const sanitizeMemberId = (value: unknown): string | null => { const slug = slugify(value); if (!slug) return null; return slug.startsWith("member-") ? slug : `member-${slug}`; }; const sanitizeTitle = (value: unknown, fallback: string): string => { if (typeof value === "string") { const trimmed = value.trim().replace(/\s+/g, " "); if (trimmed.length > 0) return trimmed; } return fallback; }; const sanitizeCopies = (value: unknown, fallback: number): number => { if (typeof value === "number" && Number.isFinite(value)) { const normalized = Math.floor(value); if (normalized > 0) return normalized; } return fallback; }; const cloneCatalog = (items: readonly LibraryItem[]): LibraryItem[] => items.map((item) => ({ ...item })); const cloneLoans = (loans: readonly LoanRecord[]): LoanRecord[] => loans.map((loan) => ({ ...loan })); const cloneHolds = (holds: readonly HoldRecord[]): HoldRecord[] => holds.map((hold) => ({ ...hold })); const sanitizeCatalogList = (value: unknown): LibraryItem[] => { if (!Array.isArray(value) || value.length === 0) { return cloneCatalog(defaultCatalog); } const used = new Set(); const sanitized: LibraryItem[] = []; for (let index = 0; index < value.length; index++) { const entry = value[index] as Partial | undefined; const fallback = defaultCatalog[index] ?? defaultCatalog[0]; const id = sanitizeItemId(entry?.id ?? fallback.id); if (!id || used.has(id)) continue; const title = sanitizeTitle(entry?.title, fallback.title); const copies = sanitizeCopies(entry?.copiesTotal, fallback.copiesTotal); sanitized.push({ id, title, copiesTotal: copies }); used.add(id); } return sanitized.length > 0 ? sanitized : cloneCatalog(defaultCatalog); }; const sanitizeLoanList = ( catalog: readonly LibraryItem[], value: unknown, ): LoanRecord[] => { const validItems = new Set(catalog.map((item) => item.id)); if (!Array.isArray(value)) { return cloneLoans( defaultLoans.filter((loan) => validItems.has(loan.itemId)), ); } if (value.length === 0) return []; const seen = new Set(); const sanitized: LoanRecord[] = []; for (const candidate of value) { const raw = candidate as Partial | undefined; const itemId = sanitizeItemId(raw?.itemId); const memberId = sanitizeMemberId(raw?.memberId); if (!itemId || !memberId || !validItems.has(itemId)) continue; const key = `${itemId}|${memberId}`; if (seen.has(key)) continue; sanitized.push({ sequence: sanitized.length + 1, itemId, memberId, }); seen.add(key); } return sanitized; }; const sanitizeHoldList = ( catalog: readonly LibraryItem[], value: unknown, ): HoldRecord[] => { const validItems = new Set(catalog.map((item) => item.id)); if (!Array.isArray(value)) { return cloneHolds( defaultHolds.filter((hold) => validItems.has(hold.itemId)), ); } if (value.length === 0) return []; const seen = new Set(); const sanitized: HoldRecord[] = []; for (const candidate of value) { const raw = candidate as Partial | undefined; const itemId = sanitizeItemId(raw?.itemId); const memberId = sanitizeMemberId(raw?.memberId); if (!itemId || !memberId || !validItems.has(itemId)) continue; const key = `${itemId}|${memberId}`; if (seen.has(key)) continue; sanitized.push({ sequence: sanitized.length + 1, itemId, memberId, }); seen.add(key); } return sanitized; }; const formatCount = ( count: number, singular: string, plural: string, ): string => { const value = count === 1 ? singular : plural; return `${count} ${value}`; }; const statusLabelFor = ( status: ItemAvailability["status"], available: number, total: number, holdCount: number, ): string => { switch (status) { case "available": { const base = `All ${total} copies available`; if (holdCount === 0) return base; const holdLabel = holdCount === 1 ? "1 hold queued" : `${holdCount} holds queued`; return `${base}; ${holdLabel}`; } case "limited": { const base = `${available} of ${total} copies available`; if (holdCount === 0) return base; const holdLabel = holdCount === 1 ? "1 hold queued" : `${holdCount} holds queued`; return `${base}; ${holdLabel}`; } case "on-hold": { const holdLabel = holdCount === 1 ? "1 hold waiting" : `${holdCount} holds waiting`; return `All copies loaned; ${holdLabel}`; } case "unavailable": return "All copies loaned out"; } }; const computeAvailability = ( catalog: readonly LibraryItem[], loans: readonly LoanRecord[], holds: readonly HoldRecord[], ): ItemAvailability[] => { const loansByItem = new Map(); for (const loan of loans) { const bucket = loansByItem.get(loan.itemId) ?? []; bucket.push(loan); loansByItem.set(loan.itemId, bucket); } const holdsByItem = new Map(); for (const hold of holds) { const bucket = holdsByItem.get(hold.itemId) ?? []; bucket.push(hold); holdsByItem.set(hold.itemId, bucket); } return catalog.map((item) => { const itemLoans = loansByItem.get(item.id) ?? []; const itemHolds = holdsByItem.get(item.id) ?? []; const availableCopies = Math.max(item.copiesTotal - itemLoans.length, 0); const holdCount = itemHolds.length; let status: ItemAvailability["status"]; if (availableCopies === item.copiesTotal) status = "available"; else if (availableCopies > 0) status = "limited"; else if (holdCount > 0) status = "on-hold"; else status = "unavailable"; const statusLabel = statusLabelFor( status, availableCopies, item.copiesTotal, holdCount, ); return { id: item.id, title: item.title, totalCopies: item.copiesTotal, activeLoans: itemLoans.length, availableCopies, holdsQueued: holdCount, loanMembers: itemLoans.map((loan) => loan.memberId), holdMembers: itemHolds.map((hold) => hold.memberId), nextHold: itemHolds.length > 0 ? itemHolds[0]?.memberId ?? null : null, status, statusLabel, }; }); }; const recordChange = ( context: CirculationContext, change: Omit, ) => { const current = context.eventSequence.get(); const base = typeof current === "number" && Number.isFinite(current) && current > 0 ? current : 1; context.lastChange.set({ sequence: base, ...change }); context.eventSequence.set(base + 1); }; const checkoutItem = handler( ( event: CheckoutEvent | undefined, context: CirculationContext, ) => { const itemId = sanitizeItemId(event?.itemId); const memberId = sanitizeMemberId(event?.memberId); if (!itemId || !memberId) return; const catalogList = sanitizeCatalogList(context.catalog.get()); const loansList = sanitizeLoanList(catalogList, context.loans.get()); const holdsList = sanitizeHoldList(catalogList, context.holds.get()); context.catalog.set(catalogList); const item = catalogList.find((entry) => entry.id === itemId); if (!item) return; if ( loansList.some((loan) => loan.itemId === itemId && loan.memberId === memberId ) ) { return; } const activeForItem = loansList.filter((loan) => loan.itemId === itemId); if (activeForItem.length >= item.copiesTotal) return; const appended = [ ...loansList, { sequence: loansList.length + 1, itemId, memberId }, ]; const sanitizedLoans = sanitizeLoanList(catalogList, appended); context.loans.set(sanitizedLoans); const filteredHolds = holdsList.filter((hold) => !(hold.itemId === itemId && hold.memberId === memberId) ); if (filteredHolds.length !== holdsList.length) { context.holds.set(sanitizeHoldList(catalogList, filteredHolds)); } recordChange(context, { type: "checkout", itemId, memberId, note: `${memberId} checked out ${item.title}`, }); }, ); const returnItem = handler( ( event: CheckoutEvent | undefined, context: CirculationContext, ) => { const itemId = sanitizeItemId(event?.itemId); const requestedMember = sanitizeMemberId(event?.memberId); if (!itemId) return; const catalogList = sanitizeCatalogList(context.catalog.get()); context.catalog.set(catalogList); const item = catalogList.find((entry) => entry.id === itemId); if (!item) return; const sanitizedLoans = sanitizeLoanList( catalogList, context.loans.get(), ); const sanitizedHolds = sanitizeHoldList( catalogList, context.holds.get(), ); const loanIndex = sanitizedLoans.findIndex((loan) => { if (loan.itemId !== itemId) return false; if (!requestedMember) return true; return loan.memberId === requestedMember; }); if (loanIndex === -1) return; const removedLoan = sanitizedLoans[loanIndex]; let loansAfter = sanitizeLoanList( catalogList, sanitizedLoans.filter((_, index) => index !== loanIndex), ); let holdsAfter = sanitizedHolds; let promotedNote = ""; let promotedMember: string | null = null; let promotedHold: HoldRecord | null = null; const holdRemainder: HoldRecord[] = []; for (const hold of holdsAfter) { if (!promotedHold && hold.itemId === itemId) { promotedHold = hold; promotedMember = hold.memberId; continue; } holdRemainder.push(hold); } if (promotedHold) { holdsAfter = sanitizeHoldList(catalogList, holdRemainder); loansAfter = sanitizeLoanList(catalogList, [ ...loansAfter, { sequence: loansAfter.length + 1, itemId: promotedHold.itemId, memberId: promotedHold.memberId, }, ]); promotedNote = `; promoted hold for ${promotedHold.memberId}`; } context.loans.set(loansAfter); context.holds.set(holdsAfter); recordChange(context, { type: "return", itemId, memberId: removedLoan.memberId, note: `${removedLoan.memberId} returned ${item.title}${promotedNote}`, }); if (promotedMember) { recordChange(context, { type: "checkout", itemId, memberId: promotedMember, note: `${promotedMember} checked out ${item.title} via hold`, }); } }, ); const placeHold = handler( ( event: HoldEvent | undefined, context: CirculationContext, ) => { const itemId = sanitizeItemId(event?.itemId); const memberId = sanitizeMemberId(event?.memberId); if (!itemId || !memberId) return; const catalogList = sanitizeCatalogList(context.catalog.get()); context.catalog.set(catalogList); const item = catalogList.find((entry) => entry.id === itemId); if (!item) return; const holdsList = sanitizeHoldList(catalogList, context.holds.get()); context.holds.set(holdsList); if ( holdsList.some((hold) => hold.itemId === itemId && hold.memberId === memberId ) ) { return; } const appended = [ ...holdsList, { sequence: holdsList.length + 1, itemId, memberId }, ]; context.holds.set(sanitizeHoldList(catalogList, appended)); recordChange(context, { type: "hold", itemId, memberId, note: `${memberId} placed hold on ${item.title}`, }); }, ); const cancelHold = handler( ( event: HoldEvent | undefined, context: CirculationContext, ) => { const itemId = sanitizeItemId(event?.itemId); const memberId = sanitizeMemberId(event?.memberId); if (!itemId || !memberId) return; const catalogList = sanitizeCatalogList(context.catalog.get()); context.catalog.set(catalogList); const item = catalogList.find((entry) => entry.id === itemId); if (!item) return; const holdsList = sanitizeHoldList(catalogList, context.holds.get()); context.holds.set(holdsList); const filtered = holdsList.filter((hold) => !(hold.itemId === itemId && hold.memberId === memberId) ); if (filtered.length === holdsList.length) return; context.holds.set(sanitizeHoldList(catalogList, filtered)); recordChange(context, { type: "cancel", itemId, memberId, note: `${memberId} canceled hold on ${item.title}`, }); }, ); export const libraryCheckoutSystem = recipe( "Library Checkout System", ({ catalog }) => { const eventSequence = cell(1); const lastChange = cell(null); const loanState = cell(cloneLoans(defaultLoans)); const holdState = cell(cloneHolds(defaultHolds)); const catalogView = lift(sanitizeCatalogList)(catalog); const loanEntries = lift(cloneLoans)(loanState); const holdEntries = lift(cloneHolds)(holdState); const availabilityRaw = lift( ( input: { catalog: LibraryItem[]; loans: LoanRecord[]; holds: HoldRecord[]; }, ) => computeAvailability(input.catalog, input.loans, input.holds), )({ catalog: catalogView, loans: loanState, holds: holdState, }); const availability = lift((entries: ItemAvailability[]) => entries.map((entry) => ({ ...entry })) )(availabilityRaw); const availabilitySignals = lift((entries: ItemAvailability[]) => entries.map((entry) => `${entry.id}|${entry.status}|${entry.availableCopies}|${entry.holdsQueued}` ) )(availability); const availableTitleCount = lift((entries: ItemAvailability[]) => entries.filter((entry) => entry.availableCopies > 0).length )(availability); const totalTitles = lift((entries: ItemAvailability[]) => entries.length)( availability, ); const activeLoanCount = lift((entries: LoanRecord[]) => entries.length)( loanEntries, ); const pendingHoldCount = lift((entries: HoldRecord[]) => entries.length)( holdEntries, ); const loanSummary = lift((count: number) => formatCount(count, "active loan", "active loans") )(activeLoanCount); const holdSummary = lift((count: number) => formatCount(count, "hold queued", "holds queued") )(pendingHoldCount); const availabilitySummary = str`${availableTitleCount}/${totalTitles} titles open · ${loanSummary} · ${holdSummary}`; const lastChangeLabel = lift((change: CirculationChange | null) => { if (!change) return "No circulation changes yet"; return `${change.note} (#${change.sequence})`; })(lastChange); const context = { catalog, loans: loanState, holds: holdState, eventSequence, lastChange, }; return { catalog, loans: loanState, holds: holdState, catalogView, loanEntries, holdEntries, availability, availableTitleCount, activeLoanCount, pendingHoldCount, availabilitySummary, availabilitySignals, lastChange, lastChangeLabel, checkout: checkoutItem(context), returnLoan: returnItem(context), placeHold: placeHold(context), cancelHold: cancelHold(context), }; }, ); export type { CirculationChange, HoldRecord, ItemAvailability, LibraryItem, LoanRecord, };