From 72c50549d7481843d97dcd245a62e886adb829d6 Mon Sep 17 00:00:00 2001 From: Nathan Lebrun Date: Sat, 25 Oct 2025 15:40:28 +0200 Subject: [PATCH] base feature --- .gitignore | 43 ++++ IMPLEMENTATION_NOTES.txt | 67 ++++++ Makefile | 46 ++++ database/db.go | 276 ++++++++++++++++++++++++ go.mod | 5 + go.sum | 2 + handlers/grocerylist.go | 45 ++++ handlers/ingredients.go | 113 ++++++++++ handlers/meals.go | 335 +++++++++++++++++++++++++++++ handlers/weekplan.go | 321 ++++++++++++++++++++++++++++ main.go | 218 +++++++++++++++++++ models/models.go | 44 ++++ sample_data.sql | 94 +++++++++ start.sh | 49 +++++ static/styles.css | 446 +++++++++++++++++++++++++++++++++++++++ 15 files changed, 2104 insertions(+) create mode 100644 .gitignore create mode 100644 IMPLEMENTATION_NOTES.txt create mode 100644 Makefile create mode 100644 database/db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/grocerylist.go create mode 100644 handlers/ingredients.go create mode 100644 handlers/meals.go create mode 100644 handlers/weekplan.go create mode 100644 main.go create mode 100644 models/models.go create mode 100644 sample_data.sql create mode 100755 start.sh create mode 100644 static/styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6851710 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +mealprep + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Database files +*.db +*.db-shm +*.db-wal + +# Dependency directories +vendor/ + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Environment files +.env +.env.local + +# Log files +*.log + +# Temporary files +tmp/ +temp/ diff --git a/IMPLEMENTATION_NOTES.txt b/IMPLEMENTATION_NOTES.txt new file mode 100644 index 0000000..e256c87 --- /dev/null +++ b/IMPLEMENTATION_NOTES.txt @@ -0,0 +1,67 @@ +MEAL TYPES - WORKING! + +=== ✅ IMPLEMENTATION COMPLETE === + +The meal types feature is fully working. + +If you had an old database, you need to either: +1. Delete mealprep.db and restart (fresh start) +2. Or the migration will auto-add the meal_type column + +=== WHAT'S WORKING === + +✅ Meals tab loads with type dropdown +✅ Week plan loads with 3 sections per day +✅ Each section filters meals by type +✅ Grocery list still works +✅ All CRUD operations working + +=== FRESH START (RECOMMENDED) === + +If meals/week plan tabs don't show: + +rm mealprep.db +./start.sh + +This creates a fresh database with meal_type column. + +=== MIGRATION INCLUDED === + +The code now includes automatic migration: +- Checks if meal_type column exists +- Adds it if missing +- Sets default to 'lunch' for existing meals + +=== FEATURES === + +1. CREATE MEAL + - Name, description, type dropdown + - Tags: 🟠 Breakfast, 🔵 Lunch, 🟣 Snack + +2. WEEK PLAN (per day) + - 🌅 Breakfast section + - 🍽️ Lunch section + - 🍪 Snack section + - Each with filtered dropdown + +3. GROCERY LIST + - Aggregates all meals regardless of type + - Works perfectly + +=== TESTED === + +✅ Server starts successfully +✅ /meals endpoint returns HTML +✅ /week-plan endpoint returns HTML +✅ Type dropdowns render +✅ Sections organized by meal type + +=== READY TO USE === + +Fresh database: +rm mealprep.db +./start.sh +http://localhost:8080 + +Everything works! + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6376c24 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: run build clean test deps install + +# Run the application +run: + go run main.go + +# Build the binary +build: + go build -o mealprep main.go + +# Install dependencies +deps: + go mod tidy + go mod download + +# Clean build artifacts and database +clean: + rm -f mealprep mealprep.db + +# Clean only database (for fresh start) +clean-db: + rm -f mealprep.db + +# Run with auto-reload (requires air: go install github.com/cosmtrek/air@latest) +dev: + air + +# Test the application +test: + go test ./... + +# Install the binary to GOPATH +install: + go install + +# Show help +help: + @echo "Available targets:" + @echo " run - Run the application" + @echo " build - Build the binary" + @echo " deps - Install dependencies" + @echo " clean - Remove binary and database" + @echo " clean-db - Remove only database" + @echo " test - Run tests" + @echo " install - Install binary to GOPATH" + @echo " help - Show this help message" diff --git a/database/db.go b/database/db.go new file mode 100644 index 0000000..f3f9980 --- /dev/null +++ b/database/db.go @@ -0,0 +1,276 @@ +package database + +import ( + "database/sql" + "fmt" + "mealprep/models" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +var DB *sql.DB + +// InitDB initializes the database and creates tables +func InitDB(dbPath string) error { + var err error + DB, err = sql.Open("sqlite3", dbPath) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + // Test connection + if err = DB.Ping(); err != nil { + return fmt.Errorf("failed to ping database: %w", err) + } + + // Create tables + if err = createTables(); err != nil { + return fmt.Errorf("failed to create tables: %w", err) + } + + // Run migrations + if err = runMigrations(); err != nil { + return fmt.Errorf("failed to run migrations: %w", err) + } + + return nil +} + +func createTables() error { + schema := ` + CREATE TABLE IF NOT EXISTS ingredients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + unit TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS meals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + meal_type TEXT NOT NULL DEFAULT 'lunch' + ); + + CREATE TABLE IF NOT EXISTS meal_ingredients ( + meal_id INTEGER NOT NULL, + ingredient_id INTEGER NOT NULL, + quantity REAL NOT NULL, + PRIMARY KEY (meal_id, ingredient_id), + FOREIGN KEY (meal_id) REFERENCES meals(id) ON DELETE CASCADE, + FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS week_plan ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + meal_id INTEGER NOT NULL, + FOREIGN KEY (meal_id) REFERENCES meals(id) ON DELETE CASCADE + ); + ` + + _, err := DB.Exec(schema) + return err +} + +func runMigrations() error { + // Check if meal_type column exists + var count int + err := DB.QueryRow("SELECT COUNT(*) FROM pragma_table_info('meals') WHERE name='meal_type'").Scan(&count) + if err != nil { + return err + } + + // Add meal_type column if it doesn't exist + if count == 0 { + _, err = DB.Exec("ALTER TABLE meals ADD COLUMN meal_type TEXT NOT NULL DEFAULT 'lunch'") + if err != nil { + return err + } + } + + return nil +} + +// Ingredient operations + +func GetAllIngredients() ([]models.Ingredient, error) { + rows, err := DB.Query("SELECT id, name, unit FROM ingredients ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + + var ingredients []models.Ingredient + for rows.Next() { + var ing models.Ingredient + if err := rows.Scan(&ing.ID, &ing.Name, &ing.Unit); err != nil { + return nil, err + } + ingredients = append(ingredients, ing) + } + return ingredients, nil +} + +func AddIngredient(name, unit string) (int64, error) { + result, err := DB.Exec("INSERT INTO ingredients (name, unit) VALUES (?, ?)", name, unit) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func DeleteIngredient(id int) error { + _, err := DB.Exec("DELETE FROM ingredients WHERE id = ?", id) + return err +} + +// Meal operations + +func GetAllMeals() ([]models.Meal, error) { + rows, err := DB.Query("SELECT id, name, description, meal_type FROM meals ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + + var meals []models.Meal + for rows.Next() { + var meal models.Meal + if err := rows.Scan(&meal.ID, &meal.Name, &meal.Description, &meal.MealType); err != nil { + return nil, err + } + meals = append(meals, meal) + } + return meals, nil +} + +func GetMealByID(id int) (*models.Meal, error) { + var meal models.Meal + err := DB.QueryRow("SELECT id, name, description, meal_type FROM meals WHERE id = ?", id). + Scan(&meal.ID, &meal.Name, &meal.Description, &meal.MealType) + if err != nil { + return nil, err + } + return &meal, nil +} + +func AddMeal(name, description, mealType string) (int64, error) { + result, err := DB.Exec("INSERT INTO meals (name, description, meal_type) VALUES (?, ?, ?)", name, description, mealType) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func DeleteMeal(id int) error { + _, err := DB.Exec("DELETE FROM meals WHERE id = ?", id) + return err +} + +// Meal Ingredients operations + +func GetMealIngredients(mealID int) ([]models.MealIngredient, error) { + query := ` + SELECT mi.meal_id, mi.ingredient_id, mi.quantity, i.name, i.unit + FROM meal_ingredients mi + JOIN ingredients i ON mi.ingredient_id = i.id + WHERE mi.meal_id = ? + ORDER BY i.name + ` + rows, err := DB.Query(query, mealID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ingredients []models.MealIngredient + for rows.Next() { + var mi models.MealIngredient + if err := rows.Scan(&mi.MealID, &mi.IngredientID, &mi.Quantity, &mi.IngredientName, &mi.Unit); err != nil { + return nil, err + } + ingredients = append(ingredients, mi) + } + return ingredients, nil +} + +func AddMealIngredient(mealID, ingredientID int, quantity float64) error { + _, err := DB.Exec( + "INSERT OR REPLACE INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES (?, ?, ?)", + mealID, ingredientID, quantity, + ) + return err +} + +func DeleteMealIngredient(mealID, ingredientID int) error { + _, err := DB.Exec("DELETE FROM meal_ingredients WHERE meal_id = ? AND ingredient_id = ?", mealID, ingredientID) + return err +} + +// Week Plan operations + +func GetWeekPlan() ([]models.WeekPlanEntry, error) { + query := ` + SELECT wp.id, wp.date, wp.meal_id, m.name, m.meal_type + FROM week_plan wp + JOIN meals m ON wp.meal_id = m.id + ORDER BY wp.date, m.meal_type + ` + rows, err := DB.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []models.WeekPlanEntry + for rows.Next() { + var entry models.WeekPlanEntry + var dateStr string + if err := rows.Scan(&entry.ID, &dateStr, &entry.MealID, &entry.MealName, &entry.MealType); err != nil { + return nil, err + } + entry.Date, _ = time.Parse("2006-01-02", dateStr) + entries = append(entries, entry) + } + return entries, nil +} + +func AddWeekPlanEntry(date time.Time, mealID int) error { + dateStr := date.Format("2006-01-02") + _, err := DB.Exec("INSERT INTO week_plan (date, meal_id) VALUES (?, ?)", dateStr, mealID) + return err +} + +func DeleteWeekPlanEntry(id int) error { + _, err := DB.Exec("DELETE FROM week_plan WHERE id = ?", id) + return err +} + +// Grocery List operations + +func GetGroceryList() ([]models.GroceryItem, error) { + query := ` + SELECT i.name, SUM(mi.quantity) as total_quantity, i.unit + FROM week_plan wp + JOIN meal_ingredients mi ON wp.meal_id = mi.meal_id + JOIN ingredients i ON mi.ingredient_id = i.id + GROUP BY i.id, i.name, i.unit + ORDER BY i.name + ` + rows, err := DB.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []models.GroceryItem + for rows.Next() { + var item models.GroceryItem + if err := rows.Scan(&item.IngredientName, &item.TotalQuantity, &item.Unit); err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2b177cb --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module mealprep + +go 1.21 + +require github.com/mattn/go-sqlite3 v1.14.18 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..810a101 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= +github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= diff --git a/handlers/grocerylist.go b/handlers/grocerylist.go new file mode 100644 index 0000000..f540871 --- /dev/null +++ b/handlers/grocerylist.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "html/template" + "mealprep/database" + "net/http" +) + +// GroceryListHandler handles the grocery list page +func GroceryListHandler(w http.ResponseWriter, r *http.Request) { + groceryItems, err := database.GetGroceryList() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tmpl := ` +
+

Grocery List

+

This list is automatically generated from your week plan.

+ + {{if .Items}} +
+ {{range .Items}} +
+ {{.IngredientName}} + {{printf "%.2f" .TotalQuantity}} {{.Unit}} +
+ {{end}} +
+ {{else}} +

No items in your grocery list. Add meals to your week plan to generate a grocery list.

+ {{end}} +
+ ` + + data := struct { + Items interface{} + }{ + Items: groceryItems, + } + + t := template.Must(template.New("grocerylist").Parse(tmpl)) + t.Execute(w, data) +} diff --git a/handlers/ingredients.go b/handlers/ingredients.go new file mode 100644 index 0000000..41a6b7e --- /dev/null +++ b/handlers/ingredients.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "html/template" + "mealprep/database" + "net/http" + "strconv" + "strings" +) + +// IngredientsHandler handles the ingredients page +func IngredientsHandler(w http.ResponseWriter, r *http.Request) { + ingredients, err := database.GetAllIngredients() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tmpl := ` +
+

Ingredients

+ +
+ + + +
+ +
+ {{range .}} +
+ {{.Name}} + ({{.Unit}}) + +
+ {{end}} +
+
+ ` + + t := template.Must(template.New("ingredients").Parse(tmpl)) + t.Execute(w, ingredients) +} + +// AddIngredientHandler handles adding a new ingredient +func AddIngredientHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + unit := strings.TrimSpace(r.FormValue("unit")) + + if name == "" || unit == "" { + http.Error(w, "Name and unit are required", http.StatusBadRequest) + return + } + + id, err := database.AddIngredient(name, unit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tmpl := ` +
+ {{.Name}} + ({{.Unit}}) + +
+ ` + + data := struct { + ID int64 + Name string + Unit string + }{id, name, unit} + + t := template.Must(template.New("ingredient").Parse(tmpl)) + t.Execute(w, data) +} + +// DeleteIngredientHandler handles deleting an ingredient +func DeleteIngredientHandler(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/ingredients/") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + if err := database.DeleteIngredient(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/handlers/meals.go b/handlers/meals.go new file mode 100644 index 0000000..1d3eaed --- /dev/null +++ b/handlers/meals.go @@ -0,0 +1,335 @@ +package handlers + +import ( + "html/template" + "mealprep/database" + "net/http" + "strconv" + "strings" +) + +// MealsHandler handles the meals page +func MealsHandler(w http.ResponseWriter, r *http.Request) { + meals, err := database.GetAllMeals() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tmpl := ` +
+

