/** * 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, equals, generateObject, handler, NAME, pattern, UI, Writable, } from "commonfabric"; // 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: string | Default<"">; } interface Department { name: string; icon: string; location: WallPosition | Default<"unassigned">; description: string | Default<"">; } interface Entrance { position: WallPosition; } interface ItemLocation { itemName: string; correctAisle: string; incorrectAisle: string | Default<"">; timestamp: number; } interface Input { storeName: Writable>; aisles: Writable>; departments: Writable>; entrances: Writable>; itemLocations: Writable>; } export 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?: string; } type ImageUploadEvent = { detail?: { images?: ImageData[]; files?: ImageData[]; }; }; const appendUploadedPhotos = handler< ImageUploadEvent, { uploadedPhotos: Writable } >(({ detail }, { uploadedPhotos }) => { const uploaded = detail?.images ?? detail?.files ?? []; if (uploaded.length === 0) return; uploadedPhotos.set([ ...(uploadedPhotos.get() ?? []), ...uploaded, ]); }); export interface ExtractedAisle { name: string; // The AI extraction may omit or null the products list. products?: string[] | null; } // 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: "", }); }); // Exported for tests. export 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 // Exported for tests. Writes through the element's cell (`.key(index)`) β€” // replacing the array slot with a fresh object literal would re-mint the // department's entity identity and orphan previously-held references // (see packages/patterns/primitives/editable-list.tsx). export 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.key(index).key("location").set(location); } }); // Handlers for AI photo import // Exported for tests. export 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 hiddenPhotoIds.addUnique(photoId); }); // Exported for tests. Writes the merged description through the element's // cell instead of replacing the array slot β€” slot replacement re-mints the // aisle's entity identity and orphans previously-held references. export 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.key(idx).key("description").set(newDesc); } } }); const hidePhoto = handler< unknown, { hiddenPhotoIds: Writable; photoId: string } >((_event, { hiddenPhotoIds, photoId }) => { hiddenPhotoIds.addUnique(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) { 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 = new Writable< "map" | "aisles" | "departments" | "corrections" | "outline" >("map"); const newCorrectionItem = new Writable(""); const newCorrectionAisle = new Writable(""); // Photo import state const uploadedPhotos = new Writable([]); const hiddenPhotoIds = new Writable([]); // 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<{ aisles: ExtractedAisle[] }>({ 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: computed(() => { // Safety check: photo might be undefined after deletion const image = photo?.data || photo?.url; if (!image) return []; return [ { type: "image" as const, image }, { 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", }, }, }, }, }, }, }, }); return { photo, photoName: photo.name, extractedAisles: { aisles: (extraction.result && extraction.result.aisles) || [], }, pending: extraction.pending, error: extraction.error, }; }); // Sorted aisles (natural numeric order) const sortedAisles = computed(() => { return [...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); }); }); // 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 = aisles.get().length; const deptCount = departments.get().length; const correctionCount = itemLocations.get().length; const entranceCount = 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 = computed(() => { const numbers = aisles.get() .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 */}
πŸ—ΊοΈ
{`${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) */}
{computed(() => { const items = itemsByPosition; 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) */}
{computed(() => { const items = itemsByPosition; 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) */}
{computed(() => { const items = itemsByPosition; 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) */}
{computed(() => { const items = itemsByPosition; 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: Left Center Right
{/* Back wall buttons */}
Back: Left Center Right
{/* Left wall buttons */}
Left: Front Center Back
{/* Right wall buttons */}
Right: Front Center Back
{/* Show added entrances */} {entranceCount > 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 */} {detectedGaps.length > 0 ? ( ⚠️ Missing aisle(s) detected:{" "} {detectedGaps.join(", ")} ) : null} {/* Aisle list */} {sortedAisles.map((aisle) => (
Aisle {aisle.name}
Γ—
))} {/* Empty state */} {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) => hiddenPhotoIds.get().includes(extraction.photo.id) ? null : (
πŸ“· {extraction.photoName} Γ— {extraction.pending ? (
Analyzing photo...
) : 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
); } const sourceAisles: Aisle[] = Array.isArray( currentAisles, ) ? currentAisles : []; const existingAisleNames = sourceAisles.map( (existing: Aisle) => existing?.name?.toLowerCase?.() ?? "", ); // Count new aisles const newCount = extracted.aisles.filter( (e) => !existingAisleNames.includes( e.name.toLowerCase(), ), ).length; return ( {/* Batch add button */} {newCount > 0 && ( + Add All {newCount} New Aisles )} {/* Individual aisle results */} {extracted.aisles.map( (extractedAisle: ExtractedAisle) => { const exists = existingAisleNames .includes( extractedAisle.name .toLowerCase(), ); 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} {dept.location} {/* Location button grid */} {/* Front wall */}
Front: Left Center Right
{/* Back wall */}
Back: Left Center Right
{/* Left wall */}
Left: Front Center Back
{/* Right wall */}
Right: Front Center Back
{/* Special locations */}
Other: Normal Aisle 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 */} {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, }; }, );