base feature

This commit is contained in:
2025-10-25 15:40:28 +02:00
commit 72c50549d7
15 changed files with 2104 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@@ -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/

67
IMPLEMENTATION_NOTES.txt Normal file
View File

@@ -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!

46
Makefile Normal file
View File

@@ -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"

276
database/db.go Normal file
View File

@@ -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
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module mealprep
go 1.21
require github.com/mattn/go-sqlite3 v1.14.18

2
go.sum Normal file
View File

@@ -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=

45
handlers/grocerylist.go Normal file
View File

@@ -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 := `
<div id="grocery-content">
<h2>Grocery List</h2>
<p class="info">This list is automatically generated from your week plan.</p>
{{if .Items}}
<div class="grocery-list">
{{range .Items}}
<div class="grocery-item">
<span class="grocery-name">{{.IngredientName}}</span>
<span class="grocery-quantity">{{printf "%.2f" .TotalQuantity}} {{.Unit}}</span>
</div>
{{end}}
</div>
{{else}}
<p class="empty-state">No items in your grocery list. Add meals to your week plan to generate a grocery list.</p>
{{end}}
</div>
`
data := struct {
Items interface{}
}{
Items: groceryItems,
}
t := template.Must(template.New("grocerylist").Parse(tmpl))
t.Execute(w, data)
}

113
handlers/ingredients.go Normal file
View File

@@ -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 := `
<div id="ingredients-content">
<h2>Ingredients</h2>
<form hx-post="/ingredients" hx-target="#ingredients-list" hx-swap="beforeend" class="add-form">
<input type="text" name="name" placeholder="Ingredient name" required />
<input type="text" name="unit" placeholder="Unit (e.g., grams, cups)" required />
<button type="submit">Add Ingredient</button>
</form>
<div id="ingredients-list" class="items-list">
{{range .}}
<div class="item" id="ingredient-{{.ID}}">
<span class="item-name">{{.Name}}</span>
<span class="item-unit">({{.Unit}})</span>
<button
hx-delete="/ingredients/{{.ID}}"
hx-target="#ingredient-{{.ID}}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this ingredient?"
class="delete-btn">
Delete
</button>
</div>
{{end}}
</div>
</div>
`
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 := `
<div class="item" id="ingredient-{{.ID}}">
<span class="item-name">{{.Name}}</span>
<span class="item-unit">({{.Unit}})</span>
<button
hx-delete="/ingredients/{{.ID}}"
hx-target="#ingredient-{{.ID}}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this ingredient?"
class="delete-btn">
Delete
</button>
</div>
`
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)
}

335
handlers/meals.go Normal file
View File

