Skip to content
sysout.dev

Technical Design: Household Planner

Technical design connecting recipes, meal planning, consumption, inventory health, and shopping generation.

1. Overview

The Household Planner transforms Homey from a passive inventory tracker into an intelligent kitchen assistant. It connects recipes to inventory and uses consumption patterns to suggest future menus.

1.1 Vision

"Not just tracking what you have, but knowing what you can make and predicting what you’ll want."

1.2 Core Concepts

Concept Description

Recipe

A collection of ingredients with quantities, optionally linked to meals

Ingredient

A reference to an inventory item type (not a specific item instance)

Meal Slot

A time-based container (breakfast/lunch/dinner) for a specific day

Menu

A week’s worth of meal slot assignments

Consumption

How a recipe depletes inventory (household-specific ratios)

Usage History

Historical data for menu prediction

Diet Goal

Nutritional or preference constraints for menu suggestions

1.3 Key Differentiators

  1. Recipe-to-Inventory Linkage: Recipes reference ingredient types (e.g., "Tomatoes"), which are matched to actual inventory items

  2. Household-Specific Consumption: Learn how YOUR household uses ingredients (family of 4 uses 2x the recipe amount)

  3. Predictive Menu Planning: After a few weeks of data, suggest menus based on history

  4. Diet-Aware Suggestions: Factor in nutritional goals when auto-generating menus

2. Feature Breakdown (Phases)

Phase Feature Description Priority

1

Recipe Management

CRUD for recipes with ingredients

HIGH

2

Weekly Menu View

Drag-drop meal planning interface

HIGH

3

Ingredient Matching

Link recipe ingredients to inventory

HIGH

4

Stock Health Check

Show missing/low ingredients for planned meals

HIGH

5

Consumption Tracking

Learn household-specific usage ratios

MEDIUM

6

Shopping List Generation

Auto-generate list from menu gaps

MEDIUM

7

Menu Suggestion Engine

Predict menus based on history

LOW

8

Diet Goals Integration

Nutritional constraints for suggestions

LOW

Recommended Implementation Order: 1 → 2 → 3 → 4 → 6 → 5 → 7 → 8

3. Data Model

3.1 Core Entities

Recipe

The central entity representing a dish that can be prepared.

@Entity(tableName = "recipes")
data class RecipeEntity(
    @PrimaryKey
    val id: String,                    // UUID

    // Basic Info
    val name: String,                  // "Tomato Pasta"
    val description: String?,          // Optional preparation notes
    val imageUri: String?,             // Recipe photo

    // Classification
    val category: String,              // RecipeCategory enum: BREAKFAST, LUNCH, DINNER, SNACK, DESSERT
    val cuisine: String?,              // "Italian", "Indian", etc.
    val tags: String?,                 // JSON array: ["vegetarian", "quick", "comfort-food"]

    // Preparation
    val prepTimeMinutes: Int?,         // Time to prepare
    val cookTimeMinutes: Int?,         // Time to cook
    val servings: Int,                 // Default serving size
    val difficulty: String?,           // EASY, MEDIUM, HARD

    // Metadata
    val isFavorite: Boolean = false,
    val useCount: Int = 0,             // Times this recipe was used
    val lastUsedAt: Long?,             // For recency-based suggestions
    val createdAt: Long,
    val updatedAt: Long
)

enum class RecipeCategory {
    BREAKFAST,
    LUNCH,
    DINNER,
    SNACK,
    DESSERT,
    BEVERAGE
}

RecipeIngredient

Junction table linking recipes to ingredient types.

