feat: implement Iteration 5 — home screen dashboard

Backend:
- internal/home: GET /home/summary endpoint
  - Today's meal plan from menu_plans/menu_items
  - Logged calories sum from meal_diary
  - Daily goal from user profile (default 2000)
  - Expiring products within 3 days
  - Last 3 saved recommendations (no AI call on home load)
- Wire homeHandler in server.go and main.go

Flutter:
- shared/models/home_summary.dart: HomeSummary, TodaySummary,
  TodayMealPlan, ExpiringSoon, HomeRecipe
- features/home/home_service.dart + home_provider.dart
- features/home/home_screen.dart: greeting, calorie progress bar,
  today's meals card, expiring banner, quick actions row,
  recommendations horizontal list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-22 15:25:28 +02:00
parent d53e019d90
commit 9530dc6ff9
8 changed files with 899 additions and 4 deletions

View File

@@ -15,6 +15,7 @@ import (
"github.com/food-ai/backend/internal/database"
"github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware"
@@ -122,6 +123,9 @@ func run() error {
diaryRepo := diary.NewRepository(pool)
diaryHandler := diary.NewHandler(diaryRepo)
// Home domain
homeHandler := home.NewHandler(pool)
// Router
router := server.NewRouter(
pool,
@@ -134,6 +138,7 @@ func run() error {
recognitionHandler,
menuHandler,
diaryHandler,
homeHandler,
authMW,
cfg.AllowedOrigins,
)

View File

@@ -0,0 +1,238 @@
package home
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/food-ai/backend/internal/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, `
SELECT name, quantity, unit,
GREATEST(0, EXTRACT(EPOCH FROM (expires_at - now())) / 86400)::int
FROM products
WHERE user_id = $1
AND expires_at IS NOT NULL
AND 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)
}

View File

@@ -0,0 +1,39 @@
package home
// Summary is the response for GET /home/summary.
type Summary struct {
Today TodaySummary `json:"today"`
ExpiringSoon []ExpiringSoon `json:"expiring_soon"`
Recommendations []Recommendation `json:"recommendations"`
}
// TodaySummary contains the day-level overview.
type TodaySummary struct {
Date string `json:"date"`
DailyGoal int `json:"daily_goal"`
LoggedCalories float64 `json:"logged_calories"`
Plan []MealPlan `json:"plan"`
}
// MealPlan is a single planned meal slot for today.
type MealPlan struct {
MealType string `json:"meal_type"`
RecipeTitle *string `json:"recipe_title"`
RecipeImageURL *string `json:"recipe_image_url"`
Calories *float64 `json:"calories"`
}
// ExpiringSoon is a product expiring within 3 days.
type ExpiringSoon struct {
Name string `json:"name"`
ExpiresInDays int `json:"expires_in_days"`
Quantity string `json:"quantity"`
}
// Recommendation is a saved recipe shown on the home screen.
type Recommendation struct {
ID string `json:"id"`
Title string `json:"title"`
ImageURL string `json:"image_url"`
Calories *float64 `json:"calories"`
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware"
@@ -29,6 +30,7 @@ func NewRouter(
recognitionHandler *recognition.Handler,
menuHandler *menu.Handler,
diaryHandler *diary.Handler,
homeHandler *home.Handler,
authMiddleware func(http.Handler) http.Handler,
allowedOrigins []string,
) *chi.Mux {
@@ -92,6 +94,8 @@ func NewRouter(
r.Delete("/{id}", diaryHandler.Delete)
})
r.Get("/home/summary", homeHandler.GetSummary)
r.Route("/ai", func(r chi.Router) {
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)