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:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user