@Entity(
    tableName = "recipe_ingredients",
    primaryKeys = ["recipeId", "ingredientKey"],
    foreignKeys = [
        ForeignKey(
            entity = RecipeEntity::class,
            parentColumns = ["id"],
            childColumns = ["recipeId"],
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [Index("recipeId")]
)
data class RecipeIngredientEntity(
    val recipeId: String,              // FK to recipes

    // Ingredient Matching
    val ingredientKey: String,         // Normalized name for matching (e.g., "tomatoes")
    val displayName: String,           // User-facing name (e.g., "Fresh Tomatoes")

    // Required Quantities (per default serving)
    val quantity: Double,              // Amount needed
    val unit: String,                  // kg, g, L, pcs, cups, tbsp, etc.

    // Optional hints
    val isOptional: Boolean = false,   // Can recipe work without this?
    val substitutes: String?,          // JSON array of alternative ingredientKeys
    val notes: String?                 // "finely chopped", "room temperature"
)

MealSlot

A planned meal for a specific day and time.

@Entity(
    tableName = "meal_slots",
    foreignKeys = [
        ForeignKey(
            entity = RecipeEntity::class,
            parentColumns = ["id"],
            childColumns = ["recipeId"],
            onDelete = ForeignKey.SET_NULL
        )
    ],
    indices = [Index("recipeId"), Index("date")]
)
data class MealSlotEntity(
    @PrimaryKey
    val id: String,                    // UUID

    // When
    val date: Long,                    // Date (midnight timestamp)
    val mealType: String,              // MealType enum: BREAKFAST, LUNCH, DINNER, SNACK

    // What (optional - slot can be empty)
    val recipeId: String?,             // FK to recipes (null = unplanned)
    val customMealName: String?,       // For meals without recipes
    val servings: Int = 1,             // Multiplier for ingredients

    // Status
    val status: String,                // MealSlotStatus: PLANNED, PREPARED, SKIPPED
    val preparedAt: Long?,             // When was this meal actually made
    val notes: String?                 // "Made with leftover chicken instead"
)

enum class MealType {
    BREAKFAST,
    LUNCH,
    DINNER,
    SNACK
}

enum class MealSlotStatus {
    PLANNED,    // Scheduled but not yet made
    PREPARED,   // Meal was made (triggers consumption)
    SKIPPED     // Meal was skipped (no consumption)
}

ConsumptionLog

Tracks actual ingredient usage for learning household patterns.

@Entity(
    tableName = "consumption_logs",
    foreignKeys = [
        ForeignKey(
            entity = MealSlotEntity::class,
            parentColumns = ["id"],
            childColumns = ["mealSlotId"],
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [Index("mealSlotId"), Index("ingredientKey")]
)
data class ConsumptionLogEntity(
    @PrimaryKey
    val id: String,                    // UUID
    val mealSlotId: String,            // FK to meal_slots

    // What was consumed
    val ingredientKey: String,         // Normalized ingredient name
    val recipeQuantity: Double,        // What recipe said to use
    val recipeUnit: String,
    val actualQuantity: Double?,       // What user actually used (if reported)
    val actualUnit: String?,

    // Which inventory items were depleted
    val consumedItemIds: String?,      // JSON array of item IDs that were used

    // Timestamp
    val consumedAt: Long
)

HouseholdConsumptionRatio

Learned ratios for how household consumption differs from recipe defaults.

@Entity(
    tableName = "household_consumption_ratios",
    primaryKeys = ["ingredientKey", "recipeId"]
)
data class HouseholdConsumptionRatioEntity(
    val ingredientKey: String,         // Normalized ingredient name
    val recipeId: String?,             // Specific recipe or null for global ratio

    // Learned ratio
    val ratioMultiplier: Double,       // e.g., 1.5 means household uses 50% more
    val confidence: Float,             // 0.0-1.0 based on sample size
    val sampleCount: Int,              // Number of observations

    // Metadata
    val lastUpdatedAt: Long
)

DietGoal

User-defined nutritional or preference goals.

@Entity(tableName = "diet_goals")
data class DietGoalEntity(
    @PrimaryKey
    val id: String,                    // UUID

    // Goal Type
    val goalType: String,              // DietGoalType enum
    val targetValue: Double?,          // For numerical goals (calories, protein grams)
    val targetUnit: String?,           // Unit for target

    // Preference-based goals
    val preferredCategories: String?,  // JSON array of preferred recipe categories
    val excludedIngredients: String?,  // JSON array of ingredients to avoid
    val preferredCuisines: String?,    // JSON array of preferred cuisines

    // Frequency goals
    val frequencyType: String?,        // DAILY, WEEKLY
    val frequencyTarget: Int?,         // e.g., "vegetarian 3 times per week"

    // Status
    val isActive: Boolean = true,
    val createdAt: Long
)

enum class DietGoalType {
    // Nutritional
    MAX_CALORIES,
    MIN_PROTEIN,
    LOW_CARB,

    // Preference
    VEGETARIAN,
    VEGAN,
    PESCATARIAN,
    NO_DAIRY,
    NO_GLUTEN,

    // Variety
    CUISINE_VARIETY,        // Don't repeat same cuisine
    INGREDIENT_VARIETY,     // Use different main ingredients

    // Custom
    CUSTOM_INCLUSION,       // Include specific ingredients
    CUSTOM_EXCLUSION        // Avoid specific ingredients
}

3.2 Relationship Diagram

+-------------+       +---------------------+       +-----------------+
|   Recipe    |<----->| RecipeIngredient   |------>| Inventory Item  |
+-------------+  1:N  | (junction)         |  N:1  | (via matching)  |
      |               +---------------------+       +-----------------+
      |
      | 1:N
      v
+-------------+       +---------------------+
|  MealSlot   |<----->| ConsumptionLog     |
+-------------+  1:N  +---------------------+
      |                       |
      |                       v
      |               +---------------------+
      |               | HouseholdRatio      |
      |               | (learned patterns)  |
      |               +---------------------+
      v
+-------------+
|  DietGoal   |  (influences MealSlot suggestions)
+-------------+

3.3 Database Migration

// Migration v5 -> v6
val MIGRATION_5_6 = object : Migration(5, 6) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Recipes table
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS recipes (
                id TEXT PRIMARY KEY NOT NULL,
                name TEXT NOT NULL,
                description TEXT,
                imageUri TEXT,
                category TEXT NOT NULL,
                cuisine TEXT,
                tags TEXT,
                prepTimeMinutes INTEGER,
                cookTimeMinutes INTEGER,
                servings INTEGER NOT NULL DEFAULT 1,
                difficulty TEXT,
                isFavorite INTEGER NOT NULL DEFAULT 0,
                useCount INTEGER NOT NULL DEFAULT 0,
                lastUsedAt INTEGER,
                createdAt INTEGER NOT NULL,
                updatedAt INTEGER NOT NULL
            )
        """)

        // Recipe ingredients junction table
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS recipe_ingredients (
                recipeId TEXT NOT NULL,
                ingredientKey TEXT NOT NULL,
                displayName TEXT NOT NULL,
                quantity REAL NOT NULL,
                unit TEXT NOT NULL,
                isOptional INTEGER NOT NULL DEFAULT 0,
                substitutes TEXT,
                notes TEXT,
                PRIMARY KEY(recipeId, ingredientKey),
                FOREIGN KEY(recipeId) REFERENCES recipes(id) ON DELETE CASCADE
            )
        """)
        database.execSQL("CREATE INDEX idx_recipe_ingredients_recipe ON recipe_ingredients(recipeId)")

        // Meal slots table
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS meal_slots (
                id TEXT PRIMARY KEY NOT NULL,
                date INTEGER NOT NULL,
                mealType TEXT NOT NULL,
                recipeId TEXT,
                customMealName TEXT,
                servings INTEGER NOT NULL DEFAULT 1,
                status TEXT NOT NULL DEFAULT 'PLANNED',
                preparedAt INTEGER,
                notes TEXT,
                FOREIGN KEY(recipeId) REFERENCES recipes(id) ON DELETE SET NULL
            )
        """)
        database.execSQL("CREATE INDEX idx_meal_slots_recipe ON meal_slots(recipeId)")
        database.execSQL("CREATE INDEX idx_meal_slots_date ON meal_slots(date)")

        // Consumption logs
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS consumption_logs (
                id TEXT PRIMARY KEY NOT NULL,
                mealSlotId TEXT NOT NULL,
                ingredientKey TEXT NOT NULL,
                recipeQuantity REAL NOT NULL,
                recipeUnit TEXT NOT NULL,
                actualQuantity REAL,
                actualUnit TEXT,
                consumedItemIds TEXT,
                consumedAt INTEGER NOT NULL,
                FOREIGN KEY(mealSlotId) REFERENCES meal_slots(id) ON DELETE CASCADE
            )
        """)
        database.execSQL("CREATE INDEX idx_consumption_logs_meal ON consumption_logs(mealSlotId)")
        database.execSQL("CREATE INDEX idx_consumption_logs_ingredient ON consumption_logs(ingredientKey)")

        // Household consumption ratios
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS household_consumption_ratios (
                ingredientKey TEXT NOT NULL,
                recipeId TEXT,
                ratioMultiplier REAL NOT NULL DEFAULT 1.0,
                confidence REAL NOT NULL DEFAULT 0.0,
                sampleCount INTEGER NOT NULL DEFAULT 0,
                lastUpdatedAt INTEGER NOT NULL,
                PRIMARY KEY(ingredientKey, recipeId)
            )
        """)

        // Diet goals
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS diet_goals (
                id TEXT PRIMARY KEY NOT NULL,
                goalType TEXT NOT NULL,
                targetValue REAL,
                targetUnit TEXT,
                preferredCategories TEXT,
                excludedIngredients TEXT,
                preferredCuisines TEXT,
                frequencyType TEXT,
                frequencyTarget INTEGER,
                isActive INTEGER NOT NULL DEFAULT 1,
                createdAt INTEGER NOT NULL
            )
        """)
    }
}

4. Ingredient Matching System

4.1 The Matching Problem

Recipes reference ingredient types ("Tomatoes"), but inventory contains specific items ("Organic Cherry Tomatoes 500g"). The matching system bridges this gap.

4.2 Matching Algorithm

object IngredientMatcher {

    /**
     * Finds inventory items that match a recipe ingredient.
     *
     * Matching strategy (in order of priority):
     * 1. Exact canonical name match
     * 2. Template-based match (if ingredient maps to a template)
     * 3. Fuzzy name matching with category filter
     */
    fun findMatches(
        ingredientKey: String,
        inventory: List<GroceryItem>,
        templates: List<ItemTemplate>
    ): MatchResult {

        // Normalize the ingredient key
        val normalized = normalize(ingredientKey)

        // 1. Try exact match on item names
        val exactMatches = inventory.filter {
            normalize(it.name).contains(normalized) ||
            normalized.contains(normalize(it.name))
        }

        if (exactMatches.isNotEmpty()) {
            return MatchResult.Found(
                matches = exactMatches,
                confidence = 0.95f
            )
        }

        // 2. Try template-based matching
        val template = templates.find {
            normalize(it.canonicalName) == normalized
        }

        if (template != null) {
            val templateMatches = inventory.filter {
                normalize(it.name).contains(normalize(template.canonicalName))
            }
            if (templateMatches.isNotEmpty()) {
                return MatchResult.Found(
                    matches = templateMatches,
                    confidence = 0.85f
                )
            }
        }

        // 3. Fuzzy matching with Levenshtein distance
        val fuzzyMatches = inventory
            .map { item -> item to levenshteinSimilarity(normalized, normalize(item.name)) }
            .filter { (_, similarity) -> similarity > 0.6f }
            .sortedByDescending { (_, similarity) -> similarity }
            .map { (item, _) -> item }

        return if (fuzzyMatches.isNotEmpty()) {
            MatchResult.Fuzzy(
                matches = fuzzyMatches,
                confidence = 0.6f
            )
        } else {
            MatchResult.NotFound(ingredientKey)
        }
    }

    private fun normalize(text: String): String {
        return text
            .lowercase()
            .trim()
            .replace(Regex("[^a-z0-9\\s]"), "")
            .replace(Regex("\\s+"), " ")
    }

    sealed class MatchResult {
        data class Found(
            val matches: List<GroceryItem>,
            val confidence: Float
        ) : MatchResult()

        data class Fuzzy(
            val matches: List<GroceryItem>,
            val confidence: Float
        ) : MatchResult()

        data class NotFound(val ingredientKey: String) : MatchResult()
    }
}

4.3 Ingredient Alias System

Allow users to define aliases for better matching:

@Entity(tableName = "ingredient_aliases")
data class IngredientAliasEntity(
    @PrimaryKey
    val alias: String,                 // "curd", "dahi"
    val canonicalKey: String           // "yogurt"
)

// Pre-seeded aliases
val STARTER_ALIASES = listOf(
    "curd" to "yogurt",
    "dahi" to "yogurt",
    "atta" to "flour",
    "maida" to "refined_flour",
    "besan" to "gram_flour",
    "dal" to "lentils",
    "paneer" to "cottage_cheese",
    "ghee" to "clarified_butter"
)

5. Phase 1: Recipe Management

5.1 UI: Recipe List Screen

+------------------------------------------+
|  ← Recipes                    [+] [🔍]   |
+------------------------------------------+
|                                          |
|  [All] [Breakfast] [Lunch] [Dinner] [+]  |
|                                          |
|  --- Favorites ---                       |
|                                          |
|  +------------------------------------+  |
|  | 🍝 Tomato Pasta              ⭐   |  |
|  | Italian • 30 min • 4 servings     |  |
|  | Last made: 3 days ago             |  |
|  +------------------------------------+  |
|                                          |
|  +------------------------------------+  |
|  | 🍛 Dal Tadka                 ⭐   |  |
|  | Indian • 25 min • 4 servings      |  |
|  | Last made: 1 week ago             |  |
|  +------------------------------------+  |
|                                          |
|  --- All Recipes (12) ---                |
|                                          |
|  +------------------------------------+  |
|  | 🥗 Greek Salad                    |  |
|  | Mediterranean • 15 min • 2 srv    |  |
|  +------------------------------------+  |
|                                          |
|  ... (more recipes)                      |
|                                          |
+------------------------------------------+

5.2 UI: Recipe Detail/Edit Screen

+------------------------------------------+
|  ← Tomato Pasta                [⭐] [✏️] |
+------------------------------------------+
|                                          |
|  +------------------------------------+  |
|  |                                    |  |
|  |        [Recipe Photo]              |  |
|  |                                    |  |
|  +------------------------------------+  |
|                                          |
|  Italian • Dinner • Easy                 |
|  ⏱️ 10 min prep • 20 min cook           |
|  🍽️ 4 servings                          |
|                                          |
|  --- Ingredients ---                     |
|                                          |
|  • Pasta 400g                       [✓]  |
|  • Tomatoes 500g                    [!]  |
|  • Garlic 4 cloves                  [✓]  |
|  • Olive Oil 2 tbsp                 [✓]  |
|  • Basil leaves (optional)          [?]  |
|                                          |
|  ✓ = In stock  ! = Low/Missing  ? = N/A  |
|                                          |
|  --- Description ---                     |
|                                          |
|  Classic Italian pasta with fresh        |
|  tomato sauce and aromatic basil.        |
|                                          |
|  [Add to Menu]        [Mark as Made]     |
|                                          |
+------------------------------------------+

5.3 Recipe ViewModel

@HiltViewModel
class RecipeViewModel @Inject constructor(
    private val recipeRepository: RecipeRepository,
    private val ingredientMatcher: IngredientMatcher,
    private val itemRepository: ItemRepository
) : ViewModel() {

    private val _recipes = MutableStateFlow<List<Recipe>>(emptyList())
    val recipes: StateFlow<List<Recipe>> = _recipes.asStateFlow()

    private val _selectedRecipe = MutableStateFlow<RecipeWithIngredients?>(null)
    val selectedRecipe: StateFlow<RecipeWithIngredients?> = _selectedRecipe.asStateFlow()

    private val _ingredientStatus = MutableStateFlow<Map<String, IngredientStatus>>(emptyMap())
    val ingredientStatus: StateFlow<Map<String, IngredientStatus>> = _ingredientStatus.asStateFlow()

    fun loadRecipes(category: RecipeCategory? = null) {
        viewModelScope.launch {
            recipeRepository.getRecipes(category)
                .collect { _recipes.value = it }
        }
    }

    fun selectRecipe(recipeId: String) {
        viewModelScope.launch {
            val recipe = recipeRepository.getRecipeWithIngredients(recipeId)
            _selectedRecipe.value = recipe

            // Check ingredient availability
            val inventory = itemRepository.getAllGroceryItems().first()
            val statuses = recipe.ingredients.associate { ingredient ->
                ingredient.ingredientKey to checkIngredientStatus(ingredient, inventory)
            }
            _ingredientStatus.value = statuses
        }
    }

    private fun checkIngredientStatus(
        ingredient: RecipeIngredient,
        inventory: List<GroceryItem>
    ): IngredientStatus {
        val match = ingredientMatcher.findMatches(
            ingredient.ingredientKey,
            inventory,
            emptyList() // templates
        )

        return when (match) {
            is MatchResult.Found -> {
                val totalQty = match.matches.sumOf { it.quantity }
                if (totalQty >= ingredient.quantity) {
                    IngredientStatus.InStock(totalQty, ingredient.unit)
                } else {
                    IngredientStatus.Low(totalQty, ingredient.quantity, ingredient.unit)
                }
            }
            is MatchResult.Fuzzy -> IngredientStatus.MaybeInStock(match.matches)
            is MatchResult.NotFound -> {
                if (ingredient.isOptional) IngredientStatus.Optional
                else IngredientStatus.Missing
            }
        }
    }

    fun createRecipe(
        name: String,
        category: RecipeCategory,
        ingredients: List<RecipeIngredient>,
        prepTime: Int?,
        cookTime: Int?,
        servings: Int,
        description: String?
    ) {
        viewModelScope.launch {
            recipeRepository.createRecipe(
                Recipe(
                    id = UUID.randomUUID().toString(),
                    name = name,
                    category = category,
                    ingredients = ingredients,
                    prepTimeMinutes = prepTime,
                    cookTimeMinutes = cookTime,
                    servings = servings,
                    description = description,
                    createdAt = Date(),
                    updatedAt = Date()
                )
            )
        }
    }

    sealed class IngredientStatus {
        data class InStock(val available: Double, val unit: String) : IngredientStatus()
        data class Low(val available: Double, val required: Double, val unit: String) : IngredientStatus()
        data class MaybeInStock(val possibleMatches: List<GroceryItem>) : IngredientStatus()
        object Missing : IngredientStatus()
        object Optional : IngredientStatus()
    }
}

6. Phase 2: Weekly Menu View

6.1 UI: Menu Planner Screen

+------------------------------------------+
|  ← Menu Planner              [< Week >]  |
+------------------------------------------+
|  Jan 20 - Jan 26, 2026                   |
+------------------------------------------+
|                                          |
|  MON 20                                  |
|  +--------+ +--------+ +--------+        |
|  |Breakfast| |  Lunch | | Dinner |       |
|  |         | |        | |        |       |
|  | Oatmeal | | (empty)| | Pasta  |       |
|  |   ✓     | |  [+]   | |   !    |       |
|  +--------+ +--------+ +--------+        |
|                                          |
|  TUE 21                                  |
|  +--------+ +--------+ +--------+        |
|  |Breakfast| |  Lunch | | Dinner |       |
|  |         | |        | |        |       |
|  |  Toast  | | Salad  | | Curry  |       |
|  |   ✓     | |   ✓    | |   ✓    |       |
|  +--------+ +--------+ +--------+        |
|                                          |
|  ... (remaining days)                    |
|                                          |
+------------------------------------------+
|  Stock Health: 85% ready                 |
|  [View Shopping List]                    |
+------------------------------------------+

Legend:
✓ = All ingredients in stock
! = Some ingredients low/missing
(empty) = No meal planned

6.2 UI: Meal Slot Dialog

+------------------------------------------+
|  Monday Lunch                        [X] |
+------------------------------------------+
|                                          |
|  --- Quick Picks ---                     |
|  (Based on history & ingredients)        |
|                                          |
|  [Greek Salad ✓] [Sandwich ✓]            |
|  [Fried Rice !]                          |
|                                          |
|  --- Or Choose Recipe ---                |
|                                          |
|  🔍 [Search recipes____________]         |
|                                          |
|  [Breakfast] [Lunch] [Dinner] [All]      |
|                                          |
|  • Greek Salad (15 min)             [+]  |
|  • Pasta Salad (20 min)             [+]  |
|  • Veggie Wrap (10 min)             [+]  |
|                                          |
|  --- Or Custom ---                       |
|                                          |
|  [Type meal name...] (no recipe)         |
|                                          |
|  Servings: [2]                           |
|                                          |
|  [Cancel]                     [Confirm]  |
+------------------------------------------+

6.3 Menu ViewModel

@HiltViewModel
class MenuViewModel @Inject constructor(
    private val mealSlotRepository: MealSlotRepository,
    private val recipeRepository: RecipeRepository,
    private val stockHealthChecker: StockHealthChecker,
    private val menuSuggestionEngine: MenuSuggestionEngine
) : ViewModel() {

    private val _currentWeekStart = MutableStateFlow(getStartOfWeek(Date()))
    val currentWeekStart: StateFlow<Date> = _currentWeekStart.asStateFlow()

    private val _weekMenu = MutableStateFlow<WeekMenu>(WeekMenu.empty())
    val weekMenu: StateFlow<WeekMenu> = _weekMenu.asStateFlow()

    private val _stockHealth = MutableStateFlow<StockHealth>(StockHealth.empty())
    val stockHealth: StateFlow<StockHealth> = _stockHealth.asStateFlow()

    private val _suggestions = MutableStateFlow<List<RecipeSuggestion>>(emptyList())
    val suggestions: StateFlow<List<RecipeSuggestion>> = _suggestions.asStateFlow()

    init {
        loadCurrentWeek()
    }

    fun loadCurrentWeek() {
        viewModelScope.launch {
            val weekStart = currentWeekStart.value
            val weekEnd = addDays(weekStart, 6)

            mealSlotRepository.getMealSlotsForRange(weekStart, weekEnd)
                .combine(recipeRepository.getAllRecipes()) { slots, recipes ->
                    buildWeekMenu(slots, recipes)
                }
                .collect { menu ->
                    _weekMenu.value = menu
                    updateStockHealth(menu)
                }
        }
    }

    fun navigateWeek(direction: Int) {
        _currentWeekStart.value = addDays(_currentWeekStart.value, direction * 7)
        loadCurrentWeek()
    }

    fun assignRecipeToSlot(date: Date, mealType: MealType, recipeId: String, servings: Int) {
        viewModelScope.launch {
            mealSlotRepository.assignRecipe(
                date = date,
                mealType = mealType,
                recipeId = recipeId,
                servings = servings
            )
        }
    }

    fun assignCustomMeal(date: Date, mealType: MealType, mealName: String) {
        viewModelScope.launch {
            mealSlotRepository.assignCustomMeal(
                date = date,
                mealType = mealType,
                customName = mealName
            )
        }
    }

    fun clearSlot(slotId: String) {
        viewModelScope.launch {
            mealSlotRepository.clearSlot(slotId)
        }
    }

    fun loadSuggestionsForSlot(date: Date, mealType: MealType) {
        viewModelScope.launch {
            val suggestions = menuSuggestionEngine.getSuggestions(
                date = date,
                mealType = mealType,
                existingMenu = weekMenu.value
            )
            _suggestions.value = suggestions
        }
    }

    private suspend fun updateStockHealth(menu: WeekMenu) {
        val health = stockHealthChecker.checkMenu(menu)
        _stockHealth.value = health
    }
}

data class WeekMenu(
    val weekStart: Date,
    val days: List<DayMenu>
) {
    companion object {
        fun empty() = WeekMenu(Date(), emptyList())
    }
}

data class DayMenu(
    val date: Date,
    val breakfast: MealSlot?,
    val lunch: MealSlot?,
    val dinner: MealSlot?,
    val snacks: List<MealSlot>
)

data class StockHealth(
    val percentageReady: Float,
    val missingIngredients: List<MissingIngredient>,
    val lowIngredients: List<LowIngredient>
) {
    companion object {
        fun empty() = StockHealth(0f, emptyList(), emptyList())
    }
}