Meals

+ +
+ + + + +
+ +
+ {{range .}} +
+
+
+ {{.Name}} + {{.MealType}} + {{.Description}} +
+
+ + +
+
+
+
+ {{end}} +
+
+ ` + + t := template.Must(template.New("meals").Parse(tmpl)) + t.Execute(w, meals) +} + +// AddMealHandler handles adding a new meal +func AddMealHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + description := strings.TrimSpace(r.FormValue("description")) + mealType := r.FormValue("meal_type") + + if name == "" || mealType == "" { + http.Error(w, "Name and meal type are required", http.StatusBadRequest) + return + } + + // Validate meal type + if mealType != "breakfast" && mealType != "lunch" && mealType != "snack" { + http.Error(w, "Invalid meal type", http.StatusBadRequest) + return + } + + id, err := database.AddMeal(name, description, mealType) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tmpl := ` +
+
+
+ {{.Name}} + {{.MealType}} + {{.Description}} +
+
+ + +
+
+
+
+ ` + + data := struct { + ID int64 + Name string + Description string + MealType string + }{id, name, description, mealType} + + t := template.Must(template.New("meal").Parse(tmpl)) + t.Execute(w, data) +} + +// DeleteMealHandler handles deleting a meal +func DeleteMealHandler(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/meals/") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + if err := database.DeleteMeal(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// GetMealIngredientsHandler shows ingredients for a specific meal +func GetMealIngredientsHandler(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 3 { + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + + mealID, err := strconv.Atoi(parts[2]) + if err != nil { + http.Error(w, "Invalid meal ID", http.StatusBadRequest) + return + } + + mealIngredients, err := database.GetMealIngredients(mealID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + allIngredients, err := database.GetAllIngredients() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tmpl := ` +
+

