# Итерация 4: Планирование меню **Цель:** пользователь запрашивает меню на неделю — Gemini генерирует полный план питания (21 приём пищи) с учётом продуктов, целей и предпочтений. Из меню автоматически формируется список покупок. **Зависимости:** Итерация 2 (продукты), Итерация 1 (сохранённые рецепты). --- ## Структура задач ``` 4.1 Backend: таблицы меню ├── 4.1.1 Миграция: menu_plans, menu_items └── 4.1.2 Миграция: meal_diary 4.2 Backend: генерация меню ├── 4.2.1 POST /ai/generate-menu ├── 4.2.2 Промпт для Gemini (7 дней × 3 приёма) ├── 4.2.3 Параллельный Pexels для 21 рецепта └── 4.2.4 Сохранение в menu_plans + menu_items + saved_recipes 4.3 Backend: CRUD меню ├── 4.3.1 GET /menu?week=YYYY-WNN ├── 4.3.2 PUT /menu/items/{id} (заменить рецепт) └── 4.3.3 DELETE /menu/items/{id} 4.4 Backend: список покупок ├── 4.4.1 POST /shopping-list/generate (из меню) ├── 4.4.2 GET /shopping-list └── 4.4.3 PATCH /shopping-list/items/{index}/check 4.5 Backend: дневник питания ├── 4.5.1 GET /diary?date=YYYY-MM-DD ├── 4.5.2 POST /diary (добавить запись) └── 4.5.3 DELETE /diary/{id} 4.6 Flutter: экран меню ├── 4.6.1 MenuScreen (7-дневный вид) ├── 4.6.2 Кнопка «Сгенерировать меню» ├── 4.6.3 Редактирование слота (смена рецепта) └── 4.6.4 Skeleton на время генерации (5–10 сек) 4.7 Flutter: список покупок └── 4.7.1 ShoppingListScreen (с галочками) 4.8 Flutter: дневник питания ├── 4.8.1 DiaryScreen (записи за день) └── 4.8.2 Добавление записи (из меню / вручную) ``` --- ## 4.1 Миграции ### menu_plans и menu_items ```sql -- migrations/005_create_menu_plans.sql -- +goose Up CREATE TABLE menu_plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, week_start DATE NOT NULL, -- понедельник недели created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(user_id, week_start) ); CREATE TABLE menu_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), menu_plan_id UUID NOT NULL REFERENCES menu_plans(id) ON DELETE CASCADE, day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner')), recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL, recipe_data JSONB, -- snapshot рецепта (если saved_recipe удалён) UNIQUE(menu_plan_id, day_of_week, meal_type) ); -- +goose Down DROP TABLE menu_items; DROP TABLE menu_plans; ``` ### meal_diary ```sql -- migrations/006_create_meal_diary.sql -- +goose Up CREATE TABLE meal_diary ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, date DATE NOT NULL, meal_type TEXT NOT NULL, name TEXT NOT NULL, portions DECIMAL(5,2) NOT NULL DEFAULT 1, calories DECIMAL(8,2), protein_g DECIMAL(8,2), fat_g DECIMAL(8,2), carbs_g DECIMAL(8,2), source TEXT NOT NULL DEFAULT 'manual', -- manual|recipe|menu|photo recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_meal_diary_user_date ON meal_diary(user_id, date); -- +goose Down DROP TABLE meal_diary; ``` --- ## 4.2 Генерация меню ### 4.2.1 Промпт для Gemini ``` Ты — диетолог-повар. Составь меню на 7 дней для пользователя. Профиль: - Цель: {goal_ru} - Дневная норма калорий: {calories} ккал - Ограничения: {restrictions или "нет"} - Предпочтения кухни: {cuisines или "любые"} Продукты в наличии (приоритет — скоро истекают ⚠): {products_list} Требования: - 3 приёма пищи в день: завтрак (25% КБЖУ), обед (40%), ужин (35%) - Разнообразие: не повторять рецепты - По возможности использовать имеющиеся продукты - КБЖУ рассчитать на 1 порцию (приблизительно) Верни ТОЛЬКО валидный JSON без markdown: { "days": [ { "day": 1, "meals": [ { "meal_type": "breakfast", "recipe": { "title": "Овсяная каша с яблоком", "description": "...", "cuisine": "european", "difficulty": "easy", "prep_time_min": 5, "cook_time_min": 10, "servings": 1, "image_query": "oatmeal apple breakfast bowl", "ingredients": [...], "steps": [...], "tags": [...], "nutrition_per_serving": { "calories": 320, "protein_g": 8, "fat_g": 6, "carbs_g": 58 } } }, { "meal_type": "lunch", "recipe": {...} }, { "meal_type": "dinner", "recipe": {...} } ] }, ... ] } ``` ### 4.2.2 Параллельный Pexels 21 Pexels-запрос выполняется параллельно (горутины). С учётом лимита 200 req/hour — для одного меню это 10% дневного бюджета. Повторяющиеся image_query (между пользователями) следует кэшировать в будущем (Redis, см. TODO.md). ### 4.2.3 Сохранение ```go // Транзакция: // 1. INSERT menu_plans (upsert по user_id + week_start) // 2. INSERT saved_recipes для каждого из 21 рецептов // 3. INSERT menu_items (21 записи с recipe_id → saved_recipes) ``` --- ## 4.3 CRUD меню ### GET /menu?week=2026-W08 ```json { "id": "uuid", "week_start": "2026-02-16", "days": [ { "day": 1, "date": "2026-02-16", "meals": [ { "id": "menu_item_uuid", "meal_type": "breakfast", "recipe": { "id": "saved_recipe_uuid", "title": "Овсяная каша с яблоком", "image_url": "...", "calories": 320, "nutrition_per_serving": {...} } } ], "total_calories": 1780 } ] } ``` ### PUT /menu/items/{id} Заменить рецепт в слоте меню. Тело: `{ "recipe_id": "uuid" }` — ID существующего сохранённого рецепта, или запрос нового от Gemini. --- ## 4.4 Список покупок ### POST /shopping-list/generate ```go // 1. SELECT menu_items JOIN saved_recipes WHERE menu_plan_id=$1 // 2. Извлечь все ингредиенты из всех рецептов // 3. Агрегация по canonical_name (через ingredient_mappings): // суммирование количества для одинаковых ингредиентов // 4. Вычесть уже имеющееся в products (где quantity > 0) // 5. INSERT/UPSERT shopping_lists ``` Gemini не участвует — чистая SQL-агрегация. ### GET /shopping-list ```json [ { "name": "Куриная грудка", "category": "meat", "amount": 1200, "unit": "г", "checked": false, "in_stock": 0 }, { "name": "Яйца", "category": "dairy", "amount": 12, "unit": "шт", "checked": false, "in_stock": 4 } ] ``` --- ## 4.6 Flutter: экран меню ### MenuScreen ``` ┌─────────────────────────────────────┐ │ Меню [← Пред] [След →]│ │ Неделя 16–22 февраля │ ├─────────────────────────────────────┤ │ │ │ Понедельник, 16 фев 1 780 ккал │ │ ┌──────────────────────────────┐ │ │ │ 🌅 Завтрак ≈320 ккал │ │ │ │ [фото] Овсянка с яблоком │ │ │ │ [Изменить]│ │ │ ├──────────────────────────────┤ │ │ │ ☀ Обед ≈680 ккал │ │ │ │ [фото] Куриный суп │ │ │ │ [Изменить]│ │ │ ├──────────────────────────────┤ │ │ │ 🌙 Ужин ≈780 ккал │ │ │ │ [фото] Рис с овощами │ │ │ │ [Изменить]│ │ │ └──────────────────────────────┘ │ │ │ │ Вторник, 17 фев 1 820 ккал │ │ ┌──────────────────────────────┐ │ │ │ ... │ │ │ └──────────────────────────────┘ │ │ │ │ ┌─────────────────────────────┐ │ │ │ ✨ Сгенерировать новое меню │ │ │ └─────────────────────────────┘ │ │ ┌─────────────────────────────┐ │ │ │ 🛒 Список покупок │ │ │ └─────────────────────────────┘ │ │ │ ├─────────────────────────────────────┤ │ [Главная] [Продукты] [Меню●] [Рецепты] [Профиль] │ └─────────────────────────────────────┘ ``` ### Skeleton при генерации (5–10 сек) ``` ┌─────────────────────────────────────┐ │ Меню │ ├─────────────────────────────────────┤ │ │ │ Составляем меню на неделю... │ │ Учитываем ваши продукты и цели │ │ │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │ │ ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ │ │ │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │ │ │ └─────────────────────────────────────┘ ``` --- ## 4.7 Flutter: список покупок ``` ┌─────────────────────────────────────┐ │ [←] Список покупок [Поделиться]│ ├─────────────────────────────────────┤ │ │ │ Мясо │ │ ☐ Куриная грудка 1.2 кг │ │ ☐ Фарш говяжий 500 г │ │ │ │ Молочное │ │ ☑ Яйца 12 шт │ │ (4 шт есть дома) │ │ ☐ Молоко 1 л │ │ │ │ Овощи │ │ ☐ Морковь 3 шт │ │ ☐ Лук репчатый 4 шт │ │ │ │ ┌───────────────────────────────┐ │ │ │ + Добавить вручную │ │ │ └───────────────────────────────┘ │ │ │ ├─────────────────────────────────────┤ │ Осталось купить: 8 позиций │ └─────────────────────────────────────┘ ``` --- ## Оценка нагрузки | Действие | Gemini | Pexels | |----------|--------|--------| | Генерация меню (неделя) | 1 (большой промпт) | до 21 | | Просмотр/редактирование меню | 0 | 0 | | Список покупок | 0 | 0 | | Дневник питания | 0 | 0 | Генерация меню — самый тяжёлый запрос по Pexels. Происходит 1 раз в неделю на пользователя. При 100 DAU × 1/7 = ~14 генераций/день × 21 Pexels = 294 Pexels-запроса/день — в пределах лимита 200 req/hour.