data class MissingIngredient(
    val ingredientKey: String,
    val displayName: String,
    val requiredQuantity: Double,
    val unit: String,
    val forRecipes: List<String>
)

7. Phase 4: Stock Health Check

7.1 Stock Health Checker

class StockHealthChecker @Inject constructor(
    private val itemRepository: ItemRepository,
    private val ingredientMatcher: IngredientMatcher,
    private val consumptionRatioRepository: ConsumptionRatioRepository
) {

    suspend fun checkMenu(menu: WeekMenu): StockHealth {
        // 1. Aggregate all required ingredients across the week
        val requiredIngredients = mutableMapOf<String, IngredientRequirement>()

        menu.days.forEach { day ->
            listOfNotNull(day.breakfast, day.lunch, day.dinner)
                .plus(day.snacks)
                .forEach { slot ->
                    slot.recipe?.ingredients?.forEach { ingredient ->
                        val key = ingredient.ingredientKey
                        val existing = requiredIngredients[key]

                        // Apply household consumption ratio
                        val ratio = consumptionRatioRepository
                            .getRatio(key, slot.recipe?.id)
                            ?.ratioMultiplier ?: 1.0

                        val adjustedQuantity = ingredient.quantity * slot.servings * ratio

                        requiredIngredients[key] = IngredientRequirement(
                            ingredientKey = key,
                            displayName = ingredient.displayName,
                            totalQuantity = (existing?.totalQuantity ?: 0.0) + adjustedQuantity,
                            unit = ingredient.unit,
                            forRecipes = (existing?.forRecipes ?: emptyList()) +
                                         (slot.recipe?.name ?: "Unknown")
                        )
                    }
                }
        }

        // 2. Check inventory for each ingredient
        val inventory = itemRepository.getAllGroceryItems().first()
        val missing = mutableListOf<MissingIngredient>()
        val low = mutableListOf<LowIngredient>()
        var mealsReady = 0
        var totalMeals = 0

        menu.days.forEach { day ->
            listOfNotNull(day.breakfast, day.lunch, day.dinner).forEach { slot ->
                if (slot.recipe != null) {
                    totalMeals++
                    val allInStock = slot.recipe.ingredients.all { ingredient ->
                        val match = ingredientMatcher.findMatches(
                            ingredient.ingredientKey,
                            inventory,
                            emptyList()
                        )
                        match is MatchResult.Found &&
                            match.matches.sumOf { it.quantity } >= ingredient.quantity
                    }
                    if (allInStock) mealsReady++
                }
            }
        }

        requiredIngredients.values.forEach { required ->
            val match = ingredientMatcher.findMatches(
                required.ingredientKey,
                inventory,
                emptyList()
            )

            when (match) {
                is MatchResult.Found -> {
                    val available = match.matches.sumOf { it.quantity }
                    if (available < required.totalQuantity) {
                        low.add(LowIngredient(
                            ingredientKey = required.ingredientKey,
                            displayName = required.displayName,
                            available = available,
                            required = required.totalQuantity,
                            unit = required.unit
                        ))
                    }
                }
                else -> {
                    missing.add(MissingIngredient(
                        ingredientKey = required.ingredientKey,
                        displayName = required.displayName,
                        requiredQuantity = required.totalQuantity,
                        unit = required.unit,
                        forRecipes = required.forRecipes
                    ))
                }
            }
        }

        return StockHealth(
            percentageReady = if (totalMeals > 0) mealsReady.toFloat() / totalMeals else 1f,
            missingIngredients = missing,
            lowIngredients = low
        )
    }
}

8. Phase 5: Consumption Tracking & Learning

8.1 Consumption Flow

User marks meal as "Prepared"
         │
         ▼
   Recipe ingredients listed
         │
         ▼
   Auto-match to inventory items
         │
         ▼
   User can adjust quantities
   (optional, for learning)
         │
         ▼
   Deduct from inventory
         │
         ▼
   Log consumption with ratio
         │
         ▼
   Update household ratio
   (if user provided actual qty)

8.2 Consumption Tracker

class ConsumptionTracker @Inject constructor(
    private val itemRepository: ItemRepository,
    private val ingredientMatcher: IngredientMatcher,
    private val consumptionLogRepository: ConsumptionLogRepository,
    private val ratioRepository: ConsumptionRatioRepository
) {

    /**
     * Process a meal slot being marked as prepared.
     * Deducts ingredients from inventory and logs consumption.
     */
    suspend fun processMealPrepared(
        mealSlot: MealSlot,
        actualQuantities: Map<String, Double>? = null  // User-provided overrides
    ): ConsumptionResult {
        val recipe = mealSlot.recipe ?: return ConsumptionResult.NoRecipe
        val inventory = itemRepository.getAllGroceryItems().first()
        val consumptionLogs = mutableListOf<ConsumptionLog>()
        val errors = mutableListOf<String>()

        recipe.ingredients.forEach { ingredient ->
            // Calculate expected quantity (with household ratio)
            val ratio = ratioRepository.getRatio(ingredient.ingredientKey, recipe.id)
            val expectedQty = ingredient.quantity * mealSlot.servings * (ratio?.ratioMultiplier ?: 1.0)

            // Use actual quantity if provided, otherwise use expected
            val actualQty = actualQuantities?.get(ingredient.ingredientKey) ?: expectedQty

            // Find matching inventory items
            val matches = ingredientMatcher.findMatches(
                ingredient.ingredientKey,
                inventory,
                emptyList()
            )

            when (matches) {
                is MatchResult.Found, is MatchResult.Fuzzy -> {
                    val items = when (matches) {
                        is MatchResult.Found -> matches.matches
                        is MatchResult.Fuzzy -> matches.matches
                        else -> emptyList()
                    }

                    // Deduct from inventory (FIFO by expiry date)
                    val (consumedItems, remaining) = deductFromInventory(
                        items.sortedBy { it.expiryDate },
                        actualQty,
                        ingredient.unit
                    )

                    // Log consumption
                    consumptionLogs.add(ConsumptionLog(
                        id = UUID.randomUUID().toString(),
                        mealSlotId = mealSlot.id,
                        ingredientKey = ingredient.ingredientKey,
                        recipeQuantity = expectedQty,
                        recipeUnit = ingredient.unit,
                        actualQuantity = actualQty,
                        actualUnit = ingredient.unit,
                        consumedItemIds = consumedItems.map { it.id },
                        consumedAt = Date()
                    ))

                    // Update household ratio if user provided actual quantity
                    if (actualQuantities?.containsKey(ingredient.ingredientKey) == true) {
                        updateHouseholdRatio(
                            ingredientKey = ingredient.ingredientKey,
                            recipeId = recipe.id,
                            recipeQty = ingredient.quantity * mealSlot.servings,
                            actualQty = actualQty
                        )
                    }
                }
                is MatchResult.NotFound -> {
                    if (!ingredient.isOptional) {
                        errors.add("Could not find ${ingredient.displayName} in inventory")
                    }
                }
            }
        }

        // Persist logs
        consumptionLogRepository.insertAll(consumptionLogs)

        return if (errors.isEmpty()) {
            ConsumptionResult.Success(consumptionLogs)
        } else {
            ConsumptionResult.PartialSuccess(consumptionLogs, errors)
        }
    }

    private suspend fun deductFromInventory(
        items: List<GroceryItem>,
        quantityNeeded: Double,
        unit: String
    ): Pair<List<GroceryItem>, Double> {
        var remaining = quantityNeeded
        val consumed = mutableListOf<GroceryItem>()

        for (item in items) {
            if (remaining <= 0) break

            // TODO: Unit conversion if needed
            val available = item.quantity
            val toDeduct = minOf(available, remaining)

            if (toDeduct >= available) {
                // Item fully consumed - delete it
                itemRepository.deleteItem(item.id)
                consumed.add(item)
            } else {
                // Partial consumption - update quantity
                itemRepository.updateItemQuantity(item.id, available - toDeduct)
                consumed.add(item.copy(quantity = toDeduct))
            }

            remaining -= toDeduct
        }

        return consumed to remaining
    }

    private suspend fun updateHouseholdRatio(
        ingredientKey: String,
        recipeId: String,
        recipeQty: Double,
        actualQty: Double
    ) {
        val newRatio = actualQty / recipeQty
        val existing = ratioRepository.getRatio(ingredientKey, recipeId)

        if (existing != null) {
            // Weighted average with existing ratio
            val newSampleCount = existing.sampleCount + 1
            val weightedRatio = (existing.ratioMultiplier * existing.sampleCount + newRatio) / newSampleCount
            val newConfidence = minOf(1.0f, 0.3f + (newSampleCount * 0.1f))

            ratioRepository.update(existing.copy(
                ratioMultiplier = weightedRatio,
                sampleCount = newSampleCount,
                confidence = newConfidence,
                lastUpdatedAt = Date().time
            ))
        } else {
            ratioRepository.insert(HouseholdConsumptionRatio(
                ingredientKey = ingredientKey,
                recipeId = recipeId,
                ratioMultiplier = newRatio,
                confidence = 0.3f,
                sampleCount = 1,
                lastUpdatedAt = Date().time
            ))
        }
    }

    sealed class ConsumptionResult {
        object NoRecipe : ConsumptionResult()
        data class Success(val logs: List<ConsumptionLog>) : ConsumptionResult()
        data class PartialSuccess(
            val logs: List<ConsumptionLog>,
            val errors: List<String>
        ) : ConsumptionResult()
    }
}

