package home import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "time" "github.com/food-ai/backend/internal/infra/middleware" "github.com/jackc/pgx/v5/pgxpool" ) // Handler handles GET /home/summary. type Handler struct { pool *pgxpool.Pool } // NewHandler creates a new Handler. func NewHandler(pool *pgxpool.Pool) *Handler { return &Handler{pool: pool} } // GetSummary handles GET /home/summary. func (h *Handler) GetSummary(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } ctx := r.Context() now := time.Now().UTC() todayStr := now.Format("2006-01-02") // ISO day of week: Monday=1 … Sunday=7. wd := int(now.Weekday()) if wd == 0 { wd = 7 } // Monday of the current ISO week. year, week := now.ISOWeek() weekStart := mondayOfISOWeek(year, week).Format("2006-01-02") // Daily calorie goal from user profile. dailyGoal := h.getDailyGoal(ctx, userID) summary := Summary{ Today: TodaySummary{ Date: todayStr, DailyGoal: dailyGoal, LoggedCalories: h.getLoggedCalories(ctx, userID, todayStr), Plan: h.getTodayPlan(ctx, userID, weekStart, wd), }, ExpiringSoon: h.getExpiringSoon(ctx, userID), Recommendations: h.getRecommendations(ctx, userID), } writeJSON(w, http.StatusOK, summary) } // getDailyGoal returns the user's daily_calories setting (default 2000). func (h *Handler) getDailyGoal(ctx context.Context, userID string) int { var goal int err := h.pool.QueryRow(ctx, `SELECT COALESCE(daily_calories, 2000) FROM users WHERE id = $1`, userID, ).Scan(&goal) if err != nil { slog.Warn("home: get daily goal", "user_id", userID, "err", err) return 2000 } return goal } // getLoggedCalories returns total calories logged in meal_diary for today. func (h *Handler) getLoggedCalories(ctx context.Context, userID, date string) float64 { var total float64 _ = h.pool.QueryRow(ctx, `SELECT COALESCE(SUM(calories * portions), 0) FROM meal_diary WHERE user_id = $1 AND date::text = $2`, userID, date, ).Scan(&total) return total } // getTodayPlan returns the three meal slots planned for today. // If no menu exists, all three slots are returned with nil recipe fields. func (h *Handler) getTodayPlan(ctx context.Context, userID, weekStart string, dow int) []MealPlan { const q = ` SELECT mi.meal_type, sr.title, sr.image_url, (sr.nutrition->>'calories')::float FROM menu_plans mp JOIN menu_items mi ON mi.menu_plan_id = mp.id LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id WHERE mp.user_id = $1 AND mp.week_start::text = $2 AND mi.day_of_week = $3` rows, err := h.pool.Query(ctx, q, userID, weekStart, dow) if err != nil { slog.Warn("home: get today plan", "err", err) return defaultPlan() } defer rows.Close() found := map[string]MealPlan{} for rows.Next() { var mealType string var title, imageURL *string var calories *float64 if err := rows.Scan(&mealType, &title, &imageURL, &calories); err != nil { continue } found[mealType] = MealPlan{ MealType: mealType, RecipeTitle: title, RecipeImageURL: imageURL, Calories: calories, } } // Always return all three meal types in order. mealTypes := []string{"breakfast", "lunch", "dinner"} plan := make([]MealPlan, 0, 3) for _, mt := range mealTypes { if mp, ok := found[mt]; ok { plan = append(plan, mp) } else { plan = append(plan, MealPlan{MealType: mt}) } } return plan } // defaultPlan returns three empty meal slots. func defaultPlan() []MealPlan { return []MealPlan{ {MealType: "breakfast"}, {MealType: "lunch"}, {MealType: "dinner"}, } } // getExpiringSoon returns products expiring within the next 3 days. func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []ExpiringSoon { rows, err := h.pool.Query(ctx, ` WITH p AS ( SELECT name, quantity, unit, (added_at + storage_days * INTERVAL '1 day') AS expires_at FROM products WHERE user_id = $1 ) SELECT name, quantity, unit, GREATEST(0, EXTRACT(EPOCH FROM (expires_at - now())) / 86400)::int FROM p WHERE expires_at > now() AND expires_at <= now() + INTERVAL '3 days' ORDER BY expires_at LIMIT 5`, userID, ) if err != nil { slog.Warn("home: get expiring soon", "err", err) return nil } defer rows.Close() var result []ExpiringSoon for rows.Next() { var name, unit string var quantity float64 var daysLeft int if err := rows.Scan(&name, &quantity, &unit, &daysLeft); err != nil { continue } result = append(result, ExpiringSoon{ Name: name, ExpiresInDays: daysLeft, Quantity: fmt.Sprintf("%.0f %s", quantity, unit), }) } return result } // getRecommendations returns the 3 most recently generated recipe recommendations. func (h *Handler) getRecommendations(ctx context.Context, userID string) []Recommendation { rows, err := h.pool.Query(ctx, ` SELECT id, title, COALESCE(image_url, ''), (nutrition->>'calories')::float FROM saved_recipes WHERE user_id = $1 AND source = 'recommendation' ORDER BY saved_at DESC LIMIT 3`, userID, ) if err != nil { slog.Warn("home: get recommendations", "err", err) return nil } defer rows.Close() var result []Recommendation for rows.Next() { var rec Recommendation var cal *float64 if err := rows.Scan(&rec.ID, &rec.Title, &rec.ImageURL, &cal); err != nil { continue } rec.Calories = cal result = append(result, rec) } return result } // mondayOfISOWeek returns the Monday of the given ISO year/week. func mondayOfISOWeek(year, week int) time.Time { jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC) wd := int(jan4.Weekday()) if wd == 0 { wd = 7 } monday1 := jan4.AddDate(0, 0, 1-wd) return monday1.AddDate(0, 0, (week-1)*7) } func writeError(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }