/// /** * Shopping List Pattern * * A shopping list with two modes: * 1. Quick List Mode: Fast, frictionless item entry (default) * 2. Sorted Mode: Items grouped by aisle using AI categorization * * When a store layout is provided, items are categorized by aisle using AI. */ import { computed, Default, derive, equals, generateObject, handler, ifElse, NAME, navigateTo, OpaqueRef, pattern, patternTool, Stream, UI, Writable, } from "commontools"; import StoreMapper from "./store-mapper.tsx"; // Item with optional aisle override for manual corrections interface ShoppingItem { title: string; done: Default; aisleSeed: Default; aisleOverride: Default; // User's manual aisle selection } // AI categorization result interface AisleResult { location: string; } interface Input { items: Writable>; storeLayout: Writable>; // Markdown store layout from Store Mapper } /** Shopping list with AI-powered aisle sorting. #shoppingList */ interface Output { items: ShoppingItem[]; summary: string; totalCount: number; doneCount: number; remainingCount: number; storeLayout: string; // Omnibot handlers addItem: OpaqueRef>; addItemForOmnibot: OpaqueRef>; addItems: OpaqueRef>; } // Demo store layout from Andronico's on Shattuck (community-patterns) // Used as fallback when no actual store layout is connected const DEMO_STORE_LAYOUT = `# Aisle 1 Soda & Beverages - Soda - Sparkling Water - Soft Drinks - Beverages # Aisle 2 Frozen Foods - Breakfast - Pizza - Vegetables - Frozen Dinners # Aisle 3 Cleaning & Paper - Charcoal / Logs - Paper Towels - Bath Tissue - Cleaning Supplies - Laundry # Aisle 4 Health & Beauty - Oral Care - Skin Care - Shampoo - Hair Care # Aisle 5 Pet & Baby - Cat Food - Dog Food - Baby Food - Feminine Care - Diapers # Aisle 6 International & Pasta - Asian - Hispanic - Packaged Dinners - Soups - Pasta # Aisle 7 Condiments & Cereal - Condiments - Pickles & Olives - Cereal - Hot Cereal # Aisle 8 Baking & Spices - Cups & Plates - Peanut Butter & Jam - Flour - Cooking Oil - Spices # Aisle 9 Coffee & Snacks - Coffee - Tea - Crackers - Cookies - Popcorn & Nuts # Aisle 10 Wine & Candy - Wine - Juices - Candy # Aisle 11 Spirits - Champagne - Spirits - Wine - Mixers # Aisle 12 Beer & Chips - Beer - Cold Beverages - Chips & Salsa - Water # Bakery (right) Fresh baked goods # Produce (right) Fresh fruits and vegetables # Bulk Bins (right) Bulk dry goods, nuts, grains # Deli (back) Prepared foods and deli meats # Fromagerie (back) Artisan cheese counter # Butcher (back) Meat counter # Seafood (back) Fresh seafood counter # Dairy (left) Milk, yogurt, cheese # Eggs (left) Fresh eggs # Breakfast Meats & Sausage (left) Bacon, sausage, breakfast meats `; // Handler for adding items via the message input const addItem = handler< { detail: { message: string } }, { items: Writable } >(({ detail }, { items }) => { const title = detail?.message?.trim(); if (!title) return; items.push({ title, done: false, aisleSeed: 0, aisleOverride: "", }); }); // Handler for omnibot to add a single item const addItemForOmnibot = handler< { itemText: string }, { items: Writable } >(({ itemText }, { items }) => { if (itemText && itemText.trim()) { items.push({ title: itemText.trim(), done: false, aisleSeed: 0, aisleOverride: "", }); } }); // Handler for omnibot to add multiple items at once const addItems = handler< { itemNames: string[] }, { items: Writable } >(({ itemNames }, { items }) => { itemNames.forEach((name) => { if (name && name.trim()) { items.push({ title: name.trim(), done: false, aisleSeed: 0, aisleOverride: "", }); } }); }); // Search sub-pattern for patternTool - filters items by query const searchItemsPattern = pattern< { items: ShoppingItem[]; query: string }, ShoppingItem[] >(({ items, query }) => { return computed(() => items.filter((item: ShoppingItem) => item.title.toLowerCase().includes(query.toLowerCase()) ) ); }); // Handler to navigate to store mapper const openStoreMapper = handler>( (_event, _state) => { return navigateTo( StoreMapper({ storeName: "My Store", aisles: [], departments: [], entrances: [], itemLocations: [], }), ); }, ); // Handler for removing an item const removeItem = handler< unknown, { items: Writable; item: ShoppingItem } >((_event, { items, item }) => { const current = items.get(); const index = current.findIndex((el) => equals(item, el)); if (index >= 0) { items.set(current.toSpliced(index, 1)); } }); // Handler for opening correction panel const openCorrection = handler< unknown, { items: Writable; item: ShoppingItem; correctionIndex: Writable; correctionTitle: Writable; } >((_event, { items, item, correctionIndex, correctionTitle }) => { const current = items.get(); const index = current.findIndex((el) => equals(item, el)); if (index >= 0) { correctionTitle.set(current[index]?.title || ""); correctionIndex.set(index); } }); // Handler for closing correction panel const closeCorrection = handler< unknown, { correctionIndex: Writable } >((_event, { correctionIndex }) => { correctionIndex.set(-1); }); // Handler for selecting an aisle correction const selectAisle = handler< unknown, { items: Writable; correctionIndex: Writable; selectedAisle: string; } >((_event, { items, correctionIndex, selectedAisle }) => { const idx = correctionIndex.get(); if (idx >= 0) { const itemsList = items.get(); const item = itemsList[idx]; if (item) { const updated = itemsList.map((i, index) => index === idx ? { ...i, aisleOverride: selectedAisle, // Store user's selection } : i ); items.set(updated); } } correctionIndex.set(-1); }); // Extract valid locations from store layout function extractLocations(layout: string): string[] { const locations: string[] = []; const lines = layout.split("\n"); for (const line of lines) { const match = line.match(/^#\s*(Aisle \d+[A-Za-z]?|[A-Za-z\s&]+)\s*(\(|$)/); if (match) { locations.push(match[1].trim()); } } locations.push("Other"); return locations; } export default pattern(({ items, storeLayout }) => { // UI state for view mode const viewMode = Writable.of<"quick" | "sorted">("quick"); // Store both index and title when opening correction panel const correctionIndex = Writable.of(-1); const correctionTitle = Writable.of(""); // Create search tool for omnibot const searchItems = patternTool(searchItemsPattern, { items }); // Computed for whether correction panel is open const isCorrecting = computed(() => correctionIndex.get() >= 0); // Computed statistics const totalCount = computed(() => items.get().length); const doneCount = computed(() => items.get().filter((i) => i.done).length); const remainingCount = derive( [totalCount, doneCount], ([total, done]) => total - done, ); // Combined stats string to avoid adjacent reactive text node rendering issues const statsText = derive( [remainingCount, doneCount], ([remaining, done]) => { const itemWord = remaining === 1 ? "item" : "items"; return `${remaining} ${itemWord} to get β’ ${done} checked off`; }, ); // Check if a real store layout is connected (not using demo fallback) const hasConnectedStore = computed(() => storeLayout.get().trim().length > 0); // Effective layout: use connected store or demo fallback const effectiveLayout = derive( storeLayout, (layout: string) => layout.trim().length > 0 ? layout : DEMO_STORE_LAYOUT, ); // Valid locations derived from effective layout const validLocations = derive( effectiveLayout, (layout: string) => extractLocations(layout), ); // AI categorization for each item (uses effectiveLayout which always has a value) const itemsWithAisles = items.map((item) => { // Build prompt using effective layout + item const categorizePrompt = derive( [effectiveLayout, item.title, item.aisleSeed], ([layout, title, seed]: [string, string, number]) => { return `Store layout:\n${layout}\n\nItem: ${title}\n\nSeed: ${seed}\n\nWhich aisle or department is this item most likely to be in? Respond with the exact location name.`; }, ); // Generate location using AI (only if layout exists) const aisleResult = generateObject({ system: "You are a grocery store assistant. Given a store layout and an item, determine which aisle or department the item is most likely to be in. You must respond with one of the exact locations from the store layout, or 'Other' if the item doesn't fit any category.", prompt: categorizePrompt, model: "anthropic:claude-haiku-4-5", }); return { item, aisle: aisleResult, }; }); return { [NAME]: "Shopping List", [UI]: ( {/* Header */} π Shopping List {statsText} viewMode.get() === "quick"), "primary", "secondary", )} size="sm" onClick={() => viewMode.set("quick")} > Quick viewMode.get() === "sorted"), "primary", "secondary", )} size="sm" onClick={() => viewMode.set("sorted")} > π Sorted πΊοΈ Store {/* Main scrollable content */} {/* Input field at top - with right padding to avoid FAB */} {/* QUICK LIST VIEW */} {ifElse( computed(() => viewMode.get() === "quick"), {/* Empty state */} {ifElse( computed(() => items.get().length === 0), Your shopping list is empty. Type above to add items! , null, )} {/* Item list */} {items.map((item) => ( Γ ))} {/* Demo layout notice */} {ifElse( derive(hasConnectedStore, (connected: boolean) => !connected), β οΈ Using demo store layout (Andronico's). Connect a Store Mapper for your actual store. , null, )} , null, )} {/* SORTED VIEW - Shows items with their AI-assigned aisles */} {ifElse( computed(() => viewMode.get() === "sorted"), {/* Items with aisles */} {itemsWithAisles.map((itemWithAisle) => ( {itemWithAisle.item.title} {/* Show aisle - prefer user override, then AI result */} {ifElse( derive( itemWithAisle.item.aisleOverride, (override: string) => !!override, ), {itemWithAisle.item.aisleOverride} , ifElse( itemWithAisle.aisle.pending, π sorting... , {derive( itemWithAisle.aisle.result, (r: AisleResult | undefined) => r?.location || "Other", )} , ), )} {/* Correction button */} βοΈ ))} {/* Demo layout notice */} {ifElse( derive(hasConnectedStore, (connected: boolean) => !connected), β οΈ Using demo store layout (Andronico's). Connect a Store Mapper for your actual store. , null, )} , null, )} {/* Correction panel (shown when correcting an item) */} {ifElse( isCorrecting, Where is "{correctionTitle}" actually located? β Cancel {validLocations.map((location) => { // Extract raw location string for handler const locationStr = derive(location, (l: string) => l); return ( {location} ); })} , null, )} ), items, summary: computed(() => { const remaining = items.get().filter((i) => !i.done); const names = remaining.slice(0, 10).map((i) => i.title); return names.join(", ") + (remaining.length > 10 ? ` (+${remaining.length - 10} more)` : ""); }), totalCount, doneCount, remainingCount, storeLayout, // Omnibot integration addItem: addItem({ items }), addItemForOmnibot: addItemForOmnibot({ items }), addItems: addItems({ items }), searchItems, }; });