9. Phase 6: Shopping List Generation

9.1 Shopping List Generator

class ShoppingListGenerator @Inject constructor(
    private val stockHealthChecker: StockHealthChecker,
    private val ratioRepository: ConsumptionRatioRepository
) {

    /**
     * Generate a shopping list based on planned menu and current inventory.
     */
    suspend fun generateList(menu: WeekMenu): ShoppingList {
        val health = stockHealthChecker.checkMenu(menu)

        val items = mutableListOf<ShoppingListItem>()

        // Add missing items
        health.missingIngredients.forEach { missing ->
            items.add(ShoppingListItem(
                ingredientKey = missing.ingredientKey,
                displayName = missing.displayName,
                quantity = missing.requiredQuantity,
                unit = missing.unit,
                reason = ShoppingReason.MISSING,
                neededFor = missing.forRecipes
            ))
        }

        // Add low items (need the difference)
        health.lowIngredients.forEach { low ->
            items.add(ShoppingListItem(
                ingredientKey = low.ingredientKey,
                displayName = low.displayName,
                quantity = low.required - low.available,
                unit = low.unit,
                reason = ShoppingReason.LOW,
                neededFor = emptyList()
            ))
        }

        return ShoppingList(
            generatedAt = Date(),
            forWeekStart = menu.weekStart,
            items = items.sortedBy { it.displayName }
        )
    }
}

data class ShoppingList(
    val generatedAt: Date,
    val forWeekStart: Date,
    val items: List<ShoppingListItem>
)

data class ShoppingListItem(
    val ingredientKey: String,
    val displayName: String,
    val quantity: Double,
    val unit: String,
    val reason: ShoppingReason,
    val neededFor: List<String>,
    var isPurchased: Boolean = false
)

enum class ShoppingReason {
    MISSING,
    LOW,
    EXPIRING_SOON  // Future: proactive replacement
}

9.2 UI: Shopping List Screen

+------------------------------------------+
|  ← Shopping List            [Share] [✓]  |
+------------------------------------------+
|  For week of Jan 20 - 26                 |
+------------------------------------------+
|                                          |
|  --- Need to Buy (8 items) ---           |
|                                          |
|  [ ] Tomatoes 2 kg                       |
|      For: Pasta, Curry                   |
|                                          |
|  [ ] Onions 1 kg                         |
|      For: Curry, Fried Rice              |
|                                          |
|  [ ] Chicken 500 g                       |
|      For: Curry                          |
|                                          |
|  [ ] Pasta 400 g                         |
|      For: Tomato Pasta                   |
|                                          |
|  ... (more items)                        |
|                                          |
|  --- Already Purchased (2) ---           |
|                                          |
|  [✓] Milk 2 L                            |
|  [✓] Bread 1 pack                        |
|                                          |
+------------------------------------------+
|  [Auto-Add to Cart (BigBasket)]          |
+------------------------------------------+

10. Phase 7: Menu Suggestion Engine

10.1 Suggestion Algorithm