@@ -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 := `
<div id="meals-content">
<h2>Meals</h2>
<form hx-post="/meals" hx-target="#meals-list" hx-swap="beforeend" class="add-form">
<input type="text" name="name" placeholder="Meal name" required />
<input type="text" name="description" placeholder="Description" />
<select name="meal_type" required>
<option value="">Select type...</option>
<option value="breakfast">Breakfast</option>
<option value="lunch">Lunch</option>
<option value="snack">Snack</option>
</select>
<button type="submit">Add Meal</button>
</form>
<div id="meals-list" class="items-list">
{{range .}}
<div class="meal-item" id="meal-{{.ID}}">
<div class="meal-header">
<div>
<span class="item-name">{{.Name}}</span>
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
<span class="item-description">{{.Description}}</span>
</div>
<div>
<button
hx-get="/meals/{{.ID}}/ingredients"
hx-target="#meal-{{.ID}}-ingredients"
hx-swap="innerHTML"
class="view-btn">
View Ingredients
</button>
<button
hx-delete="/meals/{{.ID}}"
hx-target="#meal-{{.ID}}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this meal?"
class="delete-btn">
Delete
</button>
</div>
</div>
<div id="meal-{{.ID}}-ingredients" class="meal-ingredients"></div>
</div>
{{end}}
</div>
</div>
`
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 := `
<div class="meal-item" id="meal-{{.ID}}">
<div class="meal-header">
<div>
<span class="item-name">{{.Name}}</span>
<span class="meal-type-tag tag-{{.MealType}}">{{.MealType}}</span>
<span class="item-description">{{.Description}}</span>
</div>
<div>
<button
hx-get="/meals/{{.ID}}/ingredients"
hx-target="#meal-{{.ID}}-ingredients"
hx-swap="innerHTML"
class="view-btn">
View Ingredients
</button>
<button
hx-delete="/meals/{{.ID}}"
hx-target="#meal-{{.ID}}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this meal?"
class="delete-btn">
Delete
</button>
</div>
</div>
<div id="meal-{{.ID}}-ingredients" class="meal-ingredients"></div>
</div>
`
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 := `
<div class="ingredients-section">
<h4>Ingredients:</h4>
<form hx-post="/meals/{{.MealID}}/ingredients"
hx-target="#meal-{{.MealID}}-ingredients-list"
hx-swap="beforeend"
class="add-ingredient-form">
<select name="ingredient_id" required>
<option value="">Select ingredient</option>
{{range .AllIngredients}}
<option value="{{.ID}}">{{.Name}} ({{.Unit}})</option>
{{end}}
</select>
<input type="number" name="quantity" placeholder="Quantity" step="0.01" min="0" required />
<button type="submit">Add</button>
</form>
<div id="meal-{{.MealID}}-ingredients-list" class="sub-items-list">
{{range .MealIngredients}}
<div class="sub-item" id="meal-{{.MealID}}-ingredient-{{.IngredientID}}">
<span>{{.IngredientName}}: {{.Quantity}} {{.Unit}}</span>
<button
hx-delete="/meals/{{.MealID}}/ingredients/{{.IngredientID}}"
hx-target="#meal-{{.MealID}}-ingredient-{{.IngredientID}}"
hx-swap="outerHTML"
class="delete-btn small">
Remove
</button>
</div>
{{end}}
</div>
</div>
`
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 := `
<div class="sub-item" id="meal-{{.MealID}}-ingredient-{{.IngredientID}}">
<span>{{.IngredientName}}: {{.Quantity}} {{.Unit}}</span>
<button
hx-delete="/meals/{{.MealID}}/ingredients/{{.IngredientID}}"
hx-target="#meal-{{.MealID}}-ingredient-{{.IngredientID}}"
hx-swap="outerHTML"
class="delete-btn small">
Remove
</button>
</div>
`
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)
}

321
handlers/weekplan.go Normal file
View File

