/// /** * Kanban Board Pattern * * Layer 1: Data model + computed derivations + debug UI * Layer 2: Mutation handlers for cards and columns * * Data model uses nested structure: columns contain cards. * Array position determines ordering (simple and effective). */ import { Cell, computed, Default, handler, NAME, pattern, Stream, UI, } from "commontools"; // ============ HELPERS ============ const generateId = () => Math.random().toString(36).substring(2, 9); // ============ TYPES ============ interface Card { id: string; title: string; description: Default; } interface Column { id: string; title: string; cards: Default; } // ============ INPUT/OUTPUT ============ interface State { columns: Cell< Default< Column[], [ { id: "todo"; title: "To Do"; cards: [] }, { id: "in-progress"; title: "In Progress"; cards: [] }, { id: "done"; title: "Done"; cards: [] }, ] > >; } interface Output { columns: Column[]; totalCards: number; cardCounts: Record; // Handlers as Streams addCard: Stream<{ columnId: string; title: string; description?: string }>; removeCard: Stream<{ columnId: string; cardId: string }>; moveCard: Stream< { cardId: string; fromColumnId: string; toColumnId: string } >; addColumn: Stream<{ title: string }>; removeColumn: Stream<{ columnId: string }>; } // ============ HANDLERS ============ const addCardHandler = handler< { columnId: string; title: string; description?: string }, { columns: Cell } >(({ columnId, title, description }, { columns }) => { if (!title?.trim()) return; const cols = columns.get(); const colIndex = cols.findIndex((c) => c.id === columnId); if (colIndex < 0) return; const newCard: Card = { id: generateId(), title: title.trim(), description: description?.trim() || "", }; columns.set( cols.map((col, i) => i === colIndex ? { ...col, cards: [...col.cards, newCard] } : col ), ); }); const removeCardHandler = handler< { columnId: string; cardId: string }, { columns: Cell } >(({ columnId, cardId }, { columns }) => { const cols = columns.get(); const colIndex = cols.findIndex((c) => c.id === columnId); if (colIndex < 0) return; columns.set( cols.map((col, i) => i === colIndex ? { ...col, cards: col.cards.filter((card) => card.id !== cardId) } : col ), ); }); const moveCardHandler = handler< { cardId: string; fromColumnId: string; toColumnId: string }, { columns: Cell } >(({ cardId, fromColumnId, toColumnId }, { columns }) => { if (fromColumnId === toColumnId) return; const cols = columns.get(); const fromIndex = cols.findIndex((c) => c.id === fromColumnId); const toIndex = cols.findIndex((c) => c.id === toColumnId); if (fromIndex < 0 || toIndex < 0) return; const card = cols[fromIndex].cards.find((c) => c.id === cardId); if (!card) return; columns.set( cols.map((col, i) => { if (i === fromIndex) { return { ...col, cards: col.cards.filter((c) => c.id !== cardId) }; } if (i === toIndex) { return { ...col, cards: [...col.cards, card] }; } return col; }), ); }); const addColumnHandler = handler< { title: string }, { columns: Cell } >(({ title }, { columns }) => { if (!title?.trim()) return; const newColumn: Column = { id: generateId(), title: title.trim(), cards: [], }; columns.push(newColumn); }); const removeColumnHandler = handler< { columnId: string }, { columns: Cell } >(({ columnId }, { columns }) => { const cols = columns.get(); const index = cols.findIndex((c) => c.id === columnId); if (index >= 0) { columns.set(cols.toSpliced(index, 1)); } }); // ============ PATTERN ============ export default pattern(({ columns }) => { // ============ BOUND HANDLERS ============ const addCard = addCardHandler({ columns }); const removeCard = removeCardHandler({ columns }); const moveCard = moveCardHandler({ columns }); const addColumn = addColumnHandler({ columns }); const removeColumn = removeColumnHandler({ columns }); // ============ LOCAL UI STATE ============ const newColumnTitle = Cell.of(""); // ============ COMPUTED DERIVATIONS ============ // Total cards across all columns const totalCards = computed(() => { const cols = columns.get(); let count = 0; for (const col of cols) { count += col.cards.length; } return count; }); // Card counts per column (as a map) const cardCounts = computed(() => { const cols = columns.get(); const counts: Record = {}; for (const col of cols) { counts[col.id] = col.cards.length; } return counts; }); // Column count const columnCount = computed(() => columns.get().length); // ============ UI ============ return { [NAME]: "Kanban Board", [UI]: (

Kanban Board

{/* Stats bar + Add Column */}
Columns: {columnCount} Total Cards: {totalCards}
{ const title = newColumnTitle.get().trim(); if (title) { columns.push({ id: generateId(), title, cards: [], }); newColumnTitle.set(""); } }} > + Column
{/* Kanban columns */}
{columns.map((column) => (
{/* Column header */}

{column.title}

{column.cards.length}
{ const cols = columns.get(); const index = cols.findIndex((c) => c.id === column.id); if (index >= 0) { columns.set(cols.toSpliced(index, 1)); } }} > ×
{/* Cards */}
{column.cards.map((card) => (
{/* Card header with title and delete */}
{card.title}
{ const cols = columns.get(); const colIndex = cols.findIndex((c) => c.id === column.id ); if (colIndex >= 0) { columns.set( cols.map((col, i) => i === colIndex ? { ...col, cards: col.cards.filter((c) => c.id !== card.id ), } : col ), ); } }} > ×
{/* Card description */} {card.description && (
{card.description}
)} {/* Move buttons */}
{ const cols = columns.get(); const fromIndex = cols.findIndex((c) => c.id === column.id ); const toIndex = fromIndex - 1; if (fromIndex > 0 && toIndex >= 0) { const cardData = cols[fromIndex].cards.find( (c) => c.id === card.id, ); if (cardData) { columns.set( cols.map((col, i) => { if (i === fromIndex) { return { ...col, cards: col.cards.filter( (c) => c.id !== card.id, ), }; } if (i === toIndex) { return { ...col, cards: [...col.cards, cardData], }; } return col; }), ); } } }} > ← { const cols = columns.get(); const fromIndex = cols.findIndex((c) => c.id === column.id ); const toIndex = fromIndex + 1; if (fromIndex >= 0 && toIndex < cols.length) { const cardData = cols[fromIndex].cards.find( (c) => c.id === card.id, ); if (cardData) { columns.set( cols.map((col, i) => { if (i === fromIndex) { return { ...col, cards: col.cards.filter( (c) => c.id !== card.id, ), }; } if (i === toIndex) { return { ...col, cards: [...col.cards, cardData], }; } return col; }), ); } } }} > →
))} {/* Empty state */} {computed(() => column.cards.length === 0 ? (
No cards
) : null )}
{/* Add card input */} { const title = e.detail?.message?.trim(); if (title) { const cols = columns.get(); const colIndex = cols.findIndex((c) => c.id === column.id); if (colIndex >= 0) { const newCard = { id: generateId(), title, description: "", }; columns.set( cols.map((col, i) => i === colIndex ? { ...col, cards: [...col.cards, newCard] } : col ), ); } } }} />
))}
{/* Debug Panel */}
Debug: Computed Values
            {computed(() =>
              JSON.stringify(
                {
                  totalCards,
                  columnCount: columnCount,
                  cardCounts,
                },
                null,
                2
              )
            )}
          
Debug: Raw Data
            {computed(() => JSON.stringify(columns.get(), null, 2))}
          
), // Export for linking columns, totalCards, cardCounts, // Handlers (as Streams for cross-charm communication) addCard, removeCard, moveCard, addColumn, removeColumn, }; });