class MenuSuggestionEngine @Inject constructor(
    private val recipeRepository: RecipeRepository,
    private val mealSlotRepository: MealSlotRepository,
    private val itemRepository: ItemRepository,
    private val dietGoalRepository: DietGoalRepository,
    private val ingredientMatcher: IngredientMatcher
) {

    /**
     * Generate recipe suggestions for a meal slot.
     *
     * Scoring factors:
     * 1. Ingredient availability (higher = more ingredients in stock)
     * 2. Historical frequency (higher = less recently used, for variety)
     * 3. User favorites (bonus points)
     * 4. Diet goal compliance (filter/boost)
     * 5. Time of day appropriateness (breakfast vs dinner)
     */
    suspend fun getSuggestions(
        date: Date,
        mealType: MealType,
        existingMenu: WeekMenu,
        limit: Int = 5
    ): List<RecipeSuggestion> {
        val allRecipes = recipeRepository.getAllWithIngredients().first()
        val inventory = itemRepository.getAllGroceryItems().first()
        val dietGoals = dietGoalRepository.getActiveGoals().first()
        val recentHistory = mealSlotRepository.getHistory(days = 30).first()

        // Get recipes already in this week's menu (for variety)
        val usedThisWeek = existingMenu.days.flatMap { day ->
            listOfNotNull(day.breakfast?.recipe, day.lunch?.recipe, day.dinner?.recipe)
        }.map { it.id }.toSet()

        return allRecipes
            .filter { recipe ->
                // Filter by meal type appropriateness
                isMealTypeAppropriate(recipe.category, mealType)
            }
            .filter { recipe ->
                // Filter by diet goals
                passesDietGoals(recipe, dietGoals)
            }
            .map { recipe ->
                val score = calculateScore(
                    recipe = recipe,
                    inventory = inventory,
                    recentHistory = recentHistory,
                    usedThisWeek = usedThisWeek,
                    dietGoals = dietGoals
                )
                RecipeSuggestion(recipe, score)
            }
            .sortedByDescending { it.score }
            .take(limit)
    }

    /**
     * Auto-generate a full week menu.
     * Uses constraint satisfaction to maximize variety and ingredient usage.
     */
    suspend fun generateWeekMenu(
        weekStart: Date,
        constraints: MenuConstraints = MenuConstraints()
    ): GeneratedMenu {
        val allRecipes = recipeRepository.getAllWithIngredients().first()
        val inventory = itemRepository.getAllGroceryItems().first()
        val dietGoals = dietGoalRepository.getActiveGoals().first()

        val assignments = mutableListOf<MealAssignment>()
        val usedRecipes = mutableSetOf<String>()
        val usedCuisines = mutableMapOf<String, Int>()
        val usedMainIngredients = mutableMapOf<String, Int>()

        // Generate for each day
        for (dayOffset in 0..6) {
            val date = addDays(weekStart, dayOffset)

            for (mealType in listOf(MealType.BREAKFAST, MealType.LUNCH, MealType.DINNER)) {
                val candidates = allRecipes
                    .filter { isMealTypeAppropriate(it.category, mealType) }
                    .filter { passesDietGoals(it, dietGoals) }
                    .filter { constraints.allowRepeats || it.id !in usedRecipes }
                    .map { recipe ->
                        val varietyScore = calculateVarietyScore(
                            recipe,
                            usedCuisines,
                            usedMainIngredients
                        )
                        val availabilityScore = calculateAvailabilityScore(recipe, inventory)
                        val totalScore = availabilityScore * 0.6 + varietyScore * 0.4

                        recipe to totalScore
                    }
                    .sortedByDescending { it.second }

                val selected = candidates.firstOrNull()?.first

                if (selected != null) {
                    assignments.add(MealAssignment(date, mealType, selected))
                    usedRecipes.add(selected.id)
                    selected.cuisine?.let {
                        usedCuisines[it] = (usedCuisines[it] ?: 0) + 1
                    }
                    selected.ingredients.firstOrNull()?.let {
                        usedMainIngredients[it.ingredientKey] =
                            (usedMainIngredients[it.ingredientKey] ?: 0) + 1
                    }
                }
            }
        }

        return GeneratedMenu(
            weekStart = weekStart,
            assignments = assignments,
            coveragePercent = (assignments.size.toFloat() / 21) * 100
        )
    }

    private fun calculateScore(
        recipe: RecipeWithIngredients,
        inventory: List<GroceryItem>,
        recentHistory: List<MealSlot>,
        usedThisWeek: Set<String>,
        dietGoals: List<DietGoal>
    ): Float {
        var score = 0f

        // Availability score (0-40 points)
        val availableIngredients = recipe.ingredients.count { ingredient ->
            val match = ingredientMatcher.findMatches(
                ingredient.ingredientKey, inventory, emptyList()
            )
            match is MatchResult.Found
        }
        score += (availableIngredients.toFloat() / recipe.ingredients.size) * 40

        // Recency score - less recent = higher (0-25 points)
        val daysSinceLastUse = recentHistory
            .filter { it.recipe?.id == recipe.id }
            .maxOfOrNull { daysBetween(it.date, Date()) }
            ?: 30

        score += minOf(25f, daysSinceLastUse.toFloat() * 0.8f)

        // Not used this week bonus (10 points)
        if (recipe.id !in usedThisWeek) {
            score += 10
        }

        // Favorite bonus (15 points)
        if (recipe.isFavorite) {
            score += 15
        }

        // Use count bonus (frequently made recipes) (0-10 points)
        score += minOf(10f, recipe.useCount.toFloat() * 0.5f)

        return score
    }

    private fun isMealTypeAppropriate(category: RecipeCategory, mealType: MealType): Boolean {
        return when (mealType) {
            MealType.BREAKFAST -> category in listOf(
                RecipeCategory.BREAKFAST, RecipeCategory.BEVERAGE, RecipeCategory.SNACK
            )
            MealType.LUNCH -> category in listOf(
                RecipeCategory.LUNCH, RecipeCategory.DINNER // Lunch can use dinner recipes
            )
            MealType.DINNER -> category == RecipeCategory.DINNER
            MealType.SNACK -> category in listOf(
                RecipeCategory.SNACK, RecipeCategory.DESSERT, RecipeCategory.BEVERAGE
            )
        }
    }

    private fun passesDietGoals(recipe: RecipeWithIngredients, goals: List<DietGoal>): Boolean {
        return goals.all { goal ->
            when (goal.goalType) {
                DietGoalType.VEGETARIAN ->
                    recipe.tags?.contains("vegetarian") == true ||
                    !recipe.ingredients.any { it.ingredientKey in MEAT_INGREDIENTS }

                DietGoalType.VEGAN ->
                    recipe.tags?.contains("vegan") == true

                DietGoalType.NO_DAIRY ->
                    !recipe.ingredients.any { it.ingredientKey in DAIRY_INGREDIENTS }

                DietGoalType.CUSTOM_EXCLUSION ->
                    goal.excludedIngredients?.let { excluded ->
                        !recipe.ingredients.any { it.ingredientKey in excluded }
                    } ?: true

                else -> true
            }
        }
    }

    companion object {
        val MEAT_INGREDIENTS = setOf("chicken", "fish", "mutton", "pork", "beef", "eggs")
        val DAIRY_INGREDIENTS = setOf("milk", "cheese", "butter", "cream", "yogurt", "paneer")
    }
}

data class RecipeSuggestion(
    val recipe: RecipeWithIngredients,
    val score: Float,
    val ingredientAvailability: Float = 0f
)

data class GeneratedMenu(
    val weekStart: Date,
    val assignments: List<MealAssignment>,
    val coveragePercent: Float
)

data class MealAssignment(
    val date: Date,
    val mealType: MealType,
    val recipe: RecipeWithIngredients
)

data class MenuConstraints(
    val allowRepeats: Boolean = false,
    val maxCuisineRepeat: Int = 2,
    val preferIngredientReuse: Boolean = true
)

11. Phase 8: Diet Goals Integration

11.1 UI: Diet Goals Screen

+------------------------------------------+
|  ← Diet Goals                       [+]  |
+------------------------------------------+
|                                          |
|  --- Active Goals ---                    |
|                                          |
|  +------------------------------------+  |
|  | 🥗 Vegetarian                      |  |
|  |    3 days per week                 |  |
|  |    Progress: 2/3 this week     [✏️]|  |
|  +------------------------------------+  |
|                                          |
|  +------------------------------------+  |
|  | 🌾 Low Carb                        |  |
|  |    Max 100g carbs per day          |  |
|  |    Avg: 85g this week          [✏️]|  |
|  +------------------------------------+  |
|                                          |
|  +------------------------------------+  |
|  | 🍽️ Cuisine Variety                |  |
|  |    Different cuisine each day      |  |
|  |    Score: 5/7 days             [✏️]|  |
|  +------------------------------------+  |
|                                          |
|  --- Add New Goal ---                    |
|                                          |
|  [Dietary Restrictions]                  |
|  [Nutritional Targets]                   |
|  [Variety Goals]                         |
|  [Custom Rules]                          |
|                                          |
+------------------------------------------+