Ingredients:

+ +
+ + + +
+ +
+ {{range .MealIngredients}} +
+ {{.IngredientName}}: {{.Quantity}} {{.Unit}} + +
+ {{end}} +
+
+ ` + + data := struct { + MealID int + MealIngredients interface{} + AllIngredients interface{} + }{ + MealID: mealID, + MealIngredients: mealIngredients, + AllIngredients: allIngredients, + } + + t := template.Must(template.New("mealIngredients").Parse(tmpl)) + t.Execute(w, data) +} + +// AddMealIngredientHandler adds an ingredient to a meal +func AddMealIngredientHandler(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 3 { + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + + mealID, err := strconv.Atoi(parts[2]) + if err != nil { + http.Error(w, "Invalid meal ID", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + ingredientID, err := strconv.Atoi(r.FormValue("ingredient_id")) + if err != nil { + http.Error(w, "Invalid ingredient ID", http.StatusBadRequest) + return + } + + quantity, err := strconv.ParseFloat(r.FormValue("quantity"), 64) + if err != nil { + http.Error(w, "Invalid quantity", http.StatusBadRequest) + return + } + + if err := database.AddMealIngredient(mealID, ingredientID, quantity); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Get the ingredient details to display + ingredients, err := database.GetMealIngredients(mealID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Find the ingredient we just added + var addedIngredient *interface{} + for _, ing := range ingredients { + if ing.IngredientID == ingredientID { + var temp interface{} = ing + addedIngredient = &temp + break + } + } + + if addedIngredient == nil { + http.Error(w, "Could not find added ingredient", http.StatusInternalServerError) + return + } + + tmpl := ` +
+ {{.IngredientName}}: {{.Quantity}} {{.Unit}} + +
+ ` + + t := template.Must(template.New("mealIngredient").Parse(tmpl)) + t.Execute(w, *addedIngredient) +} + +// DeleteMealIngredientHandler removes an ingredient from a meal +func DeleteMealIngredientHandler(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 5 { + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + + mealID, err := strconv.Atoi(parts[2]) + if err != nil { + http.Error(w, "Invalid meal ID", http.StatusBadRequest) + return + } + + ingredientID, err := strconv.Atoi(parts[4]) + if err != nil { + http.Error(w, "Invalid ingredient ID", http.StatusBadRequest) + return + } + + if err := database.DeleteMealIngredient(mealID, ingredientID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/handlers/weekplan.go b/handlers/weekplan.go new file mode 100644 index 0000000..061e282 --- /dev/null +++ b/handlers/weekplan.go @@ -0,0 +1,321 @@ +package handlers + +import ( + "html/template" + "mealprep/database" + "net/http" + "strconv" + "strings" + "time" +) + +// WeekPlanHandler handles the week plan page +func WeekPlanHandler(w http.ResponseWriter, r *http.Request) { + weekPlan, err := database.GetWeekPlan() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + meals, err := database.GetAllMeals() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Group by date + planByDate := make(map[string][]interface{}) + for _, entry := range weekPlan { + dateStr := entry.Date.Format("2006-01-02") + planByDate[dateStr] = append(planByDate[dateStr], entry) + } + + // Get next 7 days starting from upcoming Monday (or current Monday if today is Monday) + today := time.Now() + + // Calculate the upcoming Monday + // If today is Monday, use today. Otherwise, find next Monday. + weekday := int(today.Weekday()) + daysUntilMonday := (8 - weekday) % 7 + if weekday == 1 { // Today is Monday + daysUntilMonday = 0 + } + + startDate := today.AddDate(0, 0, daysUntilMonday) + + var dates []string + for i := 0; i < 7; i++ { + date := startDate.AddDate(0, 0, i) + dates = append(dates, date.Format("2006-01-02")) + } + + tmpl := ` +
+

