feat: add product selection step before meal planning

Inserts a new PlanProductsSheet as step 1 of the planning flow.
Users see their current products as a multi-select checklist (all
selected by default) before choosing the planning mode and dates.

- Empty state explains the benefit and offers "Add products" CTA
  while always allowing "Plan without products" to skip
- Selected product IDs flow through PlanMenuSheet →
  PlanDatePickerSheet → MenuService.generateForDates → backend
- Backend: added ProductIDs field to generate-menu request body;
  uses ListForPromptByIDs when set, ListForPrompt otherwise
- Backend: added Repository.ListForPromptByIDs (filtered SQL query)
- All 12 ARB locale files updated with planProducts* keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-23 16:07:28 +02:00
parent b6c75a3488
commit b38190ff5b
33 changed files with 1007 additions and 77 deletions

View File

@@ -31,6 +31,8 @@ type UserLoader interface {
// ProductLister returns human-readable product lines for the AI prompt.
type ProductLister interface {
ListForPrompt(ctx context.Context, userID string) ([]string, error)
// ListForPromptByIDs returns only the products with the given IDs.
ListForPromptByIDs(ctx context.Context, userID string, ids []string) ([]string, error)
}
// RecipeSaver creates a dish+recipe and returns the new recipe ID.
@@ -121,9 +123,10 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
}
var body struct {
Week string `json:"week"` // optional, defaults to current week
Dates []string `json:"dates"` // YYYY-MM-DD; triggers partial generation
MealTypes []string `json:"meal_types"` // overrides user preference when set
Week string `json:"week"` // optional, defaults to current week
Dates []string `json:"dates"` // YYYY-MM-DD; triggers partial generation
MealTypes []string `json:"meal_types"` // overrides user preference when set
ProductIDs []string `json:"product_ids"` // when set, only these products are passed to AI
}
_ = json.NewDecoder(r.Body).Decode(&body)
@@ -136,7 +139,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
}
if len(body.Dates) > 0 {
h.generateForDates(w, r, userID, u, body.Dates, body.MealTypes)
h.generateForDates(w, r, userID, u, body.Dates, body.MealTypes, body.ProductIDs)
return
}
@@ -150,8 +153,14 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
if len(body.ProductIDs) > 0 {
if products, productError := h.productLister.ListForPromptByIDs(r.Context(), userID, body.ProductIDs); productError == nil {
menuReq.AvailableProducts = products
}
} else {
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
}
}
days, generateError := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
@@ -194,7 +203,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
// generateForDates handles partial menu generation for specific dates and meal types.
// It groups dates by ISO week, generates only the requested slots, upserts them
// without touching other existing slots, and returns {"plans":[...]}.
func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userID string, u *user.User, dates, requestedMealTypes []string) {
func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userID string, u *user.User, dates, requestedMealTypes, productIDs []string) {
mealTypes := requestedMealTypes
if len(mealTypes) == 0 {
// Fall back to user's preferred meal types.
@@ -212,8 +221,14 @@ func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userI
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
menuReq.MealTypes = mealTypes
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
if len(productIDs) > 0 {
if products, productError := h.productLister.ListForPromptByIDs(r.Context(), userID, productIDs); productError == nil {
menuReq.AvailableProducts = products
}
} else {
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
}
}
weekGroups := groupDatesByWeek(dates)

View File

@@ -161,6 +161,46 @@ func (r *Repository) ListForPrompt(requestContext context.Context, userID string
return lines, rows.Err()
}
func (r *Repository) ListForPromptByIDs(requestContext context.Context, userID string, ids []string) ([]string, error) {
rows, queryError := r.pool.Query(requestContext, `
WITH up AS (
SELECT name, quantity, unit,
(added_at + storage_days * INTERVAL '1 day') AS expires_at
FROM user_products
WHERE user_id = $1 AND id = ANY($2)
)
SELECT name, quantity, unit, expires_at
FROM up
ORDER BY expires_at ASC`, userID, ids)
if queryError != nil {
return nil, fmt.Errorf("list user products by ids for prompt: %w", queryError)
}
defer rows.Close()
var lines []string
now := time.Now()
for rows.Next() {
var name, unit string
var qty float64
var expiresAt time.Time
if scanError := rows.Scan(&name, &qty, &unit, &expiresAt); scanError != nil {
return nil, fmt.Errorf("scan user product for prompt: %w", scanError)
}
daysLeft := int(expiresAt.Sub(now).Hours() / 24)
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
switch {
case daysLeft <= 0:
line += " (expires today ⚠)"
case daysLeft == 1:
line += " (expires tomorrow ⚠)"
case daysLeft <= 3:
line += fmt.Sprintf(" (expires in %d days ⚠)", daysLeft)
}
lines = append(lines, line)
}
return lines, rows.Err()
}
// --- helpers ---
func scanUserProduct(row pgx.Row) (*UserProduct, error) {