/// /** * Budget Tracker - Expense Form Sub-Pattern * * Provides UI and handlers for adding/removing expenses and budgets. * Requires Cell<> for write access to data. * * Can be deployed standalone for testing handlers, * or composed into a larger pattern. */ import { Cell, computed, handler, NAME, pattern, Stream, UI, } from "commontools"; import { type CategoryBudget, type Expense, getTodayDate } from "./schemas.tsx"; // ============ INPUT/OUTPUT TYPES ============ // Sub-patterns don't use Default<> - the parent pattern owns initialization interface Input { expenses: Cell; budgets: Cell; } interface Output { // Don't re-export shared cells - parent owns them // Handlers typed as Stream for cross-charm communication addExpense: Stream<{ description: string; amount: number; category?: string; date?: string; }>; setBudget: Stream<{ category: string; limit: number }>; removeBudget: Stream<{ category: string }>; } // ============ HANDLERS ============ const addExpenseHandler = handler< { description: string; amount: number; category?: string; date?: string }, { expenses: Cell } >(({ description, amount, category, date }, { expenses }) => { if (!description?.trim() || typeof amount !== "number" || amount <= 0) { return; } expenses.push({ description: description.trim(), amount, category: category || "Other", date: date || getTodayDate(), }); }); const setBudgetHandler = handler< { category: string; limit: number }, { budgets: Cell } >(({ category, limit }, { budgets }) => { if (!category?.trim() || typeof limit !== "number" || limit < 0) { return; } const current = budgets.get(); const existingIndex = current.findIndex((b) => b.category === category.trim() ); if (existingIndex >= 0) { budgets.set( current.map((b, i) => (i === existingIndex ? { ...b, limit } : b)), ); } else { budgets.push({ category: category.trim(), limit }); } }); const removeBudgetHandler = handler< { category: string }, { budgets: Cell } >(({ category }, { budgets }) => { const current = budgets.get(); const index = current.findIndex((b) => b.category === category); if (index >= 0) { budgets.set(current.toSpliced(index, 1)); } }); // ============ PATTERN ============ // Use single type param to avoid conflict bug when composed export default pattern(({ expenses, budgets }) => { const todayDate = getTodayDate(); // Local state for form inputs const newDescription = Cell.of(""); const newAmount = Cell.of(""); const newCategory = Cell.of("Other"); // Budget form inputs const budgetCategory = Cell.of(""); const budgetLimit = Cell.of(""); // Counts for display const expenseCount = computed(() => expenses.get().length); const budgetCount = computed(() => budgets.get().length); // Bound handlers const addExpense = addExpenseHandler({ expenses }); const setBudget = setBudgetHandler({ budgets }); const removeBudget = removeBudgetHandler({ budgets }); return { [NAME]: "Expense Form", [UI]: (

Add Expense

{/* Add Expense Form - using $value binding */}
{ const desc = newDescription.get().trim(); const amt = parseFloat(newAmount.get()); const cat = newCategory.get().trim() || "Other"; if (desc && !isNaN(amt) && amt > 0) { expenses.push({ description: desc, amount: amt, category: cat, date: todayDate, }); // Clear form newDescription.set(""); newAmount.set(""); newCategory.set("Other"); } }} > Add Expense
{/* Expense List with Remove */}

Expenses ({expenseCount})

{expenses.map((expense) => (
{expense.description} - ${expense.amount} ({expense.category}) { const current = expenses.get(); const index = current.findIndex((el) => Cell.equals(expense, el) ); if (index >= 0) { expenses.set(current.toSpliced(index, 1)); } }} > ×
))}
{/* Budget Management */}

Set Budget Limit

{ const cat = budgetCategory.get().trim(); const limitVal = parseFloat(budgetLimit.get()); if (cat && !isNaN(limitVal) && limitVal >= 0) { const current = budgets.get(); const existingIndex = current.findIndex((b) => b.category === cat ); if (existingIndex >= 0) { budgets.set( current.map(( b, i, ) => (i === existingIndex ? { ...b, limit: limitVal } : b)), ); } else { budgets.push({ category: cat, limit: limitVal }); } // Clear form budgetCategory.set(""); budgetLimit.set(""); } }} > Set Budget
{/* Budget List */}

Budgets ({budgetCount})

{budgets.map((budget) => (
{budget.category}: ${budget.limit} { const current = budgets.get(); const index = current.findIndex((b) => Cell.equals(budget, b) ); if (index >= 0) { budgets.set(current.toSpliced(index, 1)); } }} > ×
))}
{/* Debug */}
Debug: Form State
            {computed(() =>
              JSON.stringify(
                {
                  newDescription: newDescription.get(),
                  newAmount: newAmount.get(),
                  newCategory: newCategory.get(),
                  budgetCategory: budgetCategory.get(),
                  budgetLimit: budgetLimit.get(),
                },
                null,
                2
              )
            )}
          
Debug: Raw Data
            {computed(() =>
              JSON.stringify(
                {
                  expenses: expenses.get(),
                  budgets: budgets.get(),
                },
                null,
                2
              )
            )}
          
), // Export bound handlers as Streams (not shared cells - parent owns those) addExpense, setBudget, removeBudget, }; });