Week Plan

+

Planning week: {{.WeekStart}} - {{.WeekEnd}}

+ +
+ {{range $index, $date := .Dates}} +
+

{{formatDate $date}}

+ + +
+

🌅 Breakfast

+
+ {{$entries := index $.PlanByDate $date}} + {{range $entries}} + {{if eq .MealType "breakfast"}} +
+ {{.MealName}} + +
+ {{end}} + {{end}} +
+
+ + + + +
+
+ + +
+

🍽️ Lunch

+
+ {{$entries := index $.PlanByDate $date}} + {{range $entries}} + {{if eq .MealType "lunch"}} +
+ {{.MealName}} + +
+ {{end}} + {{end}} +
+
+ + + + +
+
+ + +
+

🍪 Snack

+
+ {{$entries := index $.PlanByDate $date}} + {{range $entries}} + {{if eq .MealType "snack"}} +
+ {{.MealName}} + +
+ {{end}} + {{end}} +
+
+ + + + +
+
+
+ {{end}} +
+
+ ` + + funcMap := template.FuncMap{ + "formatDate": func(dateStr string) string { + date, _ := time.Parse("2006-01-02", dateStr) + // Show full date with day name + return date.Format("Monday, Jan 2") + }, + "index": func(m map[string][]interface{}, key string) []interface{} { + return m[key] + }, + } + + // Format week range for display + weekStart := startDate.Format("Mon, Jan 2") + weekEnd := startDate.AddDate(0, 0, 6).Format("Mon, Jan 2") + + // Separate meals by type + var breakfastMeals, lunchMeals, snackMeals []interface{} + for _, meal := range meals { + switch meal.MealType { + case "breakfast": + breakfastMeals = append(breakfastMeals, meal) + case "lunch": + lunchMeals = append(lunchMeals, meal) + case "snack": + snackMeals = append(snackMeals, meal) + } + } + + data := struct { + Dates []string + PlanByDate map[string][]interface{} + BreakfastMeals interface{} + LunchMeals interface{} + SnackMeals interface{} + WeekStart string + WeekEnd string + }{ + Dates: dates, + PlanByDate: planByDate, + BreakfastMeals: breakfastMeals, + LunchMeals: lunchMeals, + SnackMeals: snackMeals, + WeekStart: weekStart, + WeekEnd: weekEnd, + } + + t := template.Must(template.New("weekplan").Funcs(funcMap).Parse(tmpl)) + t.Execute(w, data) +} + +// AddWeekPlanEntryHandler adds a meal to a specific day +func AddWeekPlanEntryHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + dateStr := r.FormValue("date") + mealType := r.FormValue("meal_type") + mealID, err := strconv.Atoi(r.FormValue("meal_id")) + if err != nil { + http.Error(w, "Invalid meal ID", http.StatusBadRequest) + return + } + + // Validate meal type + if mealType != "breakfast" && mealType != "lunch" && mealType != "snack" { + http.Error(w, "Invalid meal type", http.StatusBadRequest) + return + } + + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + http.Error(w, "Invalid date", http.StatusBadRequest) + return + } + + if err := database.AddWeekPlanEntry(date, mealID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Get the meal details + meal, err := database.GetMealByID(mealID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Verify meal type matches + if meal.MealType != mealType { + http.Error(w, "Meal type does not match", http.StatusBadRequest) + return + } + + // Get the ID of the entry we just added + weekPlan, err := database.GetWeekPlan() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var entryID int + for _, entry := range weekPlan { + if entry.Date.Format("2006-01-02") == dateStr && entry.MealID == mealID { + entryID = entry.ID + } + } + + tmpl := ` +
+ {{.MealName}} + +
+ ` + + data := struct { + ID int + MealName string + }{entryID, meal.Name} + + t := template.Must(template.New("planEntry").Parse(tmpl)) + t.Execute(w, data) +} + +// DeleteWeekPlanEntryHandler removes a meal from the week plan +func DeleteWeekPlanEntryHandler(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/week-plan/") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + if err := database.DeleteWeekPlanEntry(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cf09b9f --- /dev/null +++ b/main.go @@ -0,0 +1,218 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "mealprep/database" + "mealprep/handlers" + "net/http" + "strings" +) + +func main() { + // Print startup banner + printBanner() + + // Initialize database + if err := database.InitDB("mealprep.db"); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + defer database.DB.Close() + + log.Println("✅ Database initialized successfully") + + // Static files + fs := http.FileServer(http.Dir("static")) + http.Handle("/static/", http.StripPrefix("/static/", fs)) + + // Routes + http.HandleFunc("/", indexHandler) + + // Ingredients + http.HandleFunc("/ingredients", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + handlers.IngredientsHandler(w, r) + case "POST": + handlers.AddIngredientHandler(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + http.HandleFunc("/ingredients/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + handlers.DeleteIngredientHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Meals + http.HandleFunc("/meals", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + handlers.MealsHandler(w, r) + case "POST": + handlers.AddMealHandler(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + http.HandleFunc("/meals/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if strings.Contains(path, "/ingredients") { + // Meal ingredients routes + if r.Method == "GET" { + handlers.GetMealIngredientsHandler(w, r) + } else if r.Method == "POST" { + handlers.AddMealIngredientHandler(w, r) + } else if r.Method == "DELETE" { + handlers.DeleteMealIngredientHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + } else { + // Meal delete route + if r.Method == "DELETE" { + handlers.DeleteMealHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + } + }) + + // Week Plan + http.HandleFunc("/week-plan", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + handlers.WeekPlanHandler(w, r) + case "POST": + handlers.AddWeekPlanEntryHandler(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + http.HandleFunc("/week-plan/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + handlers.DeleteWeekPlanEntryHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Grocery List + http.HandleFunc("/grocery-list", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + handlers.GroceryListHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + + // Start server + port := "8080" + fmt.Println() + log.Printf("🚀 Server running on http://localhost:%s", port) + log.Println("📝 Press Ctrl+C to stop the server") + fmt.Println() + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("❌ Failed to start server: %v", err) + } +} + +func printBanner() { + banner := ` +╔══════════════════════════════════════════════════════════════╗ +║ ║ +║ 🍽️ MEAL PREP PLANNER 🍽️ ║ +║ ║ +║ Plan your meals • Generate grocery lists ║ +║ ║ +╚══════════════════════════════════════════════════════════════╝ +` + fmt.Println(banner) + log.Println("🔧 Initializing application...") +} + +func indexHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + tmpl := ` + + + + + + Meal Prep Planner + + + + +
+
+

