feat: implement Iteration 1 — AI recipe recommendations

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>
This commit is contained in:
dbastrikin
2026-02-21 22:43:29 +02:00
parent 24219b611e
commit e57ff8e06c
41 changed files with 5994 additions and 353 deletions

367
docs/plans/Iteration_4.md Normal file
View File

@@ -0,0 +1,367 @@
# Итерация 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 на время генерации (510 сек)
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
```
┌─────────────────────────────────────┐
│ Меню [← Пред] [След →]│
│ Неделя 1622 февраля │
├─────────────────────────────────────┤
│ │
│ Понедельник, 16 фев 1 780 ккал │
│ ┌──────────────────────────────┐ │
│ │ 🌅 Завтрак ≈320 ккал │ │
│ │ [фото] Овсянка с яблоком │ │
│ │ [Изменить]│ │
│ ├──────────────────────────────┤ │
│ │ ☀ Обед ≈680 ккал │ │
│ │ [фото] Куриный суп │ │
│ │ [Изменить]│ │
│ ├──────────────────────────────┤ │
│ │ 🌙 Ужин ≈780 ккал │ │
│ │ [фото] Рис с овощами │ │
│ │ [Изменить]│ │
│ └──────────────────────────────┘ │
│ │
│ Вторник, 17 фев 1 820 ккал │
│ ┌──────────────────────────────┐ │
│ │ ... │ │
│ └──────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✨ Сгенерировать новое меню │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ 🛒 Список покупок │ │
│ └─────────────────────────────┘ │
│ │
├─────────────────────────────────────┤
│ [Главная] [Продукты] [Меню●] [Рецепты] [Профиль] │
└─────────────────────────────────────┘
```
### Skeleton при генерации (510 сек)
```
┌─────────────────────────────────────┐
│ Меню │
├─────────────────────────────────────┤
│ │
│ Составляем меню на неделю... │
│ Учитываем ваши продукты и цели │
│ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ │
│ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │
│ │
└─────────────────────────────────────┘
```
---
## 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.