Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go
Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()
Project:
- Add CLAUDE.md with English-only rule for comments and commit messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
368 lines
14 KiB
Markdown
368 lines
14 KiB
Markdown
# Итерация 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.
|