@@ -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 := `
<div id="weekplan-content">
<h2>Week Plan</h2>
<p class="info">Planning week: {{.WeekStart}} - {{.WeekEnd}}</p>
<div class="week-container">
{{range $index, $date := .Dates}}
<div class="day-card">
<h3>{{formatDate $date}}</h3>
<!-- Breakfast Section -->
<div class="meal-type-section">
<h4 class="meal-type-header">🌅 Breakfast</h4>
<div class="day-meals" id="day-{{$date}}-breakfast">
{{$entries := index $.PlanByDate $date}}
{{range $entries}}
{{if eq .MealType "breakfast"}}
<div class="planned-meal" id="plan-{{.ID}}">
<span>{{.MealName}}</span>
<button
hx-delete="/week-plan/{{.ID}}"
hx-target="#plan-{{.ID}}"
hx-swap="outerHTML"
class="delete-btn small">
×
</button>
</div>
{{end}}
{{end}}
</div>
<form hx-post="/week-plan"
hx-target="#day-{{$date}}-breakfast"
hx-swap="beforeend"
class="add-meal-to-day">
<input type="hidden" name="date" value="{{$date}}" />
<input type="hidden" name="meal_type" value="breakfast" />
<select name="meal_id" required>
<option value="">Add breakfast...</option>
{{range $.BreakfastMeals}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
<button type="submit">+</button>
</form>
</div>
<!-- Lunch Section -->
<div class="meal-type-section">
<h4 class="meal-type-header">🍽️ Lunch</h4>
<div class="day-meals" id="day-{{$date}}-lunch">
{{$entries := index $.PlanByDate $date}}
{{range $entries}}
{{if eq .MealType "lunch"}}
<div class="planned-meal" id="plan-{{.ID}}">
<span>{{.MealName}}</span>
<button
hx-delete="/week-plan/{{.ID}}"
hx-target="#plan-{{.ID}}"
hx-swap="outerHTML"
class="delete-btn small">
×
</button>
</div>
{{end}}
{{end}}
</div>
<form hx-post="/week-plan"
hx-target="#day-{{$date}}-lunch"
hx-swap="beforeend"
class="add-meal-to-day">
<input type="hidden" name="date" value="{{$date}}" />
<input type="hidden" name="meal_type" value="lunch" />
<select name="meal_id" required>
<option value="">Add lunch...</option>
{{range $.LunchMeals}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
<button type="submit">+</button>
</form>
</div>
<!-- Snack Section -->
<div class="meal-type-section">
<h4 class="meal-type-header">🍪 Snack</h4>
<div class="day-meals" id="day-{{$date}}-snack">
{{$entries := index $.PlanByDate $date}}
{{range $entries}}
{{if eq .MealType "snack"}}
<div class="planned-meal" id="plan-{{.ID}}">
<span>{{.MealName}}</span>
<button
hx-delete="/week-plan/{{.ID}}"
hx-target="#plan-{{.ID}}"
hx-swap="outerHTML"
class="delete-btn small">
×
</button>
</div>
{{end}}
{{end}}
</div>
<form hx-post="/week-plan"
hx-target="#day-{{$date}}-snack"
hx-swap="beforeend"
class="add-meal-to-day">
<input type="hidden" name="date" value="{{$date}}" />
<input type="hidden" name="meal_type" value="snack" />
<select name="meal_id" required>
<option value="">Add snack...</option>
{{range $.SnackMeals}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
<button type="submit">+</button>
</form>
</div>
</div>
{{end}}
</div>
</div>
`
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 := `
<div class="planned-meal" id="plan-{{.ID}}">
<span>{{.MealName}}</span>
<button
hx-delete="/week-plan/{{.ID}}"
hx-target="#plan-{{.ID}}"
hx-swap="outerHTML"
class="delete-btn small">
×
</button>
</div>
`
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)
}

218
main.go Normal file
View File

@@ -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 := `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meal Prep Planner</title>
<link rel="stylesheet" href="/static/styles.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<header>
<div class="container">
<h1>🍽️ Meal Prep Planner</h1>
</div>
</header>
<div class="container">
<div class="tabs">
<button class="tab active"
hx-get="/ingredients"
hx-target="#content"
hx-swap="innerHTML"
onclick="setActiveTab(this)">
Ingredients
</button>
<button class="tab"
hx-get="/meals"
hx-target="#content"
hx-swap="innerHTML"
onclick="setActiveTab(this)">
Meals
</button>
<button class="tab"
hx-get="/week-plan"
hx-target="#content"
hx-swap="innerHTML"
onclick="setActiveTab(this)">
Week Plan
</button>
<button class="tab"
hx-get="/grocery-list"
hx-target="#content"
hx-swap="innerHTML"
onclick="setActiveTab(this)">
Grocery List
</button>
</div>
<div id="content" class="content">
<div hx-get="/ingredients" hx-trigger="load"></div>
</div>
</div>
<script>
function setActiveTab(clickedTab) {
// Remove active class from all tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
// Add active class to clicked tab
clickedTab.classList.add('active');
}
</script>
</body>
</html>
`
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)
}
}

44
models/models.go Normal file
View File

@@ -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"`
}

94
sample_data.sql Normal file
View File

@@ -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

49
start.sh Executable file
View File

@@ -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

446
static/styles.css Normal file
View File

@@ -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;
}