🍽️ Meal Prep Planner

+
+
+ +
+
+ + + + +
+ +
+
+
+
+ + + + + ` + + t := template.Must(template.New("index").Parse(tmpl)) + if err := t.Execute(w, nil); err != nil { + log.Printf("Error rendering template: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..29fb46b --- /dev/null +++ b/models/models.go @@ -0,0 +1,44 @@ +package models + +import "time" + +// Ingredient represents a food ingredient +type Ingredient struct { + ID int `json:"id"` + Name string `json:"name"` + Unit string `json:"unit"` // e.g., "grams", "ml", "cups", "pieces", "tbsp" +} + +// Meal represents a meal recipe +type Meal struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + MealType string `json:"meal_type"` // "breakfast", "lunch", "snack" +} + +// MealIngredient represents an ingredient in a meal with its quantity +type MealIngredient struct { + MealID int `json:"meal_id"` + IngredientID int `json:"ingredient_id"` + Quantity float64 `json:"quantity"` + // Joined fields + IngredientName string `json:"ingredient_name,omitempty"` + Unit string `json:"unit,omitempty"` +} + +// WeekPlanEntry represents a meal planned for a specific date +type WeekPlanEntry struct { + ID int `json:"id"` + Date time.Time `json:"date"` + MealID int `json:"meal_id"` + MealName string `json:"meal_name,omitempty"` + MealType string `json:"meal_type,omitempty"` // "breakfast", "lunch", "snack" +} + +// GroceryItem represents an aggregated ingredient for shopping +type GroceryItem struct { + IngredientName string `json:"ingredient_name"` + TotalQuantity float64 `json:"total_quantity"` + Unit string `json:"unit"` +} diff --git a/sample_data.sql b/sample_data.sql new file mode 100644 index 0000000..2aed7ee --- /dev/null +++ b/sample_data.sql @@ -0,0 +1,94 @@ +-- Sample data for Meal Prep Planner +-- Run this to populate the database with example data for testing + +-- Ingredients +INSERT INTO ingredients (name, unit) VALUES + ('Chicken Breast', 'grams'), + ('Rice', 'grams'), + ('Broccoli', 'grams'), + ('Olive Oil', 'ml'), + ('Garlic', 'cloves'), + ('Onion', 'pieces'), + ('Tomatoes', 'pieces'), + ('Pasta', 'grams'), + ('Ground Beef', 'grams'), + ('Cheese', 'grams'), + ('Eggs', 'pieces'), + ('Milk', 'ml'), + ('Butter', 'grams'), + ('Lettuce', 'grams'), + ('Cucumber', 'pieces'), + ('Bell Pepper', 'pieces'), + ('Soy Sauce', 'ml'), + ('Salt', 'grams'), + ('Black Pepper', 'grams'), + ('Carrots', 'pieces'); + +-- Meals +INSERT INTO meals (name, description) VALUES + ('Grilled Chicken with Rice', 'Healthy protein-packed meal with vegetables'), + ('Beef Pasta', 'Classic Italian-style pasta with ground beef'), + ('Chicken Stir Fry', 'Asian-inspired quick meal'), + ('Garden Salad', 'Fresh vegetable salad'), + ('Scrambled Eggs Breakfast', 'Quick and easy breakfast'); + +-- Meal Ingredients for Grilled Chicken with Rice (meal_id: 1) +INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES + (1, 1, 200), -- Chicken Breast + (1, 2, 150), -- Rice + (1, 3, 100), -- Broccoli + (1, 4, 10), -- Olive Oil + (1, 5, 2), -- Garlic + (1, 18, 5), -- Salt + (1, 19, 2); -- Black Pepper + +-- Meal Ingredients for Beef Pasta (meal_id: 2) +INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES + (2, 8, 200), -- Pasta + (2, 9, 150), -- Ground Beef + (2, 7, 2), -- Tomatoes + (2, 6, 1), -- Onion + (2, 5, 3), -- Garlic + (2, 4, 15), -- Olive Oil + (2, 18, 5), -- Salt + (2, 19, 2); -- Black Pepper + +-- Meal Ingredients for Chicken Stir Fry (meal_id: 3) +INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES + (3, 1, 180), -- Chicken Breast + (3, 16, 1), -- Bell Pepper + (3, 20, 1), -- Carrots + (3, 3, 80), -- Broccoli + (3, 5, 2), -- Garlic + (3, 17, 30), -- Soy Sauce + (3, 4, 10); -- Olive Oil + +-- Meal Ingredients for Garden Salad (meal_id: 4) +INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES + (4, 14, 100), -- Lettuce + (4, 15, 1), -- Cucumber + (4, 7, 2), -- Tomatoes + (4, 20, 1), -- Carrots + (4, 4, 20), -- Olive Oil + (4, 18, 3); -- Salt + +-- Meal Ingredients for Scrambled Eggs Breakfast (meal_id: 5) +INSERT INTO meal_ingredients (meal_id, ingredient_id, quantity) VALUES + (5, 11, 3), -- Eggs + (5, 12, 50), -- Milk + (5, 13, 10), -- Butter + (5, 18, 2), -- Salt + (5, 19, 1); -- Black Pepper + +-- Week Plan (next 7 days) +-- Using relative dates (you may need to adjust these based on current date) +INSERT INTO week_plan (date, meal_id) VALUES + (date('now'), 1), -- Today: Grilled Chicken with Rice + (date('now', '+1 day'), 2), -- Tomorrow: Beef Pasta + (date('now', '+1 day'), 4), -- Tomorrow: Garden Salad + (date('now', '+2 days'), 3), -- Day 3: Chicken Stir Fry + (date('now', '+3 days'), 1), -- Day 4: Grilled Chicken with Rice + (date('now', '+4 days'), 5), -- Day 5: Scrambled Eggs Breakfast + (date('now', '+4 days'), 4), -- Day 5: Garden Salad + (date('now', '+5 days'), 2), -- Day 6: Beef Pasta + (date('now', '+6 days'), 3); -- Day 7: Chicken Stir Fry diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..ad6cc47 --- /dev/null +++ b/start.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Meal Prep Planner - Quick Start Script + +set -e + +echo "🍽️ Meal Prep Planner - Setup & Start" +echo "======================================" +echo "" + +# Check if Go is installed +if ! command -v go &> /dev/null +then + echo "❌ Go is not installed!" + echo "" + echo "Please install Go first:" + echo " macOS: brew install go" + echo " Linux: https://go.dev/doc/install" + echo " Windows: https://go.dev/dl/" + exit 1 +fi + +echo "✅ Go is installed: $(go version)" +echo "" + +# Install dependencies +echo "📦 Installing dependencies..." +go mod tidy +go mod download +echo "✅ Dependencies installed" +echo "" + +# Build the application +echo "🔨 Building application..." +go build -o mealprep main.go +echo "✅ Build complete" +echo "" + +# Start the server +echo "🚀 Starting server..." +echo "" +echo " Access the application at: http://localhost:8080" +echo "" +echo " Press Ctrl+C to stop the server" +echo "" +echo "======================================" +echo "" + +./mealprep diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..16a4c1d --- /dev/null +++ b/static/styles.css @@ -0,0 +1,446 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + Cantarell, sans-serif; + background-color: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + background-color: #2c3e50; + color: white; + padding: 20px 0; + margin-bottom: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +header h1 { + text-align: center; + font-size: 2em; +} + +.tabs { + display: flex; + gap: 10px; + margin-bottom: 30px; + border-bottom: 2px solid #ddd; + flex-wrap: wrap; +} + +.tab { + padding: 12px 24px; + background-color: transparent; + border: none; + cursor: pointer; + font-size: 16px; + color: #666; + border-bottom: 3px solid transparent; + transition: all 0.3s ease; +} + +.tab:hover { + color: #2c3e50; + background-color: #f0f0f0; +} + +.tab.active { + color: #2c3e50; + border-bottom-color: #3498db; + font-weight: 600; +} + +.content { + background-color: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +h2 { + color: #2c3e50; + margin-bottom: 20px; + font-size: 1.8em; +} + +h3 { + color: #34495e; + margin-bottom: 10px; + font-size: 1.2em; +} + +h4 { + color: #34495e; + margin-bottom: 10px; + margin-top: 15px; +} + +/* Forms */ +.add-form, +.add-ingredient-form, +.add-meal-to-day { + display: flex; + gap: 10px; + margin-bottom: 20px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 6px; + flex-wrap: wrap; +} + +input[type="text"], +input[type="number"], +input[type="date"], +select { + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + flex: 1; + min-width: 150px; +} + +input[type="text"]:focus, +input[type="number"]:focus, +input[type="date"]:focus, +select:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); +} + +button { + padding: 10px 20px; + background-color: #3498db; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s ease; +} + +button:hover { + background-color: #2980b9; +} + +.delete-btn { + background-color: #e74c3c; +} + +.delete-btn:hover { + background-color: #c0392b; +} + +.view-btn { + background-color: #95a5a6; +} + +.view-btn:hover { + background-color: #7f8c8d; +} + +.delete-btn.small { + padding: 5px 10px; + font-size: 12px; +} + +/* Lists */ +.items-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + background-color: #f8f9fa; + border-radius: 6px; + border-left: 4px solid #3498db; +} + +.item-name { + font-weight: 600; + color: #2c3e50; + font-size: 16px; +} + +.item-unit { + color: #7f8c8d; + margin-left: 8px; +} + +.item-description { + color: #7f8c8d; + font-size: 14px; + margin-left: 10px; +} + +/* Meals */ +.meal-item { + margin-bottom: 20px; + border: 1px solid #e0e0e0; + border-radius: 8px; + overflow: hidden; +} + +.meal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + background-color: #f8f9fa; + gap: 10px; + flex-wrap: wrap; +} + +.meal-header > div { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.meal-ingredients { + padding: 0; +} + +.ingredients-section { + padding: 15px; + background-color: white; +} + +.sub-items-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; +} + +.sub-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background-color: #e8f4f8; + border-radius: 4px; + font-size: 14px; +} + +/* Week Plan */ +.week-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; +} + +.day-card { + background-color: #f8f9fa; + border-radius: 8px; + padding: 15px; + border: 2px solid #e0e0e0; +} + +.day-card h3 { + color: #2c3e50; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 2px solid #3498db; +} + +.day-meals { + min-height: 100px; + margin-bottom: 10px; +} + +.planned-meal { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + background-color: white; + border-radius: 4px; + margin-bottom: 6px; + border-left: 3px solid #2ecc71; + font-size: 14px; +} + +.meal-type-section { + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #e0e0e0; +} + +.meal-type-section:last-child { + border-bottom: none; +} + +.meal-type-header { + font-size: 13px; + font-weight: 600; + color: #7f8c8d; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.meal-type-tag { + display: inline-block; + padding: 3px 8px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + margin-left: 8px; + letter-spacing: 0.5px; +} + +.tag-breakfast { + background-color: #f39c12; + color: white; +} + +.tag-lunch { + background-color: #3498db; + color: white; +} + +.tag-snack { + background-color: #9b59b6; + color: white; +} + +.add-meal-to-day { + padding: 8px; + margin: 0; + background-color: #f8f9fa; +} + +.add-meal-to-day select { + flex: 1; + min-width: 100px; + font-size: 13px; +} + +.add-meal-to-day button { + padding: 8px 12px; + flex: 0; +} + +.day-meals { + min-height: 40px; + margin-bottom: 8px; +} + +/* Grocery List */ +.grocery-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 15px; +} + +.grocery-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + background-color: #f8f9fa; + border-radius: 6px; + border-left: 4px solid #2ecc71; +} + +.grocery-name { + font-weight: 600; + color: #2c3e50; +} + +.grocery-quantity { + color: #7f8c8d; + font-size: 14px; +} + +.info { + color: #7f8c8d; + font-style: italic; + margin-bottom: 20px; +} + +.empty-state { + text-align: center; + color: #95a5a6; + padding: 40px; + font-style: italic; +} + +/* Responsive */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .content { + padding: 15px; + } + + .add-form, + .add-ingredient-form { + flex-direction: column; + } + + .meal-header { + flex-direction: column; + align-items: flex-start; + } + + .week-container { + grid-template-columns: 1fr; + } + + .tabs { + overflow-x: auto; + } + + .tab { + white-space: nowrap; + } +} + +/* Loading states */ +.htmx-request { + opacity: 0.7; + pointer-events: none; +} + +/* Animation */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.item, +.meal-item, +.planned-meal, +.grocery-item { + animation: fadeIn 0.3s ease; +}