11.2 Diet Goal Tracker

class DietGoalTracker @Inject constructor(
    private val dietGoalRepository: DietGoalRepository,
    private val mealSlotRepository: MealSlotRepository,
    private val recipeRepository: RecipeRepository
) {

    suspend fun getGoalProgress(
        goal: DietGoal,
        weekStart: Date
    ): GoalProgress {
        val weekEnd = addDays(weekStart, 6)
        val meals = mealSlotRepository.getCompletedMealsForRange(weekStart, weekEnd).first()

        return when (goal.goalType) {
            DietGoalType.VEGETARIAN -> {
                val vegetarianMeals = meals.count { meal ->
                    meal.recipe?.tags?.contains("vegetarian") == true
                }
                GoalProgress.Frequency(
                    current = vegetarianMeals,
                    target = goal.frequencyTarget ?: 0,
                    unit = "meals"
                )
            }

            DietGoalType.CUISINE_VARIETY -> {
                val uniqueCuisines = meals
                    .mapNotNull { it.recipe?.cuisine }
                    .distinct()
                    .size
                GoalProgress.Count(
                    current = uniqueCuisines,
                    description = "different cuisines this week"
                )
            }

            DietGoalType.CUSTOM_INCLUSION -> {
                val targetIngredients = goal.preferredCategories ?: emptyList()
                val usedIngredients = meals.flatMap { meal ->
                    meal.recipe?.ingredients?.map { it.ingredientKey } ?: emptyList()
                }.toSet()
                val matched = targetIngredients.count { it in usedIngredients }
                GoalProgress.Frequency(
                    current = matched,
                    target = targetIngredients.size,
                    unit = "ingredients included"
                )
            }

            else -> GoalProgress.Unknown
        }
    }
}

sealed class GoalProgress {
    data class Frequency(
        val current: Int,
        val target: Int,
        val unit: String
    ) : GoalProgress() {
        val percentage: Float get() = if (target > 0) current.toFloat() / target else 0f
    }

    data class Count(
        val current: Int,
        val description: String
    ) : GoalProgress()

    object Unknown : GoalProgress()
}

12. File Structure

app/src/main/kotlin/dev/sysout/homey/app/features/
└── planner/
    ├── data/
    │   ├── RecipeEntity.kt
    │   ├── RecipeIngredientEntity.kt
    │   ├── MealSlotEntity.kt
    │   ├── ConsumptionLogEntity.kt
    │   ├── HouseholdConsumptionRatioEntity.kt
    │   ├── DietGoalEntity.kt
    │   ├── IngredientAliasEntity.kt
    │   ├── RecipeDao.kt
    │   ├── MealSlotDao.kt
    │   ├── ConsumptionDao.kt
    │   ├── DietGoalDao.kt
    │   ├── RecipeRepository.kt
    │   ├── MealSlotRepository.kt
    │   ├── ConsumptionRatioRepository.kt
    │   └── DietGoalRepository.kt
    ├── domain/
    │   ├── Recipe.kt
    │   ├── RecipeIngredient.kt
    │   ├── MealSlot.kt
    │   ├── ConsumptionLog.kt
    │   ├── DietGoal.kt
    │   ├── WeekMenu.kt
    │   ├── ShoppingList.kt
    │   └── RecipeSuggestion.kt
    ├── matching/
    │   ├── IngredientMatcher.kt
    │   └── IngredientAliases.kt
    ├── consumption/
    │   ├── ConsumptionTracker.kt
    │   └── StockHealthChecker.kt
    ├── suggestion/
    │   ├── MenuSuggestionEngine.kt
    │   └── DietGoalTracker.kt
    ├── shopping/
    │   └── ShoppingListGenerator.kt
    └── ui/
        ├── recipes/
        │   ├── RecipeListScreen.kt
        │   ├── RecipeDetailScreen.kt
        │   ├── RecipeEditScreen.kt
        │   └── RecipeViewModel.kt
        ├── menu/
        │   ├── MenuPlannerScreen.kt
        │   ├── MealSlotDialog.kt
        │   └── MenuViewModel.kt
        ├── shopping/
        │   ├── ShoppingListScreen.kt
        │   └── ShoppingListViewModel.kt
        ├── goals/
        │   ├── DietGoalsScreen.kt
        │   ├── AddGoalDialog.kt
        │   └── DietGoalsViewModel.kt
        └── components/
            ├── RecipeCard.kt
            ├── IngredientChip.kt
            ├── MealSlotCard.kt
            ├── StockHealthIndicator.kt
            └── WeekNavigator.kt

13. Navigation

// Add to Screen.kt
sealed class PlannerScreen(val route: String) {
    object Recipes : PlannerScreen("planner/recipes")
    object RecipeDetail : PlannerScreen("planner/recipes/{recipeId}") {
        fun createRoute(recipeId: String) = "planner/recipes/$recipeId"
    }
    object RecipeEdit : PlannerScreen("planner/recipes/{recipeId}/edit") {
        fun createRoute(recipeId: String) = "planner/recipes/$recipeId/edit"
    }
    object RecipeCreate : PlannerScreen("planner/recipes/new")
    object Menu : PlannerScreen("planner/menu")
    object ShoppingList : PlannerScreen("planner/shopping")
    object DietGoals : PlannerScreen("planner/goals")
}

14. Implementation Order

Phase Deliverable Dependencies

1

Recipe CRUD with ingredients

Database entities, Room DAOs

2

Weekly menu view with slot assignment

Phase 1, MealSlot entity

3

Ingredient matching to inventory

Existing inventory system

4

Stock health checker UI

Phases 2, 3

5

Consumption tracking on meal completion

Phases 2, 3

6

Shopping list generation

Phases 3, 4

7

Menu suggestion engine

Phases 1-5, usage history

8

Diet goals with suggestion filtering

Phase 7

15. Open Questions

  1. Recipe Import: Should we support importing recipes from URLs/apps?

  2. Nutritional Data: Add calorie/macro tracking to recipes?

  3. Serving Adjustments: How to handle "cooked for 6 but recipe was for 4"?

  4. Leftover Tracking: Track meals that produced leftovers?

  5. External Shopping: Deep link to BigBasket/Amazon for one-click purchase?

  6. Voice Input: "Made pasta for dinner" voice command?

  7. Multiple Cooks: Handle households where both cook independently?

16. Future Enhancements

16.1 ML-Powered Suggestions

  • Train model on household’s meal history

  • Factor in weather, season, holidays

  • Learn individual family member preferences

16.2 Smart Ingredient Substitution

  • Suggest alternatives when ingredient is missing

  • Learn successful substitutions from user feedback

16.3 Budget Tracking

  • Track meal costs based on ingredient prices

  • Generate budget-conscious menu suggestions

16.4 Recipe Sharing

  • Share recipes between households

  • Community recipe library


Document Version: 1.0 Created: 2026-01-24 Status: Ready for Review