/// /** * Dietary Restrictions Module - Pattern for tracking all dietary needs * * Handles allergies, intolerances, preferences, and lifestyle diets * with a unified severity system that adapts context to the restriction type. * * Features: * - Generic severity levels (Flexible → Prefer → Strict → Absolute) * - Contextual descriptions (e.g., "Absolute" shows as "Severe Allergy" for peanuts) * - Two-list UI: EXPLICIT (what you added) vs IMPLIED (what it expands to) * - Bidirectional autocomplete (typing "milk" suggests "dairy" and vice versa) * - Comprehensive dietary patterns (vegetarian, vegan, halal, kosher, keto, etc.) */ import { Cell, computed, type Default, handler, lift, NAME, recipe, UI, } from "commontools"; import type { ModuleMetadata } from "./container-protocol.ts"; // ===== Self-Describing Metadata ===== export const MODULE_METADATA: ModuleMetadata = { type: "dietary-restrictions", label: "Dietary Restrictions", icon: "\u{1F37D}\u{FE0F}", // 🍽️ plate with cutlery schema: { restrictions: { type: "array", items: { type: "object", properties: { name: { type: "string", description: "Restriction name" }, level: { type: "string", enum: ["flexible", "prefer", "strict", "absolute"], description: "Restriction level", }, }, }, description: "List of dietary restrictions", }, }, fieldMapping: ["restrictions", "dietary", "allergies", "diet"], }; // ===== Data Types ===== /** * Generic restriction levels that work for both allergies and preferences: * - flexible: "If convenient" (e.g., "I'll eat vegetarian if it's easy") * - prefer: "Unless inconvenient" (e.g., "I prefer to avoid dairy") * - strict: "Strong preference" (e.g., "I don't eat meat") * - absolute: "No exceptions" (e.g., "Severe peanut allergy") */ export type RestrictionLevel = "flexible" | "prefer" | "strict" | "absolute"; export interface RestrictionEntry { name: string; level: RestrictionLevel; } export interface DietaryRestrictionsInput { restrictions: Default; } // ===== Restriction Categories ===== // Categories determine contextual labels for severity levels type RestrictionCategory = "allergy" | "intolerance" | "diet" | "medical"; interface RestrictionGroupInfo { members: string[]; category: RestrictionCategory; defaultLevel: RestrictionLevel; description?: string; } // ===== Level Display Configuration ===== interface LevelConfig { bg: string; color: string; border: string; icon: string; labels: Record; } const LEVEL_CONFIG: Record = { flexible: { bg: "#fef9c3", // yellow-100 color: "#a16207", // yellow-700 border: "#fde047", // yellow-300 icon: "•", // yellow dot labels: { allergy: "Mild Sensitivity", intolerance: "Slight Intolerance", diet: "If Convenient", medical: "Slight Issue", }, }, prefer: { bg: "#ffedd5", // orange-100 color: "#c2410c", // orange-700 border: "#fdba74", // orange-300 icon: "•", // orange dot labels: { allergy: "Sensitivity", intolerance: "Intolerance", diet: "Prefer to Avoid", medical: "Should Avoid", }, }, strict: { bg: "#fee2e2", // red-100 color: "#b91c1c", // red-700 border: "#fca5a5", // red-300 icon: "•", // red dot labels: { allergy: "Allergy", intolerance: "Strong Intolerance", diet: "Strict", medical: "Must Avoid", }, }, absolute: { bg: "#1f2937", // gray-800 color: "#ffffff", // white border: "#374151", // gray-700 icon: "•", // black/white dot labels: { allergy: "Severe Allergy", intolerance: "Severe Intolerance", diet: "Absolute", medical: "Dangerous", }, }, }; // ===== Comprehensive Restriction Database ===== const RESTRICTION_GROUPS: Record = { // ===== Lifestyle Diets ===== vegetarian: { members: [ "beef", "pork", "lamb", "chicken", "turkey", "duck", "veal", "venison", "bacon", "ham", "sausage", "hot dogs", "deli meats", "pepperoni", "salami", "prosciutto", "gelatin", "lard", "meat broth", "bone broth", "fish", "salmon", "tuna", "shrimp", "crab", "lobster", "oysters", "anchovies", ], category: "diet", defaultLevel: "strict", description: "No meat or fish", }, vegan: { members: [ // All meats "beef", "pork", "lamb", "chicken", "turkey", "duck", "veal", "venison", "bacon", "ham", "sausage", "hot dogs", "gelatin", "lard", // All seafood "fish", "salmon", "tuna", "shrimp", "crab", "lobster", "oysters", "anchovies", // All dairy "milk", "cheese", "butter", "yogurt", "cream", "ice cream", "whey", "casein", "ghee", // All eggs "eggs", "mayonnaise", // Other animal products "honey", "beeswax", "lanolin", "carmine", "shellac", "bone char", ], category: "diet", defaultLevel: "strict", description: "No animal products", }, pescatarian: { members: [ "beef", "pork", "lamb", "chicken", "turkey", "duck", "veal", "venison", "bacon", "ham", "sausage", "hot dogs", "deli meats", "pepperoni", "salami", "prosciutto", "gelatin", "lard", ], category: "diet", defaultLevel: "strict", description: "No land meat (fish okay)", }, "poultry-free": { members: ["chicken", "turkey", "duck", "goose", "quail", "pheasant"], category: "diet", defaultLevel: "prefer", description: "No poultry", }, halal: { members: [ "pork", "bacon", "ham", "lard", "pepperoni", "salami", "prosciutto", "alcohol", "wine", "beer", "spirits", "gelatin", "non-halal meat", "blood", ], category: "diet", defaultLevel: "absolute", description: "Islamic dietary law", }, kosher: { members: [ "pork", "bacon", "ham", "lard", "shellfish", "shrimp", "crab", "lobster", "oysters", "clams", "mussels", "scallops", "mixing meat and dairy", "non-kosher meat", "blood", ], category: "diet", defaultLevel: "absolute", description: "Jewish dietary law", }, keto: { members: [ "bread", "pasta", "rice", "potatoes", "sugar", "honey", "maple syrup", "corn", "beans", "lentils", "oats", "cereal", "crackers", "chips", "fruit juice", "soda", "beer", "most fruits", "bananas", "grapes", "mangoes", ], category: "diet", defaultLevel: "prefer", description: "Low-carb, high-fat", }, paleo: { members: [ "grains", "wheat", "bread", "pasta", "rice", "oats", "legumes", "beans", "lentils", "peanuts", "dairy", "milk", "cheese", "yogurt", "refined sugar", "processed foods", "vegetable oils", "canola oil", "soybean oil", ], category: "diet", defaultLevel: "prefer", description: "Ancestral diet", }, "whole30": { members: [ "sugar", "alcohol", "grains", "legumes", "soy", "dairy", "carrageenan", "MSG", "sulfites", "baked goods", "junk food", ], category: "diet", defaultLevel: "strict", description: "30-day elimination diet", }, fodmap: { members: [ "garlic", "onions", "wheat", "rye", "lactose", "milk", "apples", "pears", "watermelon", "honey", "high fructose corn syrup", "beans", "lentils", "chickpeas", "cashews", "pistachios", "mushrooms", "cauliflower", "asparagus", ], category: "medical", defaultLevel: "strict", description: "Low FODMAP for IBS", }, // ===== Food Allergies (Top 9 + Common) ===== dairy: { members: [ "milk", "cheese", "butter", "yogurt", "cream", "ice cream", "whey", "casein", "lactose", "ghee", "sour cream", "cream cheese", "cottage cheese", "ricotta", "mozzarella", "parmesan", "brie", "gouda", ], category: "allergy", defaultLevel: "strict", description: "Milk and milk products", }, eggs: { members: [ "whole eggs", "egg whites", "egg yolks", "mayonnaise", "meringue", "custard", "hollandaise", "aioli", "egg wash", "albumin", "lysozyme", "globulin", ], category: "allergy", defaultLevel: "strict", description: "Eggs and egg-derived ingredients", }, "tree nuts": { members: [ "almonds", "cashews", "walnuts", "pecans", "pistachios", "macadamia nuts", "brazil nuts", "hazelnuts", "chestnuts", "pine nuts", "almond butter", "almond milk", "cashew milk", "walnut oil", "pralines", "marzipan", "nougat", "gianduja", ], category: "allergy", defaultLevel: "absolute", description: "Tree nut allergens", }, peanuts: { members: [ "peanut butter", "peanut oil", "peanut flour", "ground nuts", "beer nuts", "mixed nuts", "arachis oil", "monkey nuts", ], category: "allergy", defaultLevel: "absolute", description: "Peanut allergens", }, shellfish: { members: [ "shrimp", "crab", "lobster", "crawfish", "prawns", "langoustine", "oysters", "clams", "mussels", "scallops", "squid", "octopus", "abalone", "snails", "escargot", ], category: "allergy", defaultLevel: "absolute", description: "Crustaceans and mollusks", }, fish: { members: [ "salmon", "tuna", "cod", "halibut", "tilapia", "trout", "mackerel", "sardines", "anchovies", "bass", "swordfish", "mahi mahi", "snapper", "flounder", "fish sauce", "fish oil", "omega-3 supplements", "caesar dressing", "worcestershire sauce", ], category: "allergy", defaultLevel: "strict", description: "Finfish allergens", }, wheat: { members: [ "bread", "pasta", "flour", "cereal", "crackers", "cookies", "cakes", "pastries", "couscous", "bulgur", "semolina", "durum", "farro", "seitan", "soy sauce", "beer", ], category: "allergy", defaultLevel: "strict", description: "Wheat allergens", }, soy: { members: [ "tofu", "tempeh", "edamame", "soy milk", "soy sauce", "miso", "soy lecithin", "soybean oil", "soy protein", "textured vegetable protein", "TVP", ], category: "allergy", defaultLevel: "strict", description: "Soy allergens", }, sesame: { members: [ "sesame seeds", "sesame oil", "tahini", "hummus", "halvah", "sesame paste", "benne seeds", "gingelly oil", ], category: "allergy", defaultLevel: "strict", description: "Sesame allergens", }, // ===== Food Families ===== nightshades: { members: [ "potatoes", "tomatoes", "eggplant", "bell peppers", "chili peppers", "cayenne", "paprika", "goji berries", "tomatillos", "pimentos", "jalapeños", "habaneros", "hot sauce", "salsa", "ketchup", "marinara", ], category: "intolerance", defaultLevel: "prefer", description: "Solanaceae family", }, "gluten grains": { members: [ "wheat", "barley", "rye", "spelt", "kamut", "triticale", "semolina", "durum", "farro", "bulgur", "beer", "malt", "seitan", ], category: "intolerance", defaultLevel: "strict", description: "Gluten-containing grains", }, gluten: { members: [ "wheat", "barley", "rye", "bread", "pasta", "beer", "flour", "cereal", "crackers", "cookies", "cakes", "soy sauce", "malt", "seitan", ], category: "intolerance", defaultLevel: "strict", description: "All gluten sources", }, legumes: { members: [ "peanuts", "soybeans", "lentils", "chickpeas", "black beans", "kidney beans", "lima beans", "peas", "navy beans", "pinto beans", "hummus", "falafel", ], category: "intolerance", defaultLevel: "prefer", description: "Bean and legume family", }, alliums: { members: [ "onions", "garlic", "leeks", "shallots", "scallions", "chives", "green onions", "spring onions", ], category: "intolerance", defaultLevel: "prefer", description: "Onion family", }, cruciferous: { members: [ "broccoli", "cauliflower", "cabbage", "brussels sprouts", "kale", "bok choy", "radishes", "arugula", "watercress", "collard greens", "horseradish", "wasabi", ], category: "intolerance", defaultLevel: "flexible", description: "Brassica family", }, citrus: { members: [ "oranges", "lemons", "limes", "grapefruit", "tangerines", "clementines", "pomelos", "kumquats", "orange juice", "lemonade", ], category: "allergy", defaultLevel: "prefer", description: "Citrus fruits", }, "stone fruits": { members: [ "peaches", "plums", "cherries", "apricots", "nectarines", "mangoes", "lychee", ], category: "allergy", defaultLevel: "prefer", description: "Prunus family fruits", }, berries: { members: [ "strawberries", "blueberries", "raspberries", "blackberries", "cranberries", "gooseberries", "elderberries", ], category: "allergy", defaultLevel: "prefer", description: "Berry fruits", }, "tropical fruits": { members: [ "pineapple", "papaya", "kiwi", "passion fruit", "guava", "dragon fruit", "starfruit", "jackfruit", ], category: "allergy", defaultLevel: "prefer", description: "Tropical fruits", }, melons: { members: [ "watermelon", "cantaloupe", "honeydew", "casaba", "crenshaw", ], category: "allergy", defaultLevel: "flexible", description: "Melon family", }, seeds: { members: [ "sesame", "sunflower seeds", "pumpkin seeds", "poppy seeds", "flax seeds", "chia seeds", "hemp seeds", ], category: "allergy", defaultLevel: "prefer", description: "Edible seeds", }, corn: { members: [ "corn", "popcorn", "corn syrup", "high fructose corn syrup", "cornstarch", "corn flour", "corn oil", "polenta", "grits", "tortillas", "corn chips", "dextrose", "maltodextrin", ], category: "allergy", defaultLevel: "prefer", description: "Corn and corn-derived", }, // ===== Common Intolerances ===== lactose: { members: [ "milk", "cream", "ice cream", "soft cheese", "cottage cheese", "ricotta", "whey", ], category: "intolerance", defaultLevel: "prefer", description: "Lactose (milk sugar)", }, fructose: { members: [ "apples", "pears", "mangoes", "honey", "high fructose corn syrup", "agave", "fruit juice", "dried fruits", ], category: "intolerance", defaultLevel: "prefer", description: "Fructose intolerance", }, histamine: { members: [ "aged cheese", "fermented foods", "wine", "beer", "sauerkraut", "pickles", "smoked meats", "cured meats", "vinegar", "soy sauce", "canned fish", "avocado", "spinach", "tomatoes", ], category: "intolerance", defaultLevel: "prefer", description: "Histamine intolerance", }, sulfites: { members: [ "wine", "beer", "dried fruits", "grape juice", "pickles", "sauerkraut", "vinegar", "shrimp", "processed potatoes", "maraschino cherries", ], category: "intolerance", defaultLevel: "prefer", description: "Sulfite sensitivity", }, caffeine: { members: [ "coffee", "espresso", "tea", "black tea", "green tea", "energy drinks", "cola", "chocolate", "cocoa", ], category: "intolerance", defaultLevel: "flexible", description: "Caffeine sensitivity", }, alcohol: { members: [ "wine", "beer", "spirits", "liquor", "cocktails", "champagne", "sake", "cooking wine", "vanilla extract", "rum cake", ], category: "diet", defaultLevel: "strict", description: "Alcoholic beverages", }, // ===== Medical Conditions ===== "g6pd deficiency": { members: [ "fava beans", "broad beans", "sulfa drugs", "certain legumes", ], category: "medical", defaultLevel: "absolute", description: "G6PD enzyme deficiency", }, "mast cell": { members: [ "alcohol", "fermented foods", "aged foods", "leftover food", "citrus", "tomatoes", "shellfish", "artificial colors", "preservatives", ], category: "medical", defaultLevel: "strict", description: "Mast cell activation", }, salicylates: { members: [ "aspirin", "berries", "grapes", "oranges", "apricots", "pineapple", "plums", "prunes", "raisins", "almonds", "honey", "wine", "vinegar", "mint", "spices", ], category: "intolerance", defaultLevel: "prefer", description: "Salicylate sensitivity", }, tyramine: { members: [ "aged cheese", "cured meats", "fermented foods", "soy sauce", "miso", "beer", "wine", "overripe bananas", "avocados", "sauerkraut", ], category: "medical", defaultLevel: "prefer", description: "Tyramine (MAOi diet)", }, oxalates: { members: [ "spinach", "rhubarb", "beets", "swiss chard", "nuts", "chocolate", "tea", "sweet potatoes", "beans", ], category: "medical", defaultLevel: "prefer", description: "Oxalate restriction", }, purines: { members: [ "organ meats", "anchovies", "sardines", "mussels", "scallops", "trout", "tuna", "bacon", "beer", ], category: "medical", defaultLevel: "prefer", description: "Low-purine (gout)", }, }; // Common individual items (not part of groups) const INDIVIDUAL_ITEMS: string[] = [ "msg", "aspartame", "red meat", "coconut", "avocado", "banana", "mustard", "celery", "lupin", "mollusks", "buckwheat", "spelt", ]; // ===== Helper Functions ===== function isGroup(name: string | unknown): boolean { if (typeof name !== "string") return false; return name.toLowerCase() in RESTRICTION_GROUPS; } function getGroup(name: string | unknown): RestrictionGroupInfo | undefined { if (typeof name !== "string") return undefined; return RESTRICTION_GROUPS[name.toLowerCase()]; } function getCategory(name: string): RestrictionCategory { return getGroup(name)?.category || "allergy"; } function getDefaultLevel(name: string): RestrictionLevel { return getGroup(name)?.defaultLevel || "prefer"; } // Build reverse index: member -> parent groups function buildParentIndex(): Map { const index = new Map(); for (const [groupName, group] of Object.entries(RESTRICTION_GROUPS)) { for (const member of group.members) { const lower = member.toLowerCase(); const parents = index.get(lower) || []; parents.push(groupName); index.set(lower, parents); } } return index; } const PARENT_INDEX = buildParentIndex(); function getParentGroups(item: string | unknown): string[] { if (typeof item !== "string") return []; return PARENT_INDEX.get(item.toLowerCase()) || []; } // Get all searchable items for autocomplete function getAllSearchableItems(): string[] { const groups = Object.keys(RESTRICTION_GROUPS); const allMembers = Object.values(RESTRICTION_GROUPS).flatMap((g) => g.members ); const unique = [...new Set([...groups, ...allMembers, ...INDIVIDUAL_ITEMS])]; return unique.sort(); } /** * Bidirectional search: * - Matches items containing query * - If query matches a group, also suggests its members * - If query matches a member, also suggests its parent groups */ function _searchRestrictions(input: string, existing: string[]): string[] { const query = input.toLowerCase().trim(); if (!query || query.length < 2) return []; const existingLower = new Set(existing.map((e) => e.toLowerCase())); const results = new Set(); // Direct matches for (const item of getAllSearchableItems()) { if ( item.toLowerCase().includes(query) && !existingLower.has(item.toLowerCase()) ) { results.add(item); } } // If query matches a group name, suggest its top members for (const [groupName, group] of Object.entries(RESTRICTION_GROUPS)) { if (groupName.includes(query)) { for (const member of group.members.slice(0, 5)) { if (!existingLower.has(member.toLowerCase())) { results.add(member); } } } } // If query matches a member, suggest parent groups for (const [member, parents] of PARENT_INDEX.entries()) { if (member.includes(query)) { for (const parent of parents) { if (!existingLower.has(parent.toLowerCase())) { results.add(parent); } } } } // Sort: groups first, then members return [...results] .sort((a, b) => { const aIsGroup = isGroup(a); const bIsGroup = isGroup(b); if (aIsGroup && !bIsGroup) return -1; if (!aIsGroup && bIsGroup) return 1; return a.localeCompare(b); }) .slice(0, 12); } // Get contextual label for a restriction function getContextualLabel(name: string, level: RestrictionLevel): string { const category = getCategory(name); return LEVEL_CONFIG[level].labels[category]; } // ===== Autocomplete Items ===== // Build items for ct-autocomplete with searchAliases for bidirectional search interface AutocompleteItem { value: string; label: string; group?: string; searchAliases?: string[]; } function buildAutocompleteItems(): AutocompleteItem[] { const items: AutocompleteItem[] = []; const allMembers = new Set(); // Add all groups WITHOUT member aliases (too expensive for large groups) // Groups are found by their name directly; members are separate items for (const [groupName, info] of Object.entries(RESTRICTION_GROUPS)) { items.push({ value: groupName, label: `📦 ${groupName}`, group: info.category, // No searchAliases - typing "milk" finds "milk" item, "dairy" finds "dairy" group }); info.members.forEach((m) => allMembers.add(m)); } // Add individual members with parent groups as searchAliases for (const member of allMembers) { const parents = getParentGroups(member); items.push({ value: member, label: member, group: getCategory(member), searchAliases: parents, // Typing "dairy" will match "milk" }); } return items; } // Lazy-init singleton for autocomplete items (defers work until first use) let _cachedAutocompleteItems: AutocompleteItem[] | null = null; function getAutocompleteItems(): AutocompleteItem[] { if (!_cachedAutocompleteItems) { _cachedAutocompleteItems = buildAutocompleteItems(); } return _cachedAutocompleteItems; } // ===== Computed UI helpers (stable identity, no inline callbacks) ===== // Empty state for when no restrictions const emptyState = ( 🍽️ No dietary restrictions added Search for allergies, diets (vegetarian, keto), or intolerances ); // ===== Handlers ===== const _addRestriction = handler< unknown, { restrictions: Cell; input: Cell; selectedLevel: Cell; } >((_event, { restrictions, input, selectedLevel }) => { const name = input.get().trim(); if (!name) return; const current = restrictions.get() || []; if (current.some((r) => r.name.toLowerCase() === name.toLowerCase())) { input.set(""); return; } // Use group default level if available const level = isGroup(name) ? getDefaultLevel(name) : selectedLevel.get(); restrictions.set([...current, { name, level }]); input.set(""); }); const removeRestriction = handler< unknown, { restrictions: Cell; index: number } >((_event, { restrictions, index }) => { const current = restrictions.get() || []; restrictions.set(current.toSpliced(index, 1)); }); // Level cycling order: flexible → prefer → strict → absolute → flexible const LEVEL_CYCLE: Record = { flexible: "prefer", prefer: "strict", strict: "absolute", absolute: "flexible", }; const cycleLevel = handler< unknown, { restrictions: Cell; index: number } >((_event, { restrictions, index }) => { const current = restrictions.get() || []; const entry = current[index]; if (!entry) return; const nextLevel = LEVEL_CYCLE[entry.level]; const updated = [...current]; updated[index] = { ...entry, level: nextLevel }; restrictions.set(updated); }); const _selectSuggestion = handler< unknown, { restrictions: Cell; input: Cell; selectedLevel: Cell; suggestion: string; } >((_event, { restrictions, input, selectedLevel, suggestion }) => { const current = restrictions.get() || []; if (current.some((r) => r.name.toLowerCase() === suggestion.toLowerCase())) { input.set(""); return; } const level = isGroup(suggestion) ? getDefaultLevel(suggestion) : selectedLevel.get(); restrictions.set([...current, { name: suggestion, level }]); input.set(""); }); // Handler for ct-autocomplete's ct-select event const onSelectRestriction = handler< CustomEvent<{ value: string; label: string; isCustom?: boolean }>, { restrictions: Cell; selectedLevel: Cell; } >((event, { restrictions, selectedLevel }) => { const { value } = event.detail; const current = restrictions.get() || []; // Don't add duplicates if (current.some((r) => r.name.toLowerCase() === value.toLowerCase())) { return; } // Use group default level if available, otherwise use selected level const level = isGroup(value) ? getDefaultLevel(value) : selectedLevel.get(); restrictions.set([...current, { name: value, level }]); }); // ===== Module Recipe ===== // Level priority lookup for fast comparison (avoids indexOf on every comparison) const LEVEL_PRIORITY: Record = { flexible: 0, prefer: 1, strict: 2, absolute: 3, }; export const DietaryRestrictionsModule = recipe< DietaryRestrictionsInput, DietaryRestrictionsInput >("DietaryRestrictionsModule", ({ restrictions }) => { const selectedLevel = Cell.of("prefer"); // Cache for impliedItems to avoid recomputation when restrictions haven't changed let _cachedImpliedItems: Array<{ name: string; level: RestrictionLevel; sources: string[]; }> = []; let _lastRestrictionsHash = ""; // Compute implied items (expanded from groups) - memoized // VERIFIED: This computed() only runs when restrictions change, not on autocomplete keypress. // Console instrumentation confirmed memoization works correctly (Dec 2025). const impliedItems = computed(() => { const current = (restrictions || []) as RestrictionEntry[]; // Full hash to catch ALL item changes (including middle items) const hash = current.map((e) => `${e.name}:${e.level}`).join("|"); if (hash === _lastRestrictionsHash) { return _cachedImpliedItems; } _lastRestrictionsHash = hash; const implied = new Map< string, { level: RestrictionLevel; sources: string[] } >(); // Optimized loop with hoisted lookups for (let i = 0; i < current.length; i++) { const entry = current[i]; const entryName = entry.name; const entryLevel = entry.level; const entryPriority = LEVEL_PRIORITY[entryLevel]; const group = getGroup(entryName); if (!group) continue; // Skip non-groups early const members = group.members; for (let j = 0; j < members.length; j++) { const lower = members[j].toLowerCase(); const existing = implied.get(lower); if (!existing) { implied.set(lower, { level: entryLevel, sources: [entryName] }); } else if (entryPriority > LEVEL_PRIORITY[existing.level]) { existing.level = entryLevel; existing.sources.push(entryName); // Mutate instead of spread copy } else { existing.sources.push(entryName); // Mutate instead of spread copy } } } _cachedImpliedItems = [...implied.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, info]) => ({ name, ...info })); return _cachedImpliedItems; }); const displayText = computed(() => { const count = (restrictions || []).length || 0; if (count === 0) return "None"; return `${count} restriction${count !== 1 ? "s" : ""}`; }); // Use lift() for UI transforms that need handler bindings // lift() preserves the Cell reference for handlers while allowing value access // VERIFIED: These lift() functions only run when restrictions change, not on autocomplete keypress (Dec 2025). const restrictionsUI = lift( ({ list, restrictionsCell, }: { list: RestrictionEntry[]; restrictionsCell: Cell; }) => { if (!list || list.length === 0) return emptyState; return ( Your Restrictions {list.map((entry: RestrictionEntry, index: number) => { const style = LEVEL_CONFIG[entry.level] || LEVEL_CONFIG.prefer; const contextLabel = getContextualLabel(entry.name, entry.level); return ( {entry.name} {contextLabel} ); })} ); }, ); const impliedUI = lift( ( implied: Array<{ name: string; level: RestrictionLevel; sources: string[]; }>, ) => { if (!implied || implied.length === 0) return null; return ( What This Means (Avoid These) {implied.map( ( item: { name: string; level: RestrictionLevel; sources: string[]; }, idx: number, ) => { const style = LEVEL_CONFIG[item.level]; return ( {item.name} ); }, )} ); }, ); return { [NAME]: computed(() => `🍽️ Dietary: ${displayText}`), [UI]: ( {/* Input row */} {/* Restrictions list - lift with both value and Cell for handlers */} {restrictionsUI({ list: restrictions, restrictionsCell: restrictions })} {/* Implied items - lift for display only */} {impliedUI(impliedItems)} ), restrictions, }; }); export default DietaryRestrictionsModule;