/// /** * Store Mapper Pattern * * Captures grocery store layouts through: * - Manual aisle entry with descriptions * - Perimeter department positioning * - Entrance marking * - Item location corrections for future reference * * The generated store data is used by Shopping List for AI-powered aisle sorting. */ import { computed, Default, derive, equals, generateObject, handler, ifElse, NAME, pattern, UI, Writable, } from "commontools"; // Types for store layout type WallPosition = | "front-left" | "front-center" | "front-right" | "back-left" | "back-center" | "back-right" | "left-front" | "left-center" | "left-back" | "right-front" | "right-center" | "right-back" | "unassigned" | "not-in-store" | "in-center-aisle"; interface Aisle { name: string; // Just the number, e.g., "1", "2", "5A" description: Default; } interface Department { name: string; icon: string; location: Default; description: Default; } interface Entrance { position: WallPosition; } interface ItemLocation { itemName: string; correctAisle: string; incorrectAisle: Default; timestamp: number; } interface Input { storeName: Writable>; aisles: Writable>; departments: Writable>; entrances: Writable>; itemLocations: Writable>; } interface Output { storeName: string; aisles: Aisle[]; departments: Department[]; entrances: Entrance[]; itemLocations: ItemLocation[]; outline: string; aisleCount: number; deptCount: number; correctionCount: number; } // Type for position grouping in visual map (must be at module scope for pattern compiler) type ItemsByPos = Record< string, { depts: Department[]; entrances: Entrance[] } >; // Types for AI photo-based aisle import interface ImageData { id: string; name: string; url?: string; // data URL (preferred) data?: string; // data URL (for compatibility) } interface ExtractedAisle { name: string; products: string[]; } // Default departments to load const DEFAULT_DEPARTMENTS: Array<{ name: string; icon: string }> = [ { name: "Bakery", icon: "π₯" }, { name: "Deli", icon: "π₯ͺ" }, { name: "Produce", icon: "π₯¬" }, { name: "Dairy", icon: "π₯" }, { name: "Frozen Foods", icon: "π§" }, { name: "Meat & Seafood", icon: "π₯©" }, { name: "Pharmacy", icon: "π" }, ]; // Handlers const addAisle = handler< { detail: { message: string } }, { aisles: Writable } >(({ detail }, { aisles }) => { const name = detail?.message?.trim(); if (!name) return; // Check for duplicate aisle const existing = aisles.get(); if (existing.some((a) => a.name === name)) return; aisles.push({ name, description: "", }); }); const removeAisle = handler< unknown, { aisles: Writable; aisle: Aisle } >((_event, { aisles, aisle }) => { const current = aisles.get(); const index = current.findIndex((el) => equals(aisle, el)); if (index >= 0) { aisles.set(current.toSpliced(index, 1)); } }); // Entrance handlers const addEntrance = handler< unknown, { entrances: Writable; position: WallPosition } >((_event, { entrances, position }) => { const current = entrances.get(); // Don't add duplicate entrances at same position if (current.some((e) => e.position === position)) return; entrances.push({ position }); }); const removeEntrance = handler< unknown, { entrances: Writable; entrance: Entrance } >((_event, { entrances, entrance }) => { const current = entrances.get(); const index = current.findIndex((el) => equals(entrance, el)); if (index >= 0) { entrances.set(current.toSpliced(index, 1)); } }); // Department location handler const setDepartmentLocation = handler< unknown, { departments: Writable; dept: Department; location: WallPosition; } >((_event, { departments, dept, location }) => { const current = departments.get(); const index = current.findIndex((el) => equals(dept, el)); if (index >= 0) { departments.set( current.toSpliced(index, 1, { ...current[index], location }), ); } }); // Handlers for AI photo import const addExtractedAisle = handler< unknown, { aisles: Writable; extracted: ExtractedAisle } >((_event, { aisles, extracted }) => { const current = aisles.get() || []; const exists = current.some( (a: Aisle) => a.name.toLowerCase() === extracted.name.toLowerCase(), ); if (!exists) { aisles.push({ name: extracted.name, description: (extracted.products || []).map((p: string) => `- ${p}`).join( "\n", ), }); } }); const addAllExtractedAisles = handler< unknown, { aisles: Writable; extractedList: ExtractedAisle[]; hiddenPhotoIds: Writable; photoId: string; } >((_event, { aisles, extractedList, hiddenPhotoIds, photoId }) => { const current = aisles.get() || []; const existingNames = new Set( current.map((a: Aisle) => a.name.toLowerCase()), ); const newAisles = extractedList .filter((e) => !existingNames.has(e.name.toLowerCase())) .map((e) => ({ name: e.name, description: (e.products || []).map((p) => `- ${p}`).join("\n"), })); aisles.set([...current, ...newAisles]); // Hide the photo after adding const currentHidden = hiddenPhotoIds.get(); if (!currentHidden.includes(photoId)) { hiddenPhotoIds.set([...currentHidden, photoId]); } }); const mergeExtractedAisle = handler< unknown, { aisles: Writable; extracted: ExtractedAisle } >((_event, { aisles, extracted }) => { const current = aisles.get() || []; const idx = current.findIndex( (a: Aisle) => a.name.toLowerCase() === extracted.name.toLowerCase(), ); if (idx >= 0) { const existing = current[idx]; const existingItems = (existing.description || "") .split("\n") .map((l) => l.replace(/^-\s*/, "").trim().toLowerCase()) .filter(Boolean); const newProducts = (extracted.products || []).filter( (p) => !existingItems.includes(p.toLowerCase()), ); if (newProducts.length > 0) { const newDesc = existing.description ? existing.description + "\n" + newProducts.map((p) => `- ${p}`).join("\n") : newProducts.map((p) => `- ${p}`).join("\n"); aisles.set( current.toSpliced(idx, 1, { ...existing, description: newDesc }), ); } } }); const hidePhoto = handler< unknown, { hiddenPhotoIds: Writable; photoId: string } >((_event, { hiddenPhotoIds, photoId }) => { const current = hiddenPhotoIds.get() || []; if (!current.includes(photoId)) { hiddenPhotoIds.set([...current, photoId]); } }); export default pattern( ({ storeName, aisles, departments, entrances, itemLocations }) => { // Pre-load default departments if empty (using computed to safely access reactive value) const _initDepts = computed(() => { const current = departments.get(); if (current.length === 0) { // Schedule the set for next tick to avoid reactive cycle queueMicrotask(() => { departments.set( DEFAULT_DEPARTMENTS.map((d) => ({ ...d, location: "unassigned" as const, description: "", })), ); }); } return true; }); // Force evaluation of the computed void _initDepts; // UI state const currentSection = Writable.of< "map" | "aisles" | "departments" | "corrections" | "outline" >("map"); const newCorrectionItem = Writable.of(""); const newCorrectionAisle = Writable.of(""); // Photo import state const uploadedPhotos = Writable.of([]); const hiddenPhotoIds = Writable.of([]); // Process uploaded photos with AI // Note: Photos are NOT auto-deleted after "Add All" to prevent the photo extraction // reset bug. When uploadedPhotos array changes, this .map() re-evaluates and creates // new generateObject calls, resetting all photos to "Analyzing...". Users can manually // delete photos using the delete button. const photoExtractions = uploadedPhotos.map((photo) => { const extraction = generateObject({ system: 'You are analyzing photos from a grocery store. Your task is to extract ALL visible aisle signs and return them as JSON.\n\nIMPORTANT: You MUST return a JSON object with an "aisles" array, even if you only see one aisle or partial information.\n\nFor each aisle sign you see:\n- Extract ONLY the aisle number (e.g., "8", "12", "5A", "5B") - DO NOT include the word "Aisle"\n- Extract each product category as a separate item in the products array\n- Include partially visible signs - do your best to read them\n\nExample output:\n{\n "aisles": [\n {"name": "8", "products": ["Bread", "Cereal", "Coffee"]},\n {"name": "9", "products": ["Snacks", "Chips"]}\n ]\n}', prompt: derive(photo, (p) => { // Safety check: photo might be undefined after deletion if (!p || !p.data) return []; return [ { type: "image" as const, image: p.data }, { type: "text" as const, text: "Look at this grocery store photo and extract ALL aisle signs you can see. Return a JSON object with an 'aisles' array containing objects with 'name' (just the number like '5' or '5A', NOT 'Aisle 5') and 'products' (array of strings) fields. Each product category should be a separate item in the products array. Read any text on hanging signs, endcaps, or aisle markers.", }, ]; }), schema: { type: "object", properties: { aisles: { type: "array", description: "List of aisles detected in the photo", items: { type: "object", properties: { name: { type: "string", description: "Aisle number only (e.g., '8', '5A', '12') - do NOT include the word 'Aisle'", }, products: { type: "array", description: "Array of product categories in this aisle", items: { type: "string", }, }, }, }, }, }, }, model: "anthropic:claude-sonnet-4-5", }); return { photo, photoName: photo.name, extractedAisles: derive( extraction.result, (result: { aisles?: ExtractedAisle[] } | null) => ({ aisles: (result && result.aisles) || [], }), ), pending: extraction.pending, error: extraction.error, }; }); // Sorted aisles (natural numeric order) const sortedAisles = derive(aisles, (aisleList: Aisle[]) => { return [...aisleList].sort((a, b) => { const numA = parseInt(a.name.match(/^\d+/)?.[0] || "999", 10); const numB = parseInt(b.name.match(/^\d+/)?.[0] || "999", 10); if (numA !== numB) return numA - numB; return a.name.localeCompare(b.name); }); }); // Generate markdown outline const outline = computed(() => { const aislesSorted = [...aisles.get()].sort((a, b) => { const numA = parseInt(a.name.match(/^\d+/)?.[0] || "999", 10); const numB = parseInt(b.name.match(/^\d+/)?.[0] || "999", 10); if (numA !== numB) return numA - numB; return a.name.localeCompare(b.name); }); const depts = departments.get(); const corrections = itemLocations.get(); const lines: string[] = []; // Aisles for (const aisle of aislesSorted) { const knownItems = corrections .filter((c) => c.correctAisle === `Aisle ${aisle.name}`) .map((c) => c.itemName); const knownStr = knownItems.length > 0 ? ` (Known items: ${knownItems.join(", ")})` : ""; lines.push(`# Aisle ${aisle.name}${knownStr}`); lines.push(aisle.description || "(no description)"); lines.push(""); } // Departments with locations const assignedDepts = depts.filter( (d) => d.location !== "unassigned" && d.location !== "not-in-store" && d.location !== "in-center-aisle", ); for (const dept of assignedDepts) { const locStr = dept.location.replace("-", " "); const knownItems = corrections .filter((c) => c.correctAisle === dept.name) .map((c) => c.itemName); const knownStr = knownItems.length > 0 ? ` (Known items: ${knownItems.join(", ")})` : ""; lines.push(`# ${dept.name} (${locStr})${knownStr}`); lines.push(dept.description || "(no description)"); lines.push(""); } return lines.join("\n"); }); // Counts for reactive arrays const aisleCount = computed(() => aisles.get().length); const deptCount = computed(() => departments.get().length); const correctionCount = computed(() => itemLocations.get().length); const entranceCount = computed(() => entrances.get().length); // Group departments and entrances by position for the visual map const itemsByPosition = computed((): ItemsByPos => { const byPos: ItemsByPos = {}; // Add departments for (const dept of departments.get()) { if ( dept.location && dept.location !== "unassigned" && dept.location !== "not-in-store" && dept.location !== "in-center-aisle" ) { if (!byPos[dept.location]) { byPos[dept.location] = { depts: [], entrances: [] }; } byPos[dept.location].depts.push(dept); } } // Add entrances for (const entrance of entrances.get()) { if (!byPos[entrance.position]) { byPos[entrance.position] = { depts: [], entrances: [] }; } byPos[entrance.position].entrances.push(entrance); } return byPos; }); // Pre-compute entrance positions for button states (use Record instead of Set for JSON-serializable) const entrancePositions = computed(() => { const positions: Record = {}; for (const e of entrances.get()) { positions[e.position] = true; } return positions; }); // Gap detection for aisles const detectedGaps = derive(aisles, (aisleList: Aisle[]) => { const numbers = aisleList .map((a) => parseInt(a.name.match(/^\d+/)?.[0] || "", 10)) .filter((n) => !isNaN(n)) .sort((a, b) => a - b); const gaps: number[] = []; for (let i = 1; i < numbers.length; i++) { // Push all missing numbers in the gap, not just the first one for ( let missing = numbers[i - 1] + 1; missing < numbers[i]; missing++ ) { gaps.push(missing); } } return gaps; }); return { [NAME]: storeName, [UI]: ( {/* Header */} πΊοΈ {computed(() => `${aisleCount} aisles β’ ${deptCount} departments β’ ${entranceCount} entrances` )} {/* Navigation tabs */} Map Aisles Depts Fixes Outline {/* MAP SECTION */} {/* CSS for store map */} {/* Visual Store Map */} πͺ Store Layout {/* Corners */} {/* Back wall (top - orange) */} {derive(itemsByPosition, (items: ItemsByPos) => { const hasBL = (items["back-left"]?.entrances || []).length > 0; const hasBC = (items["back-center"]?.entrances || []).length > 0; const hasBR = (items["back-right"]?.entrances || []).length > 0; return ( <> {(items["back-left"]?.entrances || []).map( () => ( πͺ ), )} {(items["back-left"]?.depts || []).map(( d, ) => ( {d.icon} ))} {(items["back-center"]?.entrances || []).map( () => ( πͺ ), )} {(items["back-center"]?.depts || []).map(( d, ) => ( {d.icon} ))} {(items["back-right"]?.entrances || []).map( () => ( πͺ ), )} {(items["back-right"]?.depts || []).map(( d, ) => ( {d.icon} ))} > ); })} {/* Left wall (green) */} {derive(itemsByPosition, (items: ItemsByPos) => { const hasLB = (items["left-back"]?.entrances || []).length > 0; const hasLC = (items["left-center"]?.entrances || []).length > 0; const hasLF = (items["left-front"]?.entrances || []).length > 0; return ( <> {(items["left-back"]?.entrances || []).map( () => ( πͺ ), )} {(items["left-back"]?.depts || []).map(( d, ) => ( {d.icon} ))} {(items["left-center"]?.entrances || []).map( () => ( πͺ ), )} {(items["left-center"]?.depts || []).map(( d, ) => ( {d.icon} ))} {(items["left-front"]?.entrances || []).map( () => ( πͺ ), )} {(items["left-front"]?.depts || []).map(( d, ) => ( {d.icon} ))} > ); })} {/* Center */} Aisles {aisleCount} {/* Right wall (purple) */} {derive(itemsByPosition, (items: ItemsByPos) => { const hasRB = (items["right-back"]?.entrances || []).length > 0; const hasRC = (items["right-center"]?.entrances || []).length > 0; const hasRF = (items["right-front"]?.entrances || []).length > 0; return ( <> {(items["right-back"]?.entrances || []).map( () => ( πͺ ), )} {(items["right-back"]?.depts || []).map(( d, ) => ( {d.icon} ))} {(items["right-center"]?.entrances || []).map( () => ( πͺ ), )} {(items["right-center"]?.depts || []).map(( d, ) => ( {d.icon} ))} {(items["right-front"]?.entrances || []).map( () => ( πͺ ), )} {(items["right-front"]?.depts || []).map(( d, ) => ( {d.icon} ))} > ); })} {/* Front wall (bottom - blue) */} {derive(itemsByPosition, (items: ItemsByPos) => { const hasFL = (items["front-left"]?.entrances || []).length > 0; const hasFC = (items["front-center"]?.entrances || []).length > 0; const hasFR = (items["front-right"]?.entrances || []).length > 0; return ( <> {(items["front-left"]?.entrances || []).map( () => ( πͺ ), )} {(items["front-left"]?.depts || []).map(( d, ) => ( {d.icon} ))} {(items["front-center"]?.entrances || []).map( () => ( πͺ ), )} {(items["front-center"]?.depts || []).map(( d, ) => ( {d.icon} ))} {(items["front-right"]?.entrances || []).map( () => ( πͺ ), )} {(items["front-right"]?.depts || []).map(( d, ) => ( {d.icon} ))} > ); })} β {" "} Front (entrance){" "} β Back{" "} β Left{" "} β Right {/* Add Entrances Section */} πͺ Mark Store Entrances Click to add entrances: {/* Front wall buttons */} Front: ) => !!p["front-left"], )} onClick={addEntrance({ entrances, position: "front-left", })} > Left ) => !!p["front-center"], )} onClick={addEntrance({ entrances, position: "front-center", })} > Center ) => !!p["front-right"], )} onClick={addEntrance({ entrances, position: "front-right", })} > Right {/* Back wall buttons */} Back: ) => !!p["back-left"], )} onClick={addEntrance({ entrances, position: "back-left", })} > Left ) => !!p["back-center"], )} onClick={addEntrance({ entrances, position: "back-center", })} > Center ) => !!p["back-right"], )} onClick={addEntrance({ entrances, position: "back-right", })} > Right {/* Left wall buttons */} Left: ) => !!p["left-front"], )} onClick={addEntrance({ entrances, position: "left-front", })} > Front ) => !!p["left-center"], )} onClick={addEntrance({ entrances, position: "left-center", })} > Center ) => !!p["left-back"], )} onClick={addEntrance({ entrances, position: "left-back", })} > Back {/* Right wall buttons */} Right: ) => !!p["right-front"], )} onClick={addEntrance({ entrances, position: "right-front", })} > Front ) => !!p["right-center"], )} onClick={addEntrance({ entrances, position: "right-center", })} > Center ) => !!p["right-back"], )} onClick={addEntrance({ entrances, position: "right-back", })} > Back {/* Show added entrances */} {ifElse( derive(entranceCount, (c: number) => c > 0), Added ({entranceCount}): {entrances.map((entrance) => ( πͺ {entrance.position} Γ ))} , null, )} {/* Quick Actions */} currentSection.set("aisles")} style="flex: 1;" > + Add Aisles currentSection.set("departments")} style="flex: 1;" > + Add Departments {/* AISLES SECTION */} {/* Gap warning */} {ifElse( derive(detectedGaps, (gaps: number[]) => gaps.length > 0), β οΈ Missing aisle(s) detected:{" "} {derive(detectedGaps, (g: number[]) => g.join(", "))} , null, )} {/* Aisle list */} {sortedAisles.map((aisle) => ( Aisle {aisle.name} Γ ))} {/* Empty state */} {ifElse( computed(() => aisles.get().length === 0), No aisles yet. Add one below! , null, )} {/* Add aisle input */} {/* AI Photo Import Section */} π· Import Aisles from Photos Take photos of aisle signs - AI will extract aisle numbers and products automatically. {/* Photo extraction results */} {photoExtractions.map((extraction) => ifElse( computed(() => hiddenPhotoIds.get().includes(extraction.photo.id) ), null, π· {extraction.photoName} Γ {ifElse( extraction.pending, Analyzing photo... , ifElse( extraction.error, Error analyzing photo. Please try removing and re-uploading. , computed(() => { const extracted: { aisles: ExtractedAisle[]; } = extraction.extractedAisles; const currentAisles = aisles.get(); if ( !extracted?.aisles || extracted.aisles.length === 0 ) { return ( No aisles detected in photo ); } // Helper function to check if aisle exists // Uses .some() directly on currentAisles (works with reactive proxies) const aisleExists = (name: string) => { try { return currentAisles.some( (existing: Aisle) => existing?.name?.toLowerCase?.() === name.toLowerCase(), ); } catch { return false; } }; // Count new aisles const newCount = extracted.aisles.filter( (e) => !aisleExists(e.name), ).length; return ( {/* Batch add button */} {newCount > 0 && ( + Add All {newCount} New Aisles )} {/* Individual aisle results */} {extracted.aisles.map( (extractedAisle: ExtractedAisle) => { const exists = aisleExists( extractedAisle.name, ); return ( Aisle {extractedAisle.name} {exists && ( (exists) )} {(extractedAisle.products || []).join(", ") || "(no products)"} {exists ? ( Merge ) : ( Add )} ); }, )} ); }), ), )} , ) )} {/* DEPARTMENTS SECTION */} {/* Department list - unassigned shown first, assigned at bottom */} {computed(() => { const depts = departments.get(); // Sort: unassigned first, then assigned departments return [...depts].sort((a, b) => { const aAssigned = a.location !== "unassigned"; const bAssigned = b.location !== "unassigned"; if (aAssigned === bAssigned) return 0; return aAssigned ? 1 : -1; }); }).map((dept) => ( {dept.icon} {dept.name} l === "unassigned", ), "var(--ct-color-yellow-100)", "var(--ct-color-green-100)", ), }} > {dept.location} {/* Location button grid */} {/* Front wall */} Front: l === "front-left" ? "primary" : "outline", )} className="wall-btn-front" onClick={setDepartmentLocation({ departments, dept, location: "front-left", })} > Left l === "front-center" ? "primary" : "outline", )} className="wall-btn-front" onClick={setDepartmentLocation({ departments, dept, location: "front-center", })} > Center l === "front-right" ? "primary" : "outline", )} className="wall-btn-front" onClick={setDepartmentLocation({ departments, dept, location: "front-right", })} > Right {/* Back wall */} Back: l === "back-left" ? "primary" : "outline", )} className="wall-btn-back" onClick={setDepartmentLocation({ departments, dept, location: "back-left", })} > Left l === "back-center" ? "primary" : "outline", )} className="wall-btn-back" onClick={setDepartmentLocation({ departments, dept, location: "back-center", })} > Center l === "back-right" ? "primary" : "outline", )} className="wall-btn-back" onClick={setDepartmentLocation({ departments, dept, location: "back-right", })} > Right {/* Left wall */} Left: l === "left-front" ? "primary" : "outline", )} className="wall-btn-left" onClick={setDepartmentLocation({ departments, dept, location: "left-front", })} > Front l === "left-center" ? "primary" : "outline", )} className="wall-btn-left" onClick={setDepartmentLocation({ departments, dept, location: "left-center", })} > Center l === "left-back" ? "primary" : "outline", )} className="wall-btn-left" onClick={setDepartmentLocation({ departments, dept, location: "left-back", })} > Back {/* Right wall */} Right: l === "right-front" ? "primary" : "outline", )} className="wall-btn-right" onClick={setDepartmentLocation({ departments, dept, location: "right-front", })} > Front l === "right-center" ? "primary" : "outline", )} className="wall-btn-right" onClick={setDepartmentLocation({ departments, dept, location: "right-center", })} > Center l === "right-back" ? "primary" : "outline", )} className="wall-btn-right" onClick={setDepartmentLocation({ departments, dept, location: "right-back", })} > Back {/* Special locations */} Other: l === "in-center-aisle" ? "primary" : "outline", )} onClick={setDepartmentLocation({ departments, dept, location: "in-center-aisle", })} > Normal Aisle l === "not-in-store" ? "primary" : "outline", )} onClick={setDepartmentLocation({ departments, dept, location: "not-in-store", })} > N/A ))} {/* CORRECTIONS SECTION */} Add Item Correction Record where items are actually located for future reference. { const item = newCorrectionItem.get().trim(); const aisle = newCorrectionAisle.get().trim(); if (item && aisle) { const current = itemLocations.get(); const filtered = current.filter( (loc) => loc.itemName.toLowerCase() !== item.toLowerCase(), ); filtered.push({ itemName: item, correctAisle: aisle, incorrectAisle: "", timestamp: Date.now(), }); itemLocations.set(filtered); newCorrectionItem.set(""); newCorrectionAisle.set(""); } }} > Save Correction {/* Existing corrections */} {ifElse( computed(() => itemLocations.get().length > 0), Saved Corrections {itemLocations.map((loc) => ( {loc.itemName} β{" "} {loc.correctAisle} { const current = itemLocations.get(); const itemName = loc.itemName; itemLocations.set( current.filter( (l) => l.itemName.toLowerCase() !== itemName.toLowerCase(), ), ); }} > Γ ))} , No corrections saved yet. , )} {/* OUTLINE SECTION */} Store Layout Outline This outline is used by the Shopping List for AI-powered aisle sorting. {outline} ), storeName, aisles, departments, entrances, itemLocations, outline, aisleCount, deptCount, correctionCount, }; }, );
Record where items are actually located for future reference.
This outline is used by the Shopping List for AI-powered aisle sorting.
{outline}