summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore8
-rw-r--r--app/app.py883
-rw-r--r--app/db.py140
-rw-r--r--app/seed_data.py1007
-rw-r--r--app/static/css/style.css369
-rw-r--r--app/static/js/main.js18
-rw-r--r--app/templates/ai_chat.html241
-rw-r--r--app/templates/base.html99
-rw-r--r--app/templates/index.html116
-rw-r--r--app/templates/login.html39
-rw-r--r--app/templates/meal_plan.html265
-rw-r--r--app/templates/recipe_add.html216
-rw-r--r--app/templates/recipe_detail.html290
-rw-r--r--app/templates/recipes.html121
-rw-r--r--app/templates/shopping_list.html167
-rwxr-xr-xrefresh-token.sh26
-rw-r--r--requirements.txt3
17 files changed, 4008 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c8562d9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+__pycache__/
+*.pyc
+*.pyo
+*.db
+*.sqlite3
+venv/
+.env
+*.env
diff --git a/app/app.py b/app/app.py
new file mode 100644
index 0000000..bdeb215
--- /dev/null
+++ b/app/app.py
@@ -0,0 +1,883 @@
+import json
+import os
+from datetime import date, timedelta
+from flask import (Flask, render_template, request, jsonify,
+ redirect, url_for, flash)
+from flask_login import (LoginManager, UserMixin, login_user,
+ logout_user, login_required, current_user)
+from werkzeug.security import check_password_hash
+import db as database
+
+# ── AI / Claude constants ─────────────────────────────────────────────────────
+
+AI_SYSTEM_PROMPT = """You are a recipe assistant for a personal low-carb meal planning app.
+Your job: help users add recipes to the database by parsing whatever they paste — blog posts,
+cookbook excerpts, photos descriptions, ingredient lists, URL text, anything.
+
+When you receive a recipe or a request to add one:
+
+1. PARSE it carefully from whatever format was given.
+
+2. DETERMINE the cuisine/nationality of the dish — use the real nationality (e.g. "Mexican",
+ "Japanese", "Ethiopian", "American"). Use broad groupings only when genuinely ambiguous
+ (e.g. "Asian" for pan-Asian fusion). Call list_recipes (no filter) to check existing cuisines
+ in the library so you can match/extend consistently.
+
+3. ADAPT for low-carb if needed (the app emphasises low-carb cooking):
+ • Swap pasta → zucchini noodles or cauliflower rice, or just omit
+ • Swap rice → cauliflower rice, or just omit
+ • Swap bread → lettuce wraps or omit
+ • Keep sauces, braises, grilled/roasted proteins — they're usually fine
+ • Note substitutions briefly in your reply
+
+4. SCALE to 2 servings (the default) unless the user specifies otherwise.
+
+5. ESTIMATE nutritional info per serving if not provided:
+ calories, net carbs (g), protein (g), fat (g). Be reasonable.
+
+6. CATEGORISE each ingredient into exactly one of:
+ Meat & Poultry | Seafood | Dairy & Eggs | Produce | Pantry | Spices & Herbs
+
+7. FORMAT instructions as numbered steps: "1. Do this.\\n2. Do that."
+
+8. CHECK for duplicates — call list_recipes first if unsure.
+
+If the user provides URLs, call fetch_url for each one BEFORE trying to parse anything.
+Process one URL at a time: fetch → parse → add_recipe, then move to the next.
+
+9. CALL add_recipe to save. Do not ask for permission — just do it.
+
+10. After saving, tell the user what you added and mention they can view it on the Recipes page.
+
+For non-recipe questions, just answer helpfully. Be concise and direct."""
+
+AI_TOOLS = [
+ {
+ "name": "add_recipe",
+ "description": "Save a recipe to the meal planner database.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "cuisine": {
+ "type": "string",
+ "description": "Nationality or cuisine style of the dish (e.g. Italian, Mexican, Japanese). Use the actual nationality — broad groupings like 'Asian' only for genuine fusion."
+ },
+ "description": {"type": "string", "description": "One compelling sentence about the dish."},
+ "servings": {"type": "integer", "default": 2},
+ "calories_per_serving": {"type": "number"},
+ "carbs_per_serving": {"type": "number"},
+ "protein_per_serving": {"type": "number"},
+ "fat_per_serving": {"type": "number"},
+ "prep_time": {"type": "integer", "description": "Minutes"},
+ "cook_time": {"type": "integer", "description": "Minutes"},
+ "instructions": {
+ "type": "string",
+ "description": "Numbered steps, each on its own line: '1. Step one.\\n2. Step two.'"
+ },
+ "ingredients": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "quantity": {"type": "number"},
+ "unit": {
+ "type": "string",
+ "description": "whole|oz|lb|cup|cups|tbsp|tsp|cloves|inch|sprig|sprigs|leaves|slices|bunch|head|can|piece|pieces|strips"
+ },
+ "category": {
+ "type": "string",
+ "enum": ["Meat & Poultry", "Seafood", "Dairy & Eggs",
+ "Produce", "Pantry", "Spices & Herbs"]
+ }
+ },
+ "required": ["name", "quantity", "unit", "category"]
+ }
+ }
+ },
+ "required": ["name", "cuisine", "calories_per_serving", "carbs_per_serving",
+ "instructions", "ingredients"]
+ }
+ },
+ {
+ "name": "list_recipes",
+ "description": "List recipes already in the database, optionally filtered by cuisine.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "cuisine": {
+ "type": "string",
+ "description": "Filter by cuisine/nationality (optional)"
+ }
+ }
+ }
+ },
+ {
+ "name": "fetch_url",
+ "description": "Fetch the text content of a URL. Call this first whenever the user provides a URL, then parse the returned content as a recipe.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "url": {"type": "string", "description": "The URL to fetch"}
+ },
+ "required": ["url"]
+ }
+ }
+]
+
+app = Flask(__name__)
+app.secret_key = os.environ.get('SECRET_KEY', 'menu-planner-dev-key-please-change')
+
+# ── Auth setup ────────────────────────────────────────────────────────────────
+
+login_manager = LoginManager(app)
+login_manager.login_view = 'login'
+login_manager.login_message = 'Please log in to make changes.'
+login_manager.login_message_category = 'info'
+
+
+class User(UserMixin):
+ def __init__(self, id, username):
+ self.id = id
+ self.username = username
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ db = database.get_db()
+ row = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
+ db.close()
+ return User(row['id'], row['username']) if row else None
+
+
+@login_manager.unauthorized_handler
+def unauthorized():
+ if request.is_json:
+ return jsonify({'error': 'Login required', 'redirect': url_for('login')}), 401
+ return redirect(url_for('login', next=request.full_path))
+
+
+# ── Constants ─────────────────────────────────────────────────────────────────
+
+MEAL_TYPES = ["breakfast", "lunch", "dinner"]
+CUISINE_EMOJI = {
+ "Italian": "🇮🇹", "Greek": "🇬🇷", "Indian": "🇮🇳",
+ "Asian": "🥢", "French": "🇫🇷",
+ "Mexican": "🇲🇽", "American": "🇺🇸", "Japanese": "🇯🇵",
+ "Chinese": "🇨🇳", "Thai": "🇹🇭", "Korean": "🇰🇷",
+ "Spanish": "🇪🇸", "German": "🇩🇪", "British": "🇬🇧",
+ "Vietnamese": "🇻🇳", "Lebanese": "🇱🇧", "Moroccan": "🇲🇦",
+ "Ethiopian": "🇪🇹", "Brazilian": "🇧🇷", "Peruvian": "🇵🇪",
+}
+
+class _EmojiMap:
+ """Dict-like wrapper so templates can use both emoji_map[k] and emoji_map.get(k,'')."""
+ def get(self, key, default=""):
+ return CUISINE_EMOJI.get(key, "🍽️")
+ def __getitem__(self, key):
+ return CUISINE_EMOJI.get(key, "🍽️")
+
+CUISINE_EMOJI_MAP = _EmojiMap()
+
+def get_cuisines(db):
+ rows = db.execute("SELECT DISTINCT cuisine FROM recipes ORDER BY cuisine").fetchall()
+ return [r['cuisine'] for r in rows]
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def week_start_from_str(s):
+ if s:
+ return date.fromisoformat(s)
+ today = date.today()
+ return today - timedelta(days=today.weekday())
+
+
+def week_dates(start):
+ return [start + timedelta(days=i) for i in range(7)]
+
+
+# ── Login / Logout ────────────────────────────────────────────────────────────
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+ if current_user.is_authenticated:
+ return redirect(url_for('index'))
+ if request.method == 'POST':
+ username = request.form.get('username', '').strip()
+ password = request.form.get('password', '')
+ db = database.get_db()
+ row = db.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
+ db.close()
+ if row and check_password_hash(row['password_hash'], password):
+ login_user(User(row['id'], row['username']), remember=True)
+ nxt = request.args.get('next') or url_for('index')
+ return redirect(nxt)
+ flash('Incorrect username or password.', 'danger')
+ return render_template('login.html')
+
+
+@app.route('/logout')
+@login_required
+def logout():
+ logout_user()
+ return redirect(url_for('index'))
+
+
+# ── Dashboard ─────────────────────────────────────────────────────────────────
+
+@app.route('/')
+def index():
+ ws = week_start_from_str(None)
+ dates = week_dates(ws)
+ db = database.get_db()
+ rows = db.execute(
+ """SELECT mp.*, r.name AS recipe_name, r.cuisine
+ FROM meal_plan mp JOIN recipes r ON mp.recipe_id = r.id
+ WHERE mp.plan_date BETWEEN ? AND ?""",
+ (ws.isoformat(), dates[-1].isoformat()),
+ ).fetchall()
+ plan = {f"{r['plan_date']}_{r['meal_type']}": dict(r) for r in rows}
+ stats = db.execute("SELECT status, COUNT(*) AS cnt FROM recipes GROUP BY status").fetchall()
+ db.close()
+ stat_map = {r['status']: r['cnt'] for r in stats}
+ return render_template(
+ 'index.html',
+ week_start=ws, dates=dates, plan=plan,
+ stat_map=stat_map, meal_types=MEAL_TYPES,
+ cuisine_emoji=CUISINE_EMOJI_MAP,
+ today_str=date.today().isoformat(),
+ )
+
+
+# ── Recipes ───────────────────────────────────────────────────────────────────
+
+SORT_OPTIONS = {
+ 'name': 'name',
+ 'prep': 'prep_time, name',
+ 'cook': 'cook_time, name',
+ 'total': 'prep_time + cook_time, name',
+ 'rating': 'rating DESC NULLS LAST, name',
+}
+
+@app.route('/recipes')
+def recipes():
+ cuisine = request.args.get('cuisine', 'all')
+ status = request.args.get('status', 'active')
+ search = request.args.get('search', '').strip()
+ sort = request.args.get('sort', 'name')
+ if sort not in SORT_OPTIONS:
+ sort = 'name'
+
+ db = database.get_db()
+ q, params = "SELECT * FROM recipes WHERE 1=1", []
+
+ if cuisine != 'all':
+ q += " AND cuisine = ?"; params.append(cuisine)
+ if status == 'active':
+ q += " AND status != 'ignored'"
+ elif status in ('favorited', 'ignored', 'candidate'):
+ q += " AND status = ?"; params.append(status)
+ if search:
+ q += " AND name LIKE ?"; params.append(f'%{search}%')
+
+ if sort == 'name':
+ q += " ORDER BY CASE status WHEN 'favorited' THEN 0 WHEN 'candidate' THEN 1 ELSE 2 END, name"
+ else:
+ q += f" ORDER BY {SORT_OPTIONS[sort]}"
+ recipe_list = db.execute(q, params).fetchall()
+ cuisines = get_cuisines(db)
+ db.close()
+
+ return render_template(
+ 'recipes.html',
+ recipes=recipe_list, cuisine=cuisine, status=status, search=search,
+ sort=sort, cuisines=cuisines, cuisine_emoji=CUISINE_EMOJI_MAP,
+ )
+
+
+@app.route('/recipes/add', methods=['GET', 'POST'])
+@login_required
+def add_recipe():
+ if request.method == 'POST':
+ name = request.form.get('name', '').strip()
+ cuisine = request.form.get('cuisine', 'Italian')
+ description = request.form.get('description', '').strip()
+ servings = int(request.form.get('servings', 2) or 2)
+ calories = float(request.form.get('calories_per_serving') or 0)
+ carbs = float(request.form.get('carbs_per_serving') or 0)
+ protein = float(request.form.get('protein_per_serving') or 0)
+ fat = float(request.form.get('fat_per_serving') or 0)
+ prep_time = int(request.form.get('prep_time') or 0)
+ cook_time = int(request.form.get('cook_time') or 0)
+ instructions = request.form.get('instructions', '').strip()
+
+ ing_names = request.form.getlist('ing_name')
+ ing_qtys = request.form.getlist('ing_qty')
+ ing_units = request.form.getlist('ing_unit')
+ ing_cats = request.form.getlist('ing_category')
+
+ if not name:
+ flash('Recipe name is required.', 'danger')
+ _db = database.get_db()
+ _cuisines = get_cuisines(_db); _db.close()
+ return render_template('recipe_add.html', cuisines=_cuisines,
+ units=database.INGREDIENT_UNITS,
+ categories=database.INGREDIENT_CATEGORIES,
+ form=request.form)
+
+ db = database.get_db()
+ cur = db.execute(
+ """INSERT INTO recipes
+ (name, cuisine, description, servings, calories_per_serving,
+ carbs_per_serving, protein_per_serving, fat_per_serving,
+ prep_time, cook_time, status, instructions, added_by)
+ VALUES (?,?,?,?,?,?,?,?,?,?,'candidate',?,?)""",
+ (name, cuisine, description, servings, calories, carbs,
+ protein, fat, prep_time, cook_time, instructions,
+ current_user.username),
+ )
+ recipe_id = cur.lastrowid
+
+ for i, ing_name in enumerate(ing_names):
+ if ing_name.strip():
+ try:
+ qty = float(ing_qtys[i]) if i < len(ing_qtys) else 0
+ except (ValueError, IndexError):
+ qty = 0
+ db.execute(
+ "INSERT INTO ingredients (recipe_id, name, quantity, unit, category) VALUES (?,?,?,?,?)",
+ (recipe_id, ing_name.strip(), qty,
+ ing_units[i] if i < len(ing_units) else 'whole',
+ ing_cats[i] if i < len(ing_cats) else 'Pantry'),
+ )
+ db.commit()
+ db.close()
+ flash(f'"{name}" added successfully!', 'success')
+ return redirect(url_for('recipe_detail', recipe_id=recipe_id))
+
+ db = database.get_db()
+ cuisines = get_cuisines(db); db.close()
+ return render_template(
+ 'recipe_add.html', cuisines=cuisines,
+ units=database.INGREDIENT_UNITS,
+ categories=database.INGREDIENT_CATEGORIES,
+ form={},
+ )
+
+
+@app.route('/recipes/<int:recipe_id>')
+def recipe_detail(recipe_id):
+ db = database.get_db()
+ recipe = db.execute("SELECT * FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
+ if not recipe:
+ db.close()
+ return "Recipe not found", 404
+ ingredients = db.execute(
+ "SELECT * FROM ingredients WHERE recipe_id = ? ORDER BY category, name",
+ (recipe_id,),
+ ).fetchall()
+ comments = db.execute(
+ "SELECT * FROM comments WHERE recipe_id = ? ORDER BY created_at",
+ (recipe_id,),
+ ).fetchall()
+ db.close()
+ return render_template(
+ 'recipe_detail.html', recipe=recipe, ingredients=ingredients,
+ comments=comments, cuisine_emoji=CUISINE_EMOJI_MAP,
+ )
+
+
+@app.route('/recipes/<int:recipe_id>/comments', methods=['POST'])
+@login_required
+def add_comment(recipe_id):
+ body = (request.form.get('body') or '').strip()
+ if not body:
+ flash('Comment cannot be empty.', 'danger')
+ return redirect(url_for('recipe_detail', recipe_id=recipe_id))
+ db = database.get_db()
+ db.execute(
+ "INSERT INTO comments (recipe_id, username, body) VALUES (?, ?, ?)",
+ (recipe_id, current_user.username, body),
+ )
+ db.commit()
+ db.close()
+ return redirect(url_for('recipe_detail', recipe_id=recipe_id) + '#comments')
+
+
+@app.route('/recipes/<int:recipe_id>/comments/<int:comment_id>/delete', methods=['POST'])
+@login_required
+def delete_comment(recipe_id, comment_id):
+ db = database.get_db()
+ row = db.execute("SELECT username FROM comments WHERE id = ?", (comment_id,)).fetchone()
+ if row and row['username'] == current_user.username:
+ db.execute("DELETE FROM comments WHERE id = ?", (comment_id,))
+ db.commit()
+ db.close()
+ return redirect(url_for('recipe_detail', recipe_id=recipe_id) + '#comments')
+
+
+@app.route('/recipes/<int:recipe_id>/status', methods=['POST'])
+@login_required
+def update_status(recipe_id):
+ new_status = request.json.get('status')
+ if new_status not in ('candidate', 'favorited', 'ignored'):
+ return jsonify({'error': 'Invalid status'}), 400
+ db = database.get_db()
+ db.execute("UPDATE recipes SET status = ? WHERE id = ?", (new_status, recipe_id))
+ db.commit()
+ db.close()
+ return jsonify({'success': True, 'status': new_status})
+
+
+@app.route('/recipes/<int:recipe_id>/rate', methods=['POST'])
+@login_required
+def rate_recipe(recipe_id):
+ rating = request.json.get('rating')
+ if rating is not None and rating not in range(1, 6):
+ return jsonify({'error': 'Rating must be 1–5'}), 400
+ db = database.get_db()
+ current = db.execute("SELECT rating FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
+ if not current:
+ db.close()
+ return jsonify({'error': 'Not found'}), 404
+ new_rating = None if (current['rating'] == rating) else rating
+ db.execute("UPDATE recipes SET rating = ? WHERE id = ?", (new_rating, recipe_id))
+ db.commit()
+ db.close()
+ return jsonify({'success': True, 'rating': new_rating})
+
+
+# ── Meal Plan ─────────────────────────────────────────────────────────────────
+
+@app.route('/meal-plan')
+def meal_plan():
+ ws = week_start_from_str(request.args.get('week'))
+ dates = week_dates(ws)
+ db = database.get_db()
+ rows = db.execute(
+ """SELECT mp.*, r.name AS recipe_name, r.cuisine,
+ r.calories_per_serving, r.carbs_per_serving
+ FROM meal_plan mp JOIN recipes r ON mp.recipe_id = r.id
+ WHERE mp.plan_date BETWEEN ? AND ?""",
+ (ws.isoformat(), dates[-1].isoformat()),
+ ).fetchall()
+ all_recipes = db.execute(
+ """SELECT id, name, cuisine, calories_per_serving
+ FROM recipes WHERE status != 'ignored'
+ ORDER BY CASE status WHEN 'favorited' THEN 0 ELSE 1 END, name"""
+ ).fetchall()
+ cuisines = get_cuisines(db)
+ db.close()
+
+ plan = {f"{r['plan_date']}_{r['meal_type']}": dict(r) for r in rows}
+
+ return render_template(
+ 'meal_plan.html',
+ week_start=ws, dates=dates,
+ prev_week=(ws - timedelta(weeks=1)).isoformat(),
+ next_week=(ws + timedelta(weeks=1)).isoformat(),
+ plan=plan, meal_types=MEAL_TYPES,
+ recipes_json=json.dumps([dict(r) for r in all_recipes]),
+ cuisines=cuisines, cuisine_emoji=CUISINE_EMOJI_MAP,
+ today_str=date.today().isoformat(),
+ )
+
+
+@app.route('/meal-plan/add', methods=['POST'])
+@login_required
+def add_meal():
+ data = request.json
+ plan_date = data['date']
+ meal_type = data['meal_type']
+ recipe_id = data['recipe_id']
+ servings = int(data.get('servings', 2))
+
+ db = database.get_db()
+ db.execute("DELETE FROM meal_plan WHERE plan_date = ? AND meal_type = ?",
+ (plan_date, meal_type))
+ db.execute("INSERT INTO meal_plan (plan_date, meal_type, recipe_id, servings) VALUES (?,?,?,?)",
+ (plan_date, meal_type, recipe_id, servings))
+ db.commit()
+ recipe = db.execute(
+ "SELECT name, cuisine, calories_per_serving, carbs_per_serving FROM recipes WHERE id = ?",
+ (recipe_id,),
+ ).fetchone()
+ meal_id = db.execute(
+ "SELECT id FROM meal_plan WHERE plan_date = ? AND meal_type = ?",
+ (plan_date, meal_type),
+ ).fetchone()['id']
+ db.close()
+ return jsonify({
+ 'success': True, 'meal_id': meal_id,
+ 'recipe_name': recipe['name'], 'cuisine': recipe['cuisine'],
+ 'calories': recipe['calories_per_serving'],
+ 'carbs': recipe['carbs_per_serving'],
+ 'servings': servings,
+ })
+
+
+@app.route('/meal-plan/remove', methods=['POST'])
+@login_required
+def remove_meal():
+ data = request.json
+ db = database.get_db()
+ db.execute("DELETE FROM meal_plan WHERE plan_date = ? AND meal_type = ?",
+ (data['date'], data['meal_type']))
+ db.commit()
+ db.close()
+ return jsonify({'success': True})
+
+
+# ── Shopping List ─────────────────────────────────────────────────────────────
+
+@app.route('/shopping-list')
+def shopping_list():
+ ws = week_start_from_str(request.args.get('week'))
+ db = database.get_db()
+ items = db.execute(
+ "SELECT * FROM shopping_list WHERE week_start = ? ORDER BY category, ingredient_name",
+ (ws.isoformat(),),
+ ).fetchall()
+ db.close()
+
+ order = database.CATEGORY_ORDER
+ cat_map = {c: [] for c in order}
+ for item in items:
+ cat = item['category']
+ cat_map.setdefault(cat, []).append(dict(item))
+ categories = [(c, cat_map[c]) for c in order if cat_map.get(c)]
+ for c, lst in cat_map.items():
+ if c not in order and lst:
+ categories.append((c, lst))
+
+ return render_template(
+ 'shopping_list.html',
+ week_start=ws,
+ prev_week=(ws - timedelta(weeks=1)).isoformat(),
+ next_week=(ws + timedelta(weeks=1)).isoformat(),
+ categories=categories,
+ total=len(items),
+ checked=sum(1 for i in items if i['checked']),
+ )
+
+
+@app.route('/shopping-list/generate', methods=['POST'])
+@login_required
+def generate_shopping_list():
+ ws = week_start_from_str(request.json.get('week'))
+ dates = week_dates(ws)
+
+ db = database.get_db()
+ meals = db.execute(
+ """SELECT mp.recipe_id, mp.servings AS meal_servings, r.servings AS recipe_servings
+ FROM meal_plan mp JOIN recipes r ON mp.recipe_id = r.id
+ WHERE mp.plan_date BETWEEN ? AND ?""",
+ (ws.isoformat(), dates[-1].isoformat()),
+ ).fetchall()
+
+ if not meals:
+ db.close()
+ return jsonify({'error': 'No meals planned for this week'}), 400
+
+ totals = {}
+ for meal in meals:
+ scale = meal['meal_servings'] / meal['recipe_servings']
+ ings = db.execute("SELECT * FROM ingredients WHERE recipe_id = ?",
+ (meal['recipe_id'],)).fetchall()
+ rec = db.execute("SELECT name FROM recipes WHERE id = ?",
+ (meal['recipe_id'],)).fetchone()
+ for ing in ings:
+ key = (ing['name'].lower(), ing['unit'])
+ qty = ing['quantity'] * scale
+ if key in totals:
+ totals[key]['quantity'] += qty
+ totals[key]['recipes'].add(rec['name'])
+ else:
+ totals[key] = {
+ 'quantity': qty, 'unit': ing['unit'],
+ 'category': ing['category'], 'name': ing['name'],
+ 'recipes': {rec['name']},
+ }
+
+ db.execute("DELETE FROM shopping_list WHERE week_start = ?", (ws.isoformat(),))
+ for item in totals.values():
+ db.execute(
+ """INSERT INTO shopping_list
+ (week_start, ingredient_name, quantity, unit, category, recipe_sources, checked)
+ VALUES (?,?,?,?,?,?,0)""",
+ (ws.isoformat(), item['name'], round(item['quantity'], 2),
+ item['unit'], item['category'], ', '.join(sorted(item['recipes']))),
+ )
+ db.commit()
+ db.close()
+ return jsonify({'success': True})
+
+
+@app.route('/shopping-list/<int:item_id>/check', methods=['POST'])
+@login_required
+def check_item(item_id):
+ checked = request.json.get('checked', False)
+ db = database.get_db()
+ db.execute("UPDATE shopping_list SET checked = ? WHERE id = ?",
+ (1 if checked else 0, item_id))
+ db.commit()
+ db.close()
+ return jsonify({'success': True})
+
+
+@app.route('/shopping-list/clear', methods=['POST'])
+@login_required
+def clear_shopping_list():
+ db = database.get_db()
+ db.execute("DELETE FROM shopping_list WHERE week_start = ?",
+ (request.json.get('week'),))
+ db.commit()
+ db.close()
+ return jsonify({'success': True})
+
+
+
+# ── AI Recipe Assistant ───────────────────────────────────────────────────────
+
+def _execute_ai_tool(name, tool_input):
+ """Run an AI tool call and return a JSON-serializable result."""
+ if name == "add_recipe":
+ if not tool_input.get("name") or not tool_input.get("cuisine"):
+ return {"error": "Missing required fields: name and cuisine are required"}
+ db = database.get_db()
+ existing = db.execute(
+ "SELECT id FROM recipes WHERE name = ?", (tool_input["name"],)
+ ).fetchone()
+ if existing:
+ db.close()
+ return {"error": f"'{tool_input['name']}' already exists",
+ "existing_id": existing["id"]}
+ cur = db.execute(
+ """INSERT INTO recipes
+ (name, cuisine, description, servings, calories_per_serving,
+ carbs_per_serving, protein_per_serving, fat_per_serving,
+ prep_time, cook_time, status, instructions, added_by)
+ VALUES (?,?,?,?,?,?,?,?,?,?,'candidate',?,?)""",
+ (
+ tool_input["name"], tool_input["cuisine"],
+ tool_input.get("description", ""),
+ tool_input.get("servings", 2),
+ tool_input.get("calories_per_serving", 0),
+ tool_input.get("carbs_per_serving", 0),
+ tool_input.get("protein_per_serving"),
+ tool_input.get("fat_per_serving"),
+ tool_input.get("prep_time", 0),
+ tool_input.get("cook_time", 0),
+ tool_input.get("instructions", ""),
+ "AI Assistant",
+ ),
+ )
+ recipe_id = cur.lastrowid
+ for ing in tool_input.get("ingredients", []):
+ db.execute(
+ "INSERT INTO ingredients (recipe_id, name, quantity, unit, category) VALUES (?,?,?,?,?)",
+ (recipe_id, ing["name"], ing["quantity"], ing["unit"], ing["category"]),
+ )
+ db.commit()
+ db.close()
+ return {"success": True, "id": recipe_id, "name": tool_input["name"]}
+
+ if name == "list_recipes":
+ db = database.get_db()
+ q = "SELECT id, name, cuisine, status FROM recipes"
+ params = []
+ if tool_input.get("cuisine"):
+ q += " WHERE cuisine = ?"
+ params.append(tool_input["cuisine"])
+ q += " ORDER BY name LIMIT 50"
+ rows = db.execute(q, params).fetchall()
+ db.close()
+ return {"recipes": [dict(r) for r in rows]}
+
+ if name == "fetch_url":
+ import urllib.request
+ import urllib.parse
+ import html.parser
+ import re
+
+ url = tool_input.get("url", "")
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme not in ("http", "https"):
+ return {"error": "Only http/https URLs are supported"}
+
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ content_type = resp.headers.get("Content-Type", "")
+ charset = "utf-8"
+ if "charset=" in content_type:
+ charset = content_type.split("charset=")[-1].split(";")[0].strip()
+ raw = resp.read(102400) # 100 KB cap
+ text = raw.decode(charset, errors="replace")
+
+ if "html" in content_type.lower():
+ class _Stripper(html.parser.HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.chunks = []
+ self._skip = False
+ def handle_starttag(self, tag, attrs):
+ if tag in ("script", "style"):
+ self._skip = True
+ def handle_endtag(self, tag):
+ if tag in ("script", "style"):
+ self._skip = False
+ def handle_data(self, d):
+ if not self._skip:
+ self.chunks.append(d)
+ s = _Stripper()
+ s.feed(text)
+ text = re.sub(r"\s+", " ", " ".join(s.chunks)).strip()
+
+ return {"url": url, "content": text[:12000]}
+ except Exception as e:
+ return {"error": str(e)}
+
+ return {"error": f"Unknown tool: {name}"}
+
+
+def _blocks_to_json(content):
+ """Convert Anthropic SDK content blocks to JSON-serialisable dicts."""
+ out = []
+ for b in content:
+ if b.type == "text":
+ out.append({"type": "text", "text": b.text})
+ elif b.type == "tool_use":
+ out.append({"type": "tool_use", "id": b.id,
+ "name": b.name, "input": b.input})
+ return out
+
+
+@app.route("/ai")
+@login_required
+def ai_chat_page():
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
+ return render_template("ai_chat.html", has_api_key=bool(api_key))
+
+
+@app.route("/ai/chat", methods=["POST"])
+@login_required
+def ai_chat():
+ import anthropic as _anthropic
+
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
+ if not api_key:
+ return jsonify({"error": "ANTHROPIC_API_KEY not set on the server."}), 503
+
+ data = request.json
+ user_message = (data.get("message") or "").strip()
+ history = data.get("history") or []
+ file_text = data.get("file_text")
+ file_name = data.get("file_name", "file")
+ image_b64 = data.get("image_b64")
+ image_mime = data.get("image_mime", "image/jpeg")
+ image_name = data.get("image_name", "image")
+
+ if not user_message and not file_text and not image_b64:
+ return jsonify({"error": "Empty message"}), 400
+
+ try:
+ client = _anthropic.Anthropic(api_key=api_key)
+
+ # Build user content: may be a string or a list of blocks (for images/files)
+ if image_b64:
+ user_content = [
+ {"type": "image", "source": {"type": "base64", "media_type": image_mime, "data": image_b64}},
+ {"type": "text", "text": (f"[Image: {image_name}]\n" + user_message) if user_message else f"[Image: {image_name}]"},
+ ]
+ elif file_text:
+ prefix = f"[Attached file: {file_name}]\n{file_text}\n\n"
+ user_content = prefix + (user_message or "Please parse this file.")
+ else:
+ user_content = user_message
+
+ messages = history + [{"role": "user", "content": user_content}]
+ recipes_added = []
+
+ resp = client.messages.create(
+ model="claude-sonnet-4-6",
+ max_tokens=4096,
+ system=AI_SYSTEM_PROMPT,
+ tools=AI_TOOLS,
+ messages=messages,
+ )
+
+ # Handle tool calls — may loop if Claude calls tools in the follow-up
+ for _ in range(5):
+ tool_results = []
+ for block in resp.content:
+ if block.type == "tool_use":
+ result = _execute_ai_tool(block.name, block.input)
+ if block.name == "add_recipe" and result.get("success"):
+ recipes_added.append({"id": result["id"], "name": result["name"]})
+ tool_results.append({
+ "type": "tool_result",
+ "tool_use_id": block.id,
+ "content": json.dumps(result),
+ })
+
+ if not tool_results:
+ break
+
+ messages.append({"role": "assistant", "content": _blocks_to_json(resp.content)})
+ messages.append({"role": "user", "content": tool_results})
+
+ resp = client.messages.create(
+ model="claude-sonnet-4-6",
+ max_tokens=4096,
+ system=AI_SYSTEM_PROMPT,
+ tools=AI_TOOLS,
+ messages=messages,
+ )
+
+ # Append final assistant turn to history
+ messages.append({"role": "assistant", "content": _blocks_to_json(resp.content)})
+
+ text = next((b.text for b in resp.content if b.type == "text"), "")
+
+ # Trim history to last 30 messages to avoid runaway token growth
+ if len(messages) > 30:
+ messages = messages[-30:]
+
+ return jsonify({
+ "response": text,
+ "history": messages,
+ "recipes_added": recipes_added,
+ })
+
+ except _anthropic.RateLimitError:
+ return jsonify({"error": "Rate limit reached — please wait a moment and try again."}), 429
+ except _anthropic.AuthenticationError:
+ return jsonify({"error": "API authentication failed. The token may have expired — run refresh-token.sh on the server."}), 502
+ except _anthropic.APIError as e:
+ return jsonify({"error": f"API error: {e.message}"}), 502
+ except Exception as e:
+ return jsonify({"error": f"Unexpected error: {str(e)}"}), 500
+
+
+if __name__ == '__main__':
+ database.init_db()
+ _db = database.get_db()
+ _cols = [r[1] for r in _db.execute("PRAGMA table_info(recipes)").fetchall()]
+ if 'rating' not in _cols:
+ _db.execute('ALTER TABLE recipes ADD COLUMN rating INTEGER')
+ _db.commit()
+ _db.executescript("""
+ CREATE TABLE IF NOT EXISTS comments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
+ username TEXT NOT NULL,
+ body TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+ _db.commit()
+ _db.close()
+ app.run(host='0.0.0.0', port=5000, debug=False)
diff --git a/app/db.py b/app/db.py
new file mode 100644
index 0000000..14b2287
--- /dev/null
+++ b/app/db.py
@@ -0,0 +1,140 @@
+import sqlite3
+import os
+from werkzeug.security import generate_password_hash
+from seed_data import RECIPES
+
+DATABASE = os.environ.get('DATABASE_PATH', '/data/menu.db')
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS recipes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ cuisine TEXT NOT NULL,
+ description TEXT,
+ servings INTEGER DEFAULT 2,
+ calories_per_serving REAL,
+ carbs_per_serving REAL,
+ protein_per_serving REAL,
+ fat_per_serving REAL,
+ prep_time INTEGER,
+ cook_time INTEGER,
+ status TEXT DEFAULT 'candidate' CHECK(status IN ('candidate','favorited','ignored')),
+ instructions TEXT,
+ added_by TEXT
+);
+
+CREATE TABLE IF NOT EXISTS ingredients (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ quantity REAL NOT NULL,
+ unit TEXT NOT NULL,
+ category TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS meal_plan (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plan_date DATE NOT NULL,
+ meal_type TEXT NOT NULL CHECK(meal_type IN ('breakfast','lunch','dinner')),
+ recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
+ servings INTEGER DEFAULT 2,
+ UNIQUE(plan_date, meal_type)
+);
+
+CREATE TABLE IF NOT EXISTS shopping_list (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ week_start DATE NOT NULL,
+ ingredient_name TEXT NOT NULL,
+ quantity REAL NOT NULL,
+ unit TEXT NOT NULL,
+ category TEXT NOT NULL,
+ recipe_sources TEXT,
+ checked INTEGER DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL
+);
+"""
+
+CATEGORY_ORDER = [
+ "Meat & Poultry", "Seafood", "Dairy & Eggs",
+ "Produce", "Pantry", "Spices & Herbs",
+]
+
+SEED_USERS = [
+ ("ken", "userliust"),
+ ("terri", "Brit8334Bell"),
+ ("brittainy","sparkles"),
+ ("bella", "dipperandmabelpines"),
+]
+
+INGREDIENT_UNITS = [
+ "whole", "oz", "lb", "cup", "cups", "tbsp", "tsp",
+ "cloves", "inch", "sprig", "sprigs", "leaves", "slices",
+ "bunch", "head", "can", "piece", "pieces", "strips",
+]
+
+INGREDIENT_CATEGORIES = [
+ "Meat & Poultry", "Seafood", "Dairy & Eggs",
+ "Produce", "Pantry", "Spices & Herbs",
+]
+
+
+def get_db():
+ conn = sqlite3.connect(DATABASE)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys = ON")
+ return conn
+
+
+def init_db():
+ os.makedirs(os.path.dirname(DATABASE), exist_ok=True)
+ conn = get_db()
+ conn.executescript(SCHEMA)
+ conn.commit()
+
+ if conn.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] == 0:
+ _seed_recipes(conn)
+
+ _seed_users(conn)
+ conn.close()
+
+
+def _seed_recipes(conn):
+ for recipe in RECIPES:
+ cursor = conn.execute(
+ """INSERT INTO recipes
+ (name, cuisine, description, servings, calories_per_serving,
+ carbs_per_serving, protein_per_serving, fat_per_serving,
+ prep_time, cook_time, status, instructions)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
+ (
+ recipe["name"], recipe["cuisine"], recipe["description"],
+ recipe["servings"], recipe["calories_per_serving"],
+ recipe["carbs_per_serving"], recipe.get("protein_per_serving"),
+ recipe.get("fat_per_serving"), recipe["prep_time"],
+ recipe["cook_time"], recipe.get("status", "candidate"),
+ recipe["instructions"],
+ ),
+ )
+ recipe_id = cursor.lastrowid
+ for ing in recipe["ingredients"]:
+ conn.execute(
+ "INSERT INTO ingredients (recipe_id, name, quantity, unit, category) VALUES (?,?,?,?,?)",
+ (recipe_id, ing["name"], ing["quantity"], ing["unit"], ing["category"]),
+ )
+ conn.commit()
+
+
+def _seed_users(conn):
+ for username, password in SEED_USERS:
+ existing = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
+ if not existing:
+ conn.execute(
+ "INSERT INTO users (username, password_hash) VALUES (?, ?)",
+ (username, generate_password_hash(password)),
+ )
+ conn.commit()
diff --git a/app/seed_data.py b/app/seed_data.py
new file mode 100644
index 0000000..dbf43b7
--- /dev/null
+++ b/app/seed_data.py
@@ -0,0 +1,1007 @@
+RECIPES = [
+ # ── ITALIAN ──────────────────────────────────────────────────────────────
+ {
+ "name": "Chicken Piccata",
+ "cuisine": "Italian",
+ "description": "Tender chicken in a bright lemon-caper butter sauce. Quick, elegant, and endlessly satisfying.",
+ "servings": 2,
+ "calories_per_serving": 380,
+ "carbs_per_serving": 6,
+ "protein_per_serving": 42,
+ "fat_per_serving": 18,
+ "prep_time": 10,
+ "cook_time": 20,
+ "instructions": """1. Pound chicken breasts to ½-inch thickness. Season with salt and pepper; dust lightly with flour.
+2. Heat olive oil and 1 tbsp butter in a large skillet over medium-high heat.
+3. Cook chicken 4–5 min per side until golden. Remove and rest on a plate.
+4. Add garlic to pan, cook 30 seconds. Add white wine and reduce by half, scraping up browned bits.
+5. Add chicken broth and lemon juice. Simmer 2 minutes.
+6. Add capers and remaining butter; swirl to emulsify into a glossy sauce.
+7. Return chicken, coat with sauce, garnish with parsley and lemon slices.""",
+ "ingredients": [
+ {"name": "Chicken breast", "quantity": 2, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Capers", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Lemon", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Butter", "quantity": 3, "unit": "tbsp", "category": "Dairy & Eggs"},
+ {"name": "Olive oil", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Dry white wine", "quantity": 0.25, "unit": "cup", "category": "Pantry"},
+ {"name": "Chicken broth", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Garlic cloves", "quantity": 2, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh parsley", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ {"name": "All-purpose flour", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Osso Buco",
+ "cuisine": "Italian",
+ "description": "Braised veal shanks with a rich, wine-scented sauce and bright gremolata. A Milanese masterpiece.",
+ "servings": 2,
+ "calories_per_serving": 520,
+ "carbs_per_serving": 14,
+ "protein_per_serving": 52,
+ "fat_per_serving": 22,
+ "prep_time": 20,
+ "cook_time": 120,
+ "instructions": """1. Pat veal shanks dry; season generously with salt and pepper. Dust lightly with flour.
+2. Heat oil in a Dutch oven over medium-high. Sear shanks 4 min per side until deep brown. Set aside.
+3. Reduce heat to medium. Sauté onion, carrot, and celery until softened, 8 min. Add garlic, cook 1 min.
+4. Add white wine; deglaze and reduce by half. Add broth and crushed tomatoes.
+5. Return shanks, nestling into the sauce. Bring to a simmer.
+6. Cover and braise at 325°F (or low stovetop) for 1.5–2 hours until meat is falling off the bone.
+7. Make gremolata: combine lemon zest, minced garlic, and chopped parsley.
+8. Serve shanks with sauce spooned over, topped with gremolata.""",
+ "ingredients": [
+ {"name": "Veal shanks", "quantity": 2, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Carrot", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Celery stalks", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Dry white wine", "quantity": 1, "unit": "cup", "category": "Pantry"},
+ {"name": "Beef broth", "quantity": 1, "unit": "cup", "category": "Pantry"},
+ {"name": "Crushed tomatoes", "quantity": 14, "unit": "oz", "category": "Pantry"},
+ {"name": "Lemon", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Fresh parsley", "quantity": 0.25, "unit": "cup", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "All-purpose flour", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Branzino al Forno",
+ "cuisine": "Italian",
+ "description": "Whole sea bass roasted with lemon, garlic, and herbs. Supremely simple, exceptionally flavorful.",
+ "servings": 2,
+ "calories_per_serving": 340,
+ "carbs_per_serving": 4,
+ "protein_per_serving": 38,
+ "fat_per_serving": 18,
+ "prep_time": 10,
+ "cook_time": 25,
+ "instructions": """1. Preheat oven to 425°F. Score the fish skin 3 times diagonally on each side.
+2. Season fish inside and out with salt and pepper. Stuff cavity with lemon slices, garlic, rosemary, and thyme.
+3. Place on a parchment-lined baking sheet. Drizzle generously with olive oil.
+4. Roast 20–25 minutes until skin is crisp and flesh flakes easily.
+5. Drizzle with remaining lemon juice and fresh herbs before serving.""",
+ "ingredients": [
+ {"name": "Whole branzino (sea bass)", "quantity": 2, "unit": "whole", "category": "Seafood"},
+ {"name": "Lemon", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh rosemary", "quantity": 3, "unit": "sprigs", "category": "Produce"},
+ {"name": "Fresh thyme", "quantity": 3, "unit": "sprigs", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Cherry tomatoes", "quantity": 1, "unit": "cup", "category": "Produce"},
+ ]
+ },
+ {
+ "name": "Pork Involtini",
+ "cuisine": "Italian",
+ "description": "Thin pork cutlets wrapped around prosciutto and provolone, pan-roasted in white wine and sage.",
+ "servings": 2,
+ "calories_per_serving": 420,
+ "carbs_per_serving": 4,
+ "protein_per_serving": 44,
+ "fat_per_serving": 24,
+ "prep_time": 15,
+ "cook_time": 20,
+ "instructions": """1. Pound pork cutlets to ¼-inch thickness. Season with salt and pepper.
+2. Lay a slice of prosciutto on each cutlet. Place a piece of provolone and a sage leaf at one end.
+3. Roll tightly and secure with a toothpick.
+4. Heat olive oil in a skillet over medium-high. Brown involtini on all sides, about 6 min total.
+5. Add garlic and cook 30 seconds. Add white wine and reduce by half.
+6. Add butter, baste involtini with the pan sauce. Cover and cook 5 more minutes.
+7. Remove toothpicks. Serve with pan sauce drizzled over.""",
+ "ingredients": [
+ {"name": "Pork cutlets (thin)", "quantity": 4, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Prosciutto", "quantity": 4, "unit": "slices", "category": "Meat & Poultry"},
+ {"name": "Provolone cheese", "quantity": 2, "unit": "oz", "category": "Dairy & Eggs"},
+ {"name": "Fresh sage", "quantity": 8, "unit": "leaves", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 2, "unit": "cloves", "category": "Produce"},
+ {"name": "Dry white wine", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Butter", "quantity": 2, "unit": "tbsp", "category": "Dairy & Eggs"},
+ {"name": "Olive oil", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Beef Bracciole",
+ "cuisine": "Italian",
+ "description": "Rolled flank steak stuffed with pecorino, pine nuts, and herbs, slow-braised in red wine tomato sauce.",
+ "servings": 2,
+ "calories_per_serving": 480,
+ "carbs_per_serving": 10,
+ "protein_per_serving": 46,
+ "fat_per_serving": 26,
+ "prep_time": 20,
+ "cook_time": 90,
+ "instructions": """1. Lay flank steak flat, pound to ¼ inch. Season inside with salt and pepper.
+2. Spread pecorino, pine nuts, garlic, and parsley over the surface.
+3. Roll tightly and tie with butcher's twine at 1-inch intervals.
+4. Heat olive oil in a Dutch oven. Sear roll on all sides until browned, 8 min.
+5. Add red wine and reduce by half. Add marinara sauce and 2 tbsp fresh basil.
+6. Cover and braise at 300°F for 1.5 hours until very tender.
+7. Remove twine, slice into 1-inch rounds. Serve over sauce.""",
+ "ingredients": [
+ {"name": "Flank steak", "quantity": 1, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Pecorino romano", "quantity": 2, "unit": "oz", "category": "Dairy & Eggs"},
+ {"name": "Pine nuts", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Garlic cloves", "quantity": 3, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh parsley", "quantity": 0.25, "unit": "cup", "category": "Produce"},
+ {"name": "Marinara sauce", "quantity": 2, "unit": "cups", "category": "Pantry"},
+ {"name": "Red wine", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Fresh basil", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Zucchini Cacio e Pepe",
+ "cuisine": "Italian",
+ "description": "Spiralized zucchini in a silky pecorino and black pepper sauce — all the soul of the Roman classic, none of the pasta.",
+ "servings": 2,
+ "calories_per_serving": 310,
+ "carbs_per_serving": 12,
+ "protein_per_serving": 16,
+ "fat_per_serving": 22,
+ "prep_time": 10,
+ "cook_time": 10,
+ "instructions": """1. Spiralize or julienne zucchini. Salt lightly and let sit 10 min; pat dry to remove moisture.
+2. Toast black peppercorns in a dry skillet, then crush coarsely.
+3. Melt butter in the skillet over medium. Add pepper and toast 30 seconds.
+4. Add zucchini and toss to coat; cook 2–3 min until just tender.
+5. Off heat, add half the pecorino and all the parmesan; toss vigorously while adding 2–3 tbsp hot water until creamy.
+6. Plate and top with remaining pecorino and more fresh pepper.""",
+ "ingredients": [
+ {"name": "Zucchini", "quantity": 4, "unit": "whole", "category": "Produce"},
+ {"name": "Pecorino romano", "quantity": 3, "unit": "oz", "category": "Dairy & Eggs"},
+ {"name": "Parmesan", "quantity": 2, "unit": "oz", "category": "Dairy & Eggs"},
+ {"name": "Black peppercorns", "quantity": 1, "unit": "tbsp", "category": "Spices & Herbs"},
+ {"name": "Butter", "quantity": 2, "unit": "tbsp", "category": "Dairy & Eggs"},
+ ]
+ },
+
+ # ── GREEK ────────────────────────────────────────────────────────────────
+ {
+ "name": "Lamb Chops with Tzatziki",
+ "cuisine": "Greek",
+ "description": "Herb-marinated lamb loin chops grilled to perfection with cooling homemade tzatziki.",
+ "servings": 2,
+ "calories_per_serving": 480,
+ "carbs_per_serving": 7,
+ "protein_per_serving": 48,
+ "fat_per_serving": 28,
+ "prep_time": 15,
+ "cook_time": 15,
+ "instructions": """1. Make tzatziki: grate cucumber, squeeze out liquid. Mix with yogurt, garlic, dill, lemon juice, salt. Chill.
+2. Combine olive oil, oregano, rosemary, lemon zest, garlic. Marinate chops at least 30 min.
+3. Grill or sear chops in a cast iron pan over high heat, 3–4 min per side for medium-rare.
+4. Rest 5 minutes before serving with tzatziki and lemon wedges.""",
+ "ingredients": [
+ {"name": "Lamb loin chops", "quantity": 4, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Greek yogurt", "quantity": 1, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "English cucumber", "quantity": 0.5, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh dill", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ {"name": "Lemon", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Dried oregano", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Fresh rosemary", "quantity": 2, "unit": "sprigs", "category": "Produce"},
+ ]
+ },
+ {
+ "name": "Chicken Souvlaki",
+ "cuisine": "Greek",
+ "description": "Lemon-herb marinated chicken thighs skewered and grilled, served over a Greek salad with creamy tzatziki.",
+ "servings": 2,
+ "calories_per_serving": 420,
+ "carbs_per_serving": 8,
+ "protein_per_serving": 46,
+ "fat_per_serving": 22,
+ "prep_time": 20,
+ "cook_time": 15,
+ "instructions": """1. Cut chicken into 1.5-inch cubes. Combine with olive oil, lemon juice, garlic, oregano, paprika, salt, pepper.
+2. Marinate at least 1 hour (overnight is better).
+3. Thread onto skewers. Grill 12–14 min, turning, until cooked through and slightly charred.
+4. Make quick tzatziki: Greek yogurt, grated cucumber, garlic, dill, lemon juice.
+5. Serve over mixed greens, cucumber, tomato, olives, and red onion. Top with tzatziki.""",
+ "ingredients": [
+ {"name": "Chicken thighs, boneless", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Lemon", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Dried oregano", "quantity": 2, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Smoked paprika", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Greek yogurt", "quantity": 0.75, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "English cucumber", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Fresh dill", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ {"name": "Mixed greens", "quantity": 4, "unit": "cups", "category": "Produce"},
+ {"name": "Cherry tomatoes", "quantity": 1, "unit": "cup", "category": "Produce"},
+ {"name": "Kalamata olives", "quantity": 0.25, "unit": "cup", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Shrimp Saganaki",
+ "cuisine": "Greek",
+ "description": "Plump shrimp baked in a spiced tomato sauce with crumbled feta — a taverna classic.",
+ "servings": 2,
+ "calories_per_serving": 360,
+ "carbs_per_serving": 12,
+ "protein_per_serving": 32,
+ "fat_per_serving": 18,
+ "prep_time": 10,
+ "cook_time": 20,
+ "instructions": """1. Heat olive oil in an oven-safe skillet. Sauté onion until soft, 5 min. Add garlic and red pepper flakes.
+2. Add white wine and cook 1 min. Add diced tomatoes, oregano, salt, and pepper. Simmer 8 min.
+3. Nestle shrimp into the sauce. Crumble feta over the top.
+4. Transfer to a 400°F oven and bake 10–12 min until shrimp are pink and feta is golden.
+5. Finish with fresh parsley and a squeeze of lemon.""",
+ "ingredients": [
+ {"name": "Large shrimp, peeled", "quantity": 1, "unit": "lb", "category": "Seafood"},
+ {"name": "Feta cheese", "quantity": 4, "unit": "oz", "category": "Dairy & Eggs"},
+ {"name": "Diced tomatoes (canned)", "quantity": 14, "unit": "oz", "category": "Pantry"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 3, "unit": "cloves", "category": "Produce"},
+ {"name": "Dry white wine", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Dried oregano", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Crushed red pepper flakes", "quantity": 0.5, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Fresh parsley", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ {"name": "Lemon", "quantity": 1, "unit": "whole", "category": "Produce"},
+ ]
+ },
+ {
+ "name": "Greek Lemon-Herb Baked Chicken",
+ "cuisine": "Greek",
+ "description": "Bone-in chicken thighs roasted in a bath of lemon, garlic, and olive oil with Kalamata olives.",
+ "servings": 2,
+ "calories_per_serving": 400,
+ "carbs_per_serving": 6,
+ "protein_per_serving": 42,
+ "fat_per_serving": 22,
+ "prep_time": 10,
+ "cook_time": 45,
+ "instructions": """1. Preheat oven to 400°F. Whisk together olive oil, lemon juice, garlic, oregano, salt, and pepper.
+2. Place chicken thighs in a baking dish. Pour lemon mixture over and toss to coat.
+3. Add chicken broth to the dish. Scatter olives and lemon zest over top.
+4. Roast 40–45 min, basting once, until skin is golden and juices run clear (165°F internal).
+5. Rest 5 min. Serve with pan juices spooned over.""",
+ "ingredients": [
+ {"name": "Chicken thighs, bone-in", "quantity": 4, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Lemon", "quantity": 3, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 6, "unit": "cloves", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Dried oregano", "quantity": 2, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Chicken broth", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Kalamata olives", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Spanakopita-Stuffed Chicken",
+ "cuisine": "Greek",
+ "description": "Chicken breasts filled with spinach, feta, and fresh dill — all the flavors of spanakopita without the phyllo.",
+ "servings": 2,
+ "calories_per_serving": 440,
+ "carbs_per_serving": 6,
+ "protein_per_serving": 50,
+ "fat_per_serving": 22,
+ "prep_time": 15,
+ "cook_time": 25,
+ "instructions": """1. Preheat oven to 375°F. Squeeze spinach very dry. Mix with feta, garlic, dill, salt, and pepper.
+2. Cut a deep horizontal pocket in each chicken breast without cutting all the way through.
+3. Stuff each breast with half the spinach mixture. Secure with toothpicks.
+4. Season outside with salt, pepper, and smoked paprika. Sear in ovenproof skillet 3 min per side.
+5. Transfer to oven and bake 18–20 min until internal temp reaches 165°F.
+6. Remove toothpicks, rest 5 min, slice and serve.""",
+ "ingredients": [
+ {"name": "Chicken breasts, large", "quantity": 2, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Frozen spinach, thawed", "quantity": 5, "unit": "oz", "category": "Produce"},
+ {"name": "Feta cheese", "quantity": 4, "unit": "oz", "category": "Dairy & Eggs"},
+ {"name": "Garlic cloves", "quantity": 2, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh dill", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ {"name": "Smoked paprika", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Olive oil", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+
+ # ── INDIAN ───────────────────────────────────────────────────────────────
+ {
+ "name": "Chicken Tikka Masala",
+ "cuisine": "Indian",
+ "description": "Charred yogurt-marinated chicken simmered in a rich, spiced tomato-cream sauce. Deeply aromatic and satisfying.",
+ "servings": 2,
+ "calories_per_serving": 480,
+ "carbs_per_serving": 14,
+ "protein_per_serving": 42,
+ "fat_per_serving": 26,
+ "prep_time": 20,
+ "cook_time": 35,
+ "instructions": """1. Combine yogurt, garam masala, cumin, turmeric, coriander, chili, lemon juice, and garlic. Add chicken and marinate 1–8 hours.
+2. Broil or pan-sear chicken until charred, about 8 min per side. Cut into bite-sized pieces.
+3. Heat ghee in a large pan. Sauté onion until deeply golden, 12 min. Add garlic, ginger, and tomato paste; cook 2 min.
+4. Add crushed tomatoes and simmer 10 min. Blend smooth if desired.
+5. Stir in heavy cream and chicken. Simmer 8 min until sauce thickens.
+6. Finish with kasuri methi (dried fenugreek) and fresh cilantro.""",
+ "ingredients": [
+ {"name": "Chicken thighs, boneless", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Greek yogurt", "quantity": 0.5, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Garam masala", "quantity": 2, "unit": "tbsp", "category": "Spices & Herbs"},
+ {"name": "Ground cumin", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground turmeric", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground coriander", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Kashmiri chili powder", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Crushed tomatoes", "quantity": 14, "unit": "oz", "category": "Pantry"},
+ {"name": "Heavy cream", "quantity": 0.5, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 5, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 1, "unit": "inch", "category": "Produce"},
+ {"name": "Tomato paste", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Ghee or butter", "quantity": 3, "unit": "tbsp", "category": "Dairy & Eggs"},
+ {"name": "Dried fenugreek leaves (kasuri methi)", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Fresh cilantro", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ ]
+ },
+ {
+ "name": "Saag Paneer",
+ "cuisine": "Indian",
+ "description": "Creamy spiced spinach with golden-fried paneer cubes. A vegetarian classic rich with ghee and warm spices.",
+ "servings": 2,
+ "calories_per_serving": 420,
+ "carbs_per_serving": 12,
+ "protein_per_serving": 22,
+ "fat_per_serving": 32,
+ "prep_time": 10,
+ "cook_time": 25,
+ "instructions": """1. Fry paneer cubes in ghee over medium-high until golden on all sides. Set aside.
+2. In same pan, add cumin seeds. When they pop, add onion and cook until golden, 8 min.
+3. Add garlic and ginger; cook 1 min. Add tomato, turmeric, coriander, chili. Cook 5 min.
+4. Add spinach in batches, wilting each addition. Blend to a rough purée.
+5. Return to heat. Stir in cream and paneer; simmer 5 min.
+6. Finish with garam masala and fresh ginger. Serve with extra cream drizzled over.""",
+ "ingredients": [
+ {"name": "Paneer", "quantity": 8, "unit": "oz", "category": "Dairy & Eggs"},
+ {"name": "Fresh spinach", "quantity": 10, "unit": "oz", "category": "Produce"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 1, "unit": "inch", "category": "Produce"},
+ {"name": "Cumin seeds", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground turmeric", "quantity": 0.5, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground coriander", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Garam masala", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Chili powder", "quantity": 0.5, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Tomato", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Heavy cream", "quantity": 0.25, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Ghee", "quantity": 3, "unit": "tbsp", "category": "Dairy & Eggs"},
+ ]
+ },
+ {
+ "name": "Tandoori Chicken",
+ "cuisine": "Indian",
+ "description": "Smoky, charred chicken marinated in spiced yogurt — best cooked at high heat to mimic the tandoor's intensity.",
+ "servings": 2,
+ "calories_per_serving": 380,
+ "carbs_per_serving": 8,
+ "protein_per_serving": 44,
+ "fat_per_serving": 18,
+ "prep_time": 15,
+ "cook_time": 30,
+ "instructions": """1. Score chicken deeply with a knife. Mix yogurt with tandoori masala, turmeric, cumin, chili powder, garlic, ginger, lemon juice, and salt.
+2. Coat chicken completely and marinate at least 2 hours, ideally overnight.
+3. Preheat oven to 450°F or preheat broiler. Place chicken on a rack over a foil-lined pan.
+4. Cook 25–30 min, turning once, until edges are charred and chicken is cooked through.
+5. Serve with lemon wedges, sliced onion, and fresh mint.""",
+ "ingredients": [
+ {"name": "Chicken legs and thighs", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Greek yogurt", "quantity": 1, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Tandoori masala", "quantity": 2, "unit": "tbsp", "category": "Spices & Herbs"},
+ {"name": "Ground turmeric", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground cumin", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Chili powder", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 1, "unit": "inch", "category": "Produce"},
+ {"name": "Lemon", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Red onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Fresh mint", "quantity": 0.25, "unit": "cup", "category": "Produce"},
+ ]
+ },
+ {
+ "name": "Lamb Rogan Josh",
+ "cuisine": "Indian",
+ "description": "Slow-cooked Kashmiri lamb stew fragrant with whole spices, layered heat, and a deep burgundy color.",
+ "servings": 2,
+ "calories_per_serving": 520,
+ "carbs_per_serving": 12,
+ "protein_per_serving": 46,
+ "fat_per_serving": 28,
+ "prep_time": 15,
+ "cook_time": 90,
+ "instructions": """1. Heat ghee in a heavy pot. Fry whole spices 1 min until fragrant.
+2. Add onion and cook until deeply caramelized, 15 min. Add garlic and ginger; cook 2 min.
+3. Add ground spices and chili; stir 1 min. Add lamb and sear until browned on all sides.
+4. Add yogurt one spoonful at a time, stirring well. Add tomatoes.
+5. Cover and cook on low 75–90 min, stirring occasionally, until lamb is very tender.
+6. Adjust seasoning. Garnish with cilantro and fried onions.""",
+ "ingredients": [
+ {"name": "Lamb shoulder, cubed", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 1.5, "unit": "inch", "category": "Produce"},
+ {"name": "Whole cardamom pods", "quantity": 4, "unit": "whole", "category": "Spices & Herbs"},
+ {"name": "Whole cloves", "quantity": 3, "unit": "whole", "category": "Spices & Herbs"},
+ {"name": "Cinnamon stick", "quantity": 1, "unit": "whole", "category": "Spices & Herbs"},
+ {"name": "Kashmiri chili powder", "quantity": 2, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground cumin", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground coriander", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Crushed tomatoes", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Greek yogurt", "quantity": 0.5, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Ghee", "quantity": 3, "unit": "tbsp", "category": "Dairy & Eggs"},
+ {"name": "Fresh cilantro", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ ]
+ },
+ {
+ "name": "Butter Chicken",
+ "cuisine": "Indian",
+ "description": "Mildly spiced chicken in a velvety tomato-butter sauce with cream. The dish that wins over everyone.",
+ "servings": 2,
+ "calories_per_serving": 460,
+ "carbs_per_serving": 12,
+ "protein_per_serving": 40,
+ "fat_per_serving": 28,
+ "prep_time": 15,
+ "cook_time": 35,
+ "instructions": """1. Marinate chicken in yogurt, lemon, garam masala, and chili for 1–8 hours.
+2. Broil or pan-sear chicken until lightly charred. Cut into pieces.
+3. Heat butter and oil. Sauté onion until golden, 10 min. Add garlic, ginger, and spices; cook 2 min.
+4. Add tomato purée and simmer 15 min. Blend smooth.
+5. Stir in cream, honey (optional), and kasuri methi. Add chicken and simmer 8 min.
+6. Finish with a knob of butter and fresh cilantro.""",
+ "ingredients": [
+ {"name": "Chicken thighs, boneless", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Greek yogurt", "quantity": 0.5, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Crushed tomatoes", "quantity": 14, "unit": "oz", "category": "Pantry"},
+ {"name": "Heavy cream", "quantity": 0.5, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 5, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 1, "unit": "inch", "category": "Produce"},
+ {"name": "Kashmiri chili powder", "quantity": 1.5, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Garam masala", "quantity": 1.5, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground cumin", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Dried fenugreek leaves (kasuri methi)", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Butter", "quantity": 3, "unit": "tbsp", "category": "Dairy & Eggs"},
+ {"name": "Olive oil", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Fresh cilantro", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ ]
+ },
+ {
+ "name": "Goan Fish Curry",
+ "cuisine": "Indian",
+ "description": "Tangy coconut milk curry with firm white fish — Goa's sun-drenched coast in a bowl.",
+ "servings": 2,
+ "calories_per_serving": 400,
+ "carbs_per_serving": 10,
+ "protein_per_serving": 36,
+ "fat_per_serving": 24,
+ "prep_time": 10,
+ "cook_time": 20,
+ "instructions": """1. Heat coconut oil in a pan. Sauté onion until golden, 8 min. Add garlic and ginger.
+2. Add turmeric, cumin, coriander, and chili; cook 1 min. Add tamarind paste and stir well.
+3. Pour in coconut milk; simmer 5 min. Season with salt and a pinch of sugar.
+4. Add fish in a single layer. Simmer gently 8–10 min until fish is just cooked through.
+5. Garnish with fresh cilantro and serve with cauliflower rice.""",
+ "ingredients": [
+ {"name": "Cod or halibut fillets", "quantity": 1.5, "unit": "lb", "category": "Seafood"},
+ {"name": "Coconut milk (full fat)", "quantity": 14, "unit": "oz", "category": "Pantry"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 1, "unit": "inch", "category": "Produce"},
+ {"name": "Tamarind paste", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Ground turmeric", "quantity": 0.5, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground cumin", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground coriander", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Kashmiri chili powder", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Coconut oil", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Fresh cilantro", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ ]
+ },
+
+ # ── ASIAN ────────────────────────────────────────────────────────────────
+ {
+ "name": "Beef and Broccoli",
+ "cuisine": "Asian",
+ "description": "Silky flank steak and crisp broccoli in a savory-umami stir-fry sauce. Better than takeout.",
+ "servings": 2,
+ "calories_per_serving": 380,
+ "carbs_per_serving": 14,
+ "protein_per_serving": 38,
+ "fat_per_serving": 18,
+ "prep_time": 15,
+ "cook_time": 15,
+ "instructions": """1. Slice beef thinly against the grain. Toss with 1 tbsp soy sauce and 1 tsp sesame oil. Let sit 10 min.
+2. Whisk remaining soy sauce, oyster sauce, beef broth, and arrowroot for the sauce.
+3. Heat wok or large skillet to very high. Sear beef in batches until just browned. Remove.
+4. In same wok, stir-fry broccoli 3 min. Add garlic and ginger; cook 30 seconds.
+5. Pour sauce over broccoli; toss until thickened, 1–2 min. Return beef and toss to coat.
+6. Finish with sesame oil and serve immediately.""",
+ "ingredients": [
+ {"name": "Flank steak", "quantity": 1, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Broccoli florets", "quantity": 3, "unit": "cups", "category": "Produce"},
+ {"name": "Soy sauce (or tamari)", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Oyster sauce", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Sesame oil", "quantity": 2, "unit": "tsp", "category": "Pantry"},
+ {"name": "Beef broth", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Arrowroot powder", "quantity": 1.5, "unit": "tsp", "category": "Pantry"},
+ {"name": "Garlic cloves", "quantity": 3, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 0.5, "unit": "inch", "category": "Produce"},
+ {"name": "Vegetable oil", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Thai Basil Chicken (Pad Krapao)",
+ "cuisine": "Asian",
+ "description": "Aromatic minced chicken with Thai basil, garlic, and chilies. Fast, fiery, and addictive.",
+ "servings": 2,
+ "calories_per_serving": 360,
+ "carbs_per_serving": 8,
+ "protein_per_serving": 40,
+ "fat_per_serving": 18,
+ "prep_time": 10,
+ "cook_time": 12,
+ "instructions": """1. Heat oil in a wok over high heat. Add garlic and chilies; stir 30 seconds until fragrant.
+2. Add ground chicken, breaking it up. Stir-fry 5 min until cooked and slightly browned.
+3. Add fish sauce, oyster sauce, and soy sauce; toss to coat.
+4. Remove from heat. Fold in Thai basil leaves — they'll wilt in the residual heat.
+5. Optional: top each bowl with a crispy fried egg.
+6. Serve over cauliflower rice.""",
+ "ingredients": [
+ {"name": "Ground chicken", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Fresh Thai basil", "quantity": 1.5, "unit": "cups", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 5, "unit": "cloves", "category": "Produce"},
+ {"name": "Thai red chilies", "quantity": 3, "unit": "whole", "category": "Produce"},
+ {"name": "Fish sauce", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Oyster sauce", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Soy sauce", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Vegetable oil", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Eggs", "quantity": 2, "unit": "whole", "category": "Dairy & Eggs"},
+ ]
+ },
+ {
+ "name": "Miso-Glazed Salmon",
+ "cuisine": "Asian",
+ "description": "Broiled salmon with a sweet-savory miso glaze that caramelizes into a lacquered crust. Effortlessly elegant.",
+ "servings": 2,
+ "calories_per_serving": 440,
+ "carbs_per_serving": 10,
+ "protein_per_serving": 44,
+ "fat_per_serving": 22,
+ "prep_time": 5,
+ "cook_time": 12,
+ "instructions": """1. Whisk together miso paste, sake, mirin, soy sauce, and sesame oil until smooth.
+2. Pat salmon dry. Pour marinade over and marinate 20 min (up to overnight in the fridge).
+3. Preheat broiler. Place salmon skin-side down on a foil-lined pan.
+4. Broil 10–12 min until the glaze is deeply caramelized and fish flakes easily.
+5. Garnish with scallions and sesame seeds. Serve immediately.""",
+ "ingredients": [
+ {"name": "Salmon fillets", "quantity": 2, "unit": "whole", "category": "Seafood"},
+ {"name": "White miso paste", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Sake or dry sherry", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Mirin", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Soy sauce", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Sesame oil", "quantity": 1, "unit": "tsp", "category": "Pantry"},
+ {"name": "Scallions", "quantity": 3, "unit": "whole", "category": "Produce"},
+ {"name": "Sesame seeds", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Mapo Tofu",
+ "cuisine": "Asian",
+ "description": "Silken tofu and ground pork in a fiery, numbing Sichuan sauce. A landmark of bold flavors.",
+ "servings": 2,
+ "calories_per_serving": 380,
+ "carbs_per_serving": 8,
+ "protein_per_serving": 28,
+ "fat_per_serving": 24,
+ "prep_time": 10,
+ "cook_time": 15,
+ "instructions": """1. Heat oil in a wok. Brown pork until crispy and fragrant. Push to one side.
+2. Add doubanjiang; fry in oil 1 min until deep red and fragrant. Add garlic and ginger.
+3. Pour in chicken broth; bring to a simmer. Add tofu in large cubes, stir gently.
+4. Stir in soy sauce. Thicken with arrowroot slurry.
+5. Finish with sesame oil and scatter scallions. Dust with Sichuan peppercorns before serving.""",
+ "ingredients": [
+ {"name": "Silken tofu", "quantity": 14, "unit": "oz", "category": "Pantry"},
+ {"name": "Ground pork", "quantity": 0.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Doubanjiang (chili bean paste)", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Garlic cloves", "quantity": 3, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 0.5, "unit": "inch", "category": "Produce"},
+ {"name": "Chicken broth", "quantity": 1, "unit": "cup", "category": "Pantry"},
+ {"name": "Soy sauce", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Sichuan peppercorns", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Sesame oil", "quantity": 1, "unit": "tsp", "category": "Pantry"},
+ {"name": "Scallions", "quantity": 3, "unit": "whole", "category": "Produce"},
+ {"name": "Arrowroot powder", "quantity": 1, "unit": "tsp", "category": "Pantry"},
+ {"name": "Vegetable oil", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Korean Bulgogi",
+ "cuisine": "Asian",
+ "description": "Thinly sliced ribeye marinated in soy, sesame, and Asian pear — caramelized and intensely savory.",
+ "servings": 2,
+ "calories_per_serving": 440,
+ "carbs_per_serving": 12,
+ "protein_per_serving": 42,
+ "fat_per_serving": 24,
+ "prep_time": 20,
+ "cook_time": 10,
+ "instructions": """1. Grate the Asian pear (or apple). Mix with soy sauce, sesame oil, gochujang, garlic, ginger, scallions, sugar (or substitute), and sesame seeds.
+2. Add thinly sliced beef; marinate at least 30 min (or overnight).
+3. Heat a grill pan or skillet to high. Cook beef in a single layer, 2–3 min per side until caramelized.
+4. Serve over lettuce cups or cauliflower rice, garnished with sesame seeds and scallions.""",
+ "ingredients": [
+ {"name": "Ribeye steak, thinly sliced", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Soy sauce", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Sesame oil", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Gochujang", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Asian pear or apple", "quantity": 0.5, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh ginger", "quantity": 0.5, "unit": "inch", "category": "Produce"},
+ {"name": "Sesame seeds", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Scallions", "quantity": 4, "unit": "whole", "category": "Produce"},
+ {"name": "Butter lettuce (for wraps)", "quantity": 1, "unit": "head", "category": "Produce"},
+ ]
+ },
+ {
+ "name": "Vietnamese Caramelized Pork (Thit Kho)",
+ "cuisine": "Asian",
+ "description": "Pork belly slow-braised in coconut water until sticky and deeply caramelized. Comfort food from Saigon.",
+ "servings": 2,
+ "calories_per_serving": 500,
+ "carbs_per_serving": 6,
+ "protein_per_serving": 38,
+ "fat_per_serving": 34,
+ "prep_time": 10,
+ "cook_time": 75,
+ "instructions": """1. Cut pork belly into 1.5-inch pieces. Sear in a heavy pot until golden on all sides.
+2. Combine fish sauce, soy sauce, garlic, shallot, and black pepper. Add to pork.
+3. Pour coconut water or plain water to half-cover. Add hard-boiled eggs.
+4. Bring to a boil, then reduce heat. Simmer uncovered 60–75 min, turning occasionally, until sauce is thick and sticky.
+5. The liquid should reduce to a rich glaze. Adjust seasoning.
+6. Serve garnished with scallions.""",
+ "ingredients": [
+ {"name": "Pork belly", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Eggs, hard-boiled", "quantity": 2, "unit": "whole", "category": "Dairy & Eggs"},
+ {"name": "Fish sauce", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Soy sauce", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Garlic cloves", "quantity": 3, "unit": "cloves", "category": "Produce"},
+ {"name": "Shallot", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Coconut water or water", "quantity": 1.5, "unit": "cups", "category": "Pantry"},
+ {"name": "Scallions", "quantity": 2, "unit": "whole", "category": "Produce"},
+ ]
+ },
+
+ # ── FRENCH ───────────────────────────────────────────────────────────────
+ {
+ "name": "Coq au Vin",
+ "cuisine": "French",
+ "description": "Chicken braised in Burgundy with bacon, mushrooms, and pearl onions. The definitive French comfort dish.",
+ "servings": 2,
+ "calories_per_serving": 540,
+ "carbs_per_serving": 12,
+ "protein_per_serving": 44,
+ "fat_per_serving": 28,
+ "prep_time": 20,
+ "cook_time": 90,
+ "instructions": """1. Cook bacon in a Dutch oven until crispy. Remove; leave fat in pan.
+2. Season chicken; brown on all sides in bacon fat over medium-high, 8 min. Remove.
+3. Sauté mushrooms until golden, 5 min. Add onion and cook 5 min more.
+4. Add garlic; cook 1 min. Add tomato paste and cook 1 min.
+5. Add cognac or brandy and let it reduce 1 min. Add wine and broth.
+6. Return chicken and bacon. Add thyme and bay leaf. Simmer covered 45 min until chicken is very tender.
+7. Uncover the last 15 min to thicken the sauce. Check seasoning.""",
+ "ingredients": [
+ {"name": "Chicken thighs, bone-in", "quantity": 4, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Bacon lardons", "quantity": 4, "unit": "oz", "category": "Meat & Poultry"},
+ {"name": "Red Burgundy wine", "quantity": 2, "unit": "cups", "category": "Pantry"},
+ {"name": "Chicken broth", "quantity": 1, "unit": "cup", "category": "Pantry"},
+ {"name": "Cremini mushrooms", "quantity": 8, "unit": "oz", "category": "Produce"},
+ {"name": "Pearl onions", "quantity": 1, "unit": "cup", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 3, "unit": "cloves", "category": "Produce"},
+ {"name": "Tomato paste", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Cognac or brandy", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Fresh thyme", "quantity": 4, "unit": "sprigs", "category": "Produce"},
+ {"name": "Bay leaves", "quantity": 2, "unit": "whole", "category": "Spices & Herbs"},
+ {"name": "Butter", "quantity": 2, "unit": "tbsp", "category": "Dairy & Eggs"},
+ ]
+ },
+ {
+ "name": "Duck Confit",
+ "cuisine": "French",
+ "description": "Duck legs slow-cooked in their own fat until silky and tender, then crisped in a screaming hot pan.",
+ "servings": 2,
+ "calories_per_serving": 640,
+ "carbs_per_serving": 2,
+ "protein_per_serving": 42,
+ "fat_per_serving": 50,
+ "prep_time": 15,
+ "cook_time": 180,
+ "instructions": """1. Rub duck legs with salt, garlic, thyme, rosemary, and pepper. Refrigerate uncured at least 2 hours.
+2. Place in a baking dish; cover with duck fat (or olive oil as substitute). The legs should be submerged.
+3. Cook at 250°F for 2.5–3 hours until meat is very tender and pulls easily from the bone.
+4. Remove legs. Reserve the fat for future confit or roasting potatoes.
+5. Heat a heavy skillet to high. Sear duck legs skin-side down 5–6 min until skin is shatteringly crispy.
+6. Rest briefly and serve with wilted greens or celery root purée.""",
+ "ingredients": [
+ {"name": "Duck legs", "quantity": 2, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Duck fat (or olive oil)", "quantity": 2, "unit": "cups", "category": "Pantry"},
+ {"name": "Coarse sea salt", "quantity": 2, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Fresh thyme", "quantity": 4, "unit": "sprigs", "category": "Produce"},
+ {"name": "Fresh rosemary", "quantity": 2, "unit": "sprigs", "category": "Produce"},
+ {"name": "Black pepper", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ ]
+ },
+ {
+ "name": "Steak au Poivre",
+ "cuisine": "French",
+ "description": "Pepper-crusted steak with a flambéed cognac-cream pan sauce. Bistro drama on your own stovetop.",
+ "servings": 2,
+ "calories_per_serving": 580,
+ "carbs_per_serving": 4,
+ "protein_per_serving": 46,
+ "fat_per_serving": 38,
+ "prep_time": 10,
+ "cook_time": 15,
+ "instructions": """1. Crack peppercorns coarsely with a mortar or rolling pin. Press firmly onto both sides of steaks.
+2. Season with salt. Heat 1 tbsp butter and oil in a heavy skillet over high heat.
+3. Cook steaks to desired doneness (3–4 min per side for medium-rare). Rest on a plate.
+4. Reduce heat. Add shallot; cook 1 min. Carefully add cognac and tip the pan to flambé.
+5. When flames die out, add cream and thyme. Simmer until sauce coats a spoon, 2–3 min.
+6. Season sauce. Pour over steaks and serve immediately.""",
+ "ingredients": [
+ {"name": "Ribeye or NY strip steaks", "quantity": 2, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Black peppercorns", "quantity": 2, "unit": "tbsp", "category": "Spices & Herbs"},
+ {"name": "Cognac or brandy", "quantity": 0.25, "unit": "cup", "category": "Pantry"},
+ {"name": "Heavy cream", "quantity": 0.5, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Shallot", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Fresh thyme", "quantity": 2, "unit": "sprigs", "category": "Produce"},
+ {"name": "Butter", "quantity": 2, "unit": "tbsp", "category": "Dairy & Eggs"},
+ {"name": "Olive oil", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Bouillabaisse",
+ "cuisine": "French",
+ "description": "Provençal saffron-scented seafood stew with fennel and tomatoes. A taste of Marseille in a bowl.",
+ "servings": 2,
+ "calories_per_serving": 380,
+ "carbs_per_serving": 14,
+ "protein_per_serving": 34,
+ "fat_per_serving": 18,
+ "prep_time": 20,
+ "cook_time": 30,
+ "instructions": """1. Heat olive oil in a wide pot. Sauté fennel and onion until softened, 8 min.
+2. Add garlic, orange zest, saffron, and thyme; cook 1 min.
+3. Add wine, tomatoes, and broth; bring to a boil. Simmer 15 min.
+4. Add firm fish first; cook 3 min. Add shrimp and mussels; cover and cook 5 min until mussels open.
+5. Discard any unopened mussels. Season and ladle into warm bowls.
+6. Serve with garlic aioli on the side (optional).""",
+ "ingredients": [
+ {"name": "Mixed firm white fish (cod, halibut)", "quantity": 0.75, "unit": "lb", "category": "Seafood"},
+ {"name": "Large shrimp, peeled", "quantity": 0.5, "unit": "lb", "category": "Seafood"},
+ {"name": "Mussels, cleaned", "quantity": 0.5, "unit": "lb", "category": "Seafood"},
+ {"name": "Fennel bulb", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Diced tomatoes (canned)", "quantity": 14, "unit": "oz", "category": "Pantry"},
+ {"name": "Fish or seafood broth", "quantity": 2, "unit": "cups", "category": "Pantry"},
+ {"name": "Dry white wine", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Saffron threads", "quantity": 0.25, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Orange zest", "quantity": 1, "unit": "tsp", "category": "Produce"},
+ {"name": "Fresh thyme", "quantity": 3, "unit": "sprigs", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Poulet Basquaise",
+ "cuisine": "French",
+ "description": "Braised chicken with sweet peppers, tomatoes, and olives from the Basque country. Vivid and rustic.",
+ "servings": 2,
+ "calories_per_serving": 420,
+ "carbs_per_serving": 14,
+ "protein_per_serving": 40,
+ "fat_per_serving": 20,
+ "prep_time": 15,
+ "cook_time": 50,
+ "instructions": """1. Season chicken thighs. Brown skin-side down in olive oil over medium-high, 5 min. Flip, brown 3 min more. Remove.
+2. In same pan, sauté bell peppers and onion until softened, 8 min. Add garlic; cook 1 min.
+3. Add smoked paprika and thyme; stir 30 seconds. Add wine; deglaze.
+4. Add tomatoes and bring to a simmer. Nestle chicken skin-side up in the sauce.
+5. Cook partially covered 35–40 min until chicken is cooked through.
+6. Stir in olives and fresh basil. Adjust seasoning.""",
+ "ingredients": [
+ {"name": "Chicken thighs, bone-in", "quantity": 4, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Red bell pepper", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Green bell pepper", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Onion", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 4, "unit": "cloves", "category": "Produce"},
+ {"name": "Crushed tomatoes", "quantity": 14, "unit": "oz", "category": "Pantry"},
+ {"name": "Dry white wine", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Smoked paprika", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Fresh thyme", "quantity": 3, "unit": "sprigs", "category": "Produce"},
+ {"name": "Picholine or green olives", "quantity": 0.25, "unit": "cup", "category": "Pantry"},
+ {"name": "Fresh basil", "quantity": 0.25, "unit": "cup", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Sole Meunière",
+ "cuisine": "French",
+ "description": "Delicate sole fillets dredged in flour and finished with brown butter and lemon. Simplicity perfected.",
+ "servings": 2,
+ "calories_per_serving": 340,
+ "carbs_per_serving": 6,
+ "protein_per_serving": 36,
+ "fat_per_serving": 18,
+ "prep_time": 5,
+ "cook_time": 10,
+ "instructions": """1. Pat sole fillets dry. Season with salt and white pepper; dust lightly with flour.
+2. Melt 2 tbsp butter in a large skillet over medium-high heat until foaming.
+3. Cook sole 2–3 min per side until golden. Remove to warm plates.
+4. Wipe pan; add remaining butter and cook over medium until nut-brown (beurre noisette).
+5. Off heat, add lemon juice — it will sizzle dramatically.
+6. Pour brown butter over fish. Garnish with capers (optional) and fresh parsley.""",
+ "ingredients": [
+ {"name": "Sole or flounder fillets", "quantity": 2, "unit": "whole", "category": "Seafood"},
+ {"name": "Butter", "quantity": 4, "unit": "tbsp", "category": "Dairy & Eggs"},
+ {"name": "Lemon", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Fresh flat-leaf parsley", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ {"name": "All-purpose flour", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Capers", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ ]
+ },
+ {
+ "name": "Salade Niçoise",
+ "cuisine": "French",
+ "description": "The iconic Riviera salad — tuna, eggs, haricots verts, olives, and anchovies in a bold Dijon vinaigrette.",
+ "servings": 2,
+ "calories_per_serving": 440,
+ "carbs_per_serving": 12,
+ "protein_per_serving": 38,
+ "fat_per_serving": 26,
+ "prep_time": 20,
+ "cook_time": 15,
+ "instructions": """1. Cook eggs to a firm-but-jammy soft boil (8 min from cold start). Cool and peel.
+2. Blanch haricots verts in boiling salted water 3 min; plunge into ice water to stay bright green.
+3. Whisk together olive oil, red wine vinegar, Dijon, garlic, salt, and pepper for the vinaigrette.
+4. Arrange greens on a platter. Top with tuna (broken into chunks), halved eggs, green beans, tomatoes, olives, and anchovies.
+5. Drizzle generously with vinaigrette and serve immediately.""",
+ "ingredients": [
+ {"name": "Tuna in olive oil (canned)", "quantity": 10, "unit": "oz", "category": "Seafood"},
+ {"name": "Eggs", "quantity": 4, "unit": "whole", "category": "Dairy & Eggs"},
+ {"name": "Haricots verts (thin green beans)", "quantity": 6, "unit": "oz", "category": "Produce"},
+ {"name": "Cherry tomatoes", "quantity": 1, "unit": "cup", "category": "Produce"},
+ {"name": "Kalamata olives", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Anchovy fillets", "quantity": 4, "unit": "whole", "category": "Seafood"},
+ {"name": "Mixed salad greens", "quantity": 4, "unit": "cups", "category": "Produce"},
+ {"name": "Olive oil", "quantity": 4, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Red wine vinegar", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Dijon mustard", "quantity": 1, "unit": "tsp", "category": "Pantry"},
+ {"name": "Garlic cloves", "quantity": 1, "unit": "cloves", "category": "Produce"},
+ ]
+ },
+
+ # ── SPANISH (filed under Italian) ────────────────────────────────────────
+ {
+ "name": "Pollo al Ajillo",
+ "cuisine": "Italian",
+ "description": "Spanish garlic chicken — golden pieces braised with a forest of garlic, dry sherry, and thyme until the sauce becomes sticky and deeply savory.",
+ "servings": 2,
+ "calories_per_serving": 460,
+ "carbs_per_serving": 6,
+ "protein_per_serving": 44,
+ "fat_per_serving": 26,
+ "prep_time": 10,
+ "cook_time": 35,
+ "status": "candidate",
+ "instructions": """1. Season chicken pieces with salt and pepper.
+2. Heat olive oil in a wide skillet over medium-high. Sear chicken skin-side down until deep golden, 7 min. Flip, cook 3 min more. Remove.
+3. Add garlic to the fat; cook over medium until golden and fragrant, 3 min.
+4. Add sherry; let it bubble and reduce by half, scraping up the browned bits.
+5. Return chicken and add thyme and bay leaf. Cover and braise on low 20 min.
+6. Uncover the last 5 min to thicken the sauce. Discard bay leaf.
+7. Finish with a squeeze of lemon and fresh parsley.""",
+ "ingredients": [
+ {"name": "Chicken thighs, bone-in", "quantity": 4, "unit": "whole", "category": "Meat & Poultry"},
+ {"name": "Garlic cloves", "quantity": 12, "unit": "cloves", "category": "Produce"},
+ {"name": "Dry sherry (or white wine)", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Fresh thyme", "quantity": 4, "unit": "sprigs", "category": "Produce"},
+ {"name": "Bay leaf", "quantity": 1, "unit": "whole", "category": "Spices & Herbs"},
+ {"name": "Lemon", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Fresh parsley", "quantity": 2, "unit": "tbsp", "category": "Produce"},
+ ]
+ },
+
+ # ── LEVANTINE (filed under Greek) ─────────────────────────────────────────
+ {
+ "name": "Shish Tawook",
+ "cuisine": "Greek",
+ "description": "Lebanese marinated chicken skewers with yogurt, lemon, and warm spices — charred, juicy, and irresistible with garlic toum.",
+ "servings": 2,
+ "calories_per_serving": 390,
+ "carbs_per_serving": 8,
+ "protein_per_serving": 46,
+ "fat_per_serving": 18,
+ "prep_time": 15,
+ "cook_time": 15,
+ "status": "candidate",
+ "instructions": """1. Combine yogurt, lemon juice, olive oil, garlic, tomato paste, cumin, allspice, cinnamon, paprika, and salt to form the marinade.
+2. Cut chicken into 1.5-inch cubes; toss with marinade. Marinate at least 2 hours, overnight is ideal.
+3. Thread chicken onto skewers. Let come to room temperature 15 min before cooking.
+4. Grill or broil over high heat, turning every 2–3 min, until charred at the edges and cooked through, about 12 min total.
+5. Make quick toum (garlic sauce): blend garlic, lemon juice, salt, and whisk in olive oil until emulsified.
+6. Serve skewers over cucumber-tomato salad with toum on the side.""",
+ "ingredients": [
+ {"name": "Chicken breast, boneless", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Greek yogurt", "quantity": 0.5, "unit": "cup", "category": "Dairy & Eggs"},
+ {"name": "Lemon", "quantity": 2, "unit": "whole", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 6, "unit": "cloves", "category": "Produce"},
+ {"name": "Tomato paste", "quantity": 1, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Olive oil", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Ground cumin", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground allspice", "quantity": 0.5, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Ground cinnamon", "quantity": 0.25, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "Smoked paprika", "quantity": 1, "unit": "tsp", "category": "Spices & Herbs"},
+ {"name": "English cucumber", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Cherry tomatoes", "quantity": 1, "unit": "cup", "category": "Produce"},
+ ]
+ },
+
+ # ── JAPANESE (filed under Asian) ──────────────────────────────────────────
+ {
+ "name": "Chicken Karaage",
+ "cuisine": "Asian",
+ "description": "Japanese double-fried chicken thighs — juicy inside, shattering crisp outside, with a ginger-soy marinade and kewpie mayo.",
+ "servings": 2,
+ "calories_per_serving": 480,
+ "carbs_per_serving": 8,
+ "protein_per_serving": 42,
+ "fat_per_serving": 28,
+ "prep_time": 20,
+ "cook_time": 20,
+ "status": "candidate",
+ "instructions": """1. Cut chicken thighs into 1.5-inch pieces. Combine soy sauce, sake, ginger, and garlic; marinate chicken 30 min minimum.
+2. Remove chicken from marinade; pat dry. Toss in arrowroot powder (or potato starch) to coat lightly.
+3. Heat vegetable oil to 325°F in a heavy pot. Fry chicken in batches 4 min until just cooked. Drain on a rack.
+4. Raise oil to 375°F. Fry all pieces again 90 seconds until deeply golden and shatteringly crisp.
+5. Drain and immediately season with a pinch of salt.
+6. Serve with lemon wedges, kewpie mayo for dipping, and shredded cabbage.""",
+ "ingredients": [
+ {"name": "Chicken thighs, boneless skin-on", "quantity": 1.5, "unit": "lb", "category": "Meat & Poultry"},
+ {"name": "Soy sauce", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Sake or dry sherry", "quantity": 2, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Fresh ginger", "quantity": 1, "unit": "inch", "category": "Produce"},
+ {"name": "Garlic cloves", "quantity": 3, "unit": "cloves", "category": "Produce"},
+ {"name": "Arrowroot or potato starch", "quantity": 0.5, "unit": "cup", "category": "Pantry"},
+ {"name": "Vegetable oil (for frying)", "quantity": 3, "unit": "cups", "category": "Pantry"},
+ {"name": "Lemon", "quantity": 1, "unit": "whole", "category": "Produce"},
+ {"name": "Kewpie mayonnaise", "quantity": 3, "unit": "tbsp", "category": "Pantry"},
+ {"name": "Green cabbage, shredded", "quantity": 1, "unit": "cup", "category": "Produce"},
+ ]
+ },
+]
diff --git a/app/static/css/style.css b/app/static/css/style.css
new file mode 100644
index 0000000..7167e05
--- /dev/null
+++ b/app/static/css/style.css
@@ -0,0 +1,369 @@
+/* ── Variables ────────────────────────────────────────────────────────────── */
+:root {
+ --primary: #b5451b;
+ --primary-hover: #8c3414;
+ --accent: #2d6a4f;
+ --bg: #f7f4f0;
+ --card-bg: #ffffff;
+ --text: #1a1a1a;
+ --muted: #6b7280;
+ --border: #e5e0d8;
+}
+
+/* ── Base ─────────────────────────────────────────────────────────────────── */
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
+}
+
+/* ── Navbar ───────────────────────────────────────────────────────────────── */
+.navbar {
+ background: #1c1c1c !important;
+ border-bottom: 2px solid var(--primary);
+}
+
+.navbar-brand {
+ font-size: 1.1rem;
+ letter-spacing: 0.3px;
+}
+
+.nav-link {
+ color: #ccc !important;
+ border-radius: 6px;
+ padding: 0.35rem 0.75rem !important;
+ transition: all 0.15s;
+}
+
+.nav-link:hover, .nav-link.active {
+ color: #fff !important;
+ background: rgba(255,255,255,0.1);
+}
+
+/* ── Buttons ──────────────────────────────────────────────────────────────── */
+.btn-primary {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+.btn-primary:hover {
+ background: var(--primary-hover);
+ border-color: var(--primary-hover);
+}
+
+/* ── Stat cards ───────────────────────────────────────────────────────────── */
+.stat-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 1.25rem;
+ text-align: center;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
+}
+
+.stat-number {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--primary);
+ line-height: 1;
+}
+
+.stat-label {
+ font-size: 0.8rem;
+ color: var(--muted);
+ margin-top: 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* ── Week table (dashboard + plan) ───────────────────────────────────────── */
+.week-table, .plan-table {
+ font-size: 0.85rem;
+}
+
+.week-table td, .plan-table td {
+ vertical-align: middle;
+}
+
+.meal-type-label {
+ background: #f9f6f2;
+ text-align: right;
+ padding-right: 1rem !important;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--muted);
+}
+
+.plan-cell {
+ padding: 6px !important;
+ min-width: 130px;
+}
+
+.today-col {
+ background: rgba(181, 69, 27, 0.06) !important;
+}
+
+.plan-entry, .planned-meal {
+ font-size: 0.82rem;
+}
+
+.meal-recipe-name {
+ font-weight: 600;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.meal-macros {
+ font-size: 0.72rem;
+ color: var(--muted);
+ margin-top: 2px;
+}
+
+.add-meal-btn {
+ background: transparent;
+ border: 1px dashed var(--border);
+ color: #aaa;
+ transition: all 0.15s;
+ padding: 6px;
+ font-size: 1.1rem;
+}
+
+.add-meal-btn:hover {
+ border-color: var(--primary);
+ color: var(--primary);
+ background: rgba(181,69,27,0.04);
+}
+
+.empty-slot {
+ color: #ccc;
+ font-size: 1.2rem;
+}
+
+/* ── Recipe cards ─────────────────────────────────────────────────────────── */
+.recipe-card {
+ border: 1px solid var(--border) !important;
+ border-radius: 12px !important;
+ transition: box-shadow 0.2s, transform 0.2s;
+}
+
+.recipe-card:hover {
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important;
+ transform: translateY(-2px);
+}
+
+/* ── Cuisine pills ────────────────────────────────────────────────────────── */
+.cuisine-pill {
+ display: inline-block;
+ background: #f0ebe3;
+ color: #5a4a3a;
+ font-size: 0.72rem;
+ padding: 2px 10px;
+ border-radius: 20px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+}
+
+.cuisine-badge {
+ font-size: 1rem;
+}
+
+/* ── Nutrition badges ─────────────────────────────────────────────────────── */
+.nutri-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.72rem;
+ color: var(--muted);
+ background: #f5f5f5;
+ padding: 2px 8px;
+ border-radius: 20px;
+ white-space: nowrap;
+}
+
+.nutrition-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+/* ── Info badges (recipe detail) ──────────────────────────────────────────── */
+.info-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 0.82rem;
+ padding: 4px 12px;
+ border-radius: 20px;
+ font-weight: 500;
+}
+
+/* ── Ingredient list ──────────────────────────────────────────────────────── */
+.ingredient-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.ingredient-list li {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 5px 0;
+ border-bottom: 1px solid #f0ece6;
+ font-size: 0.9rem;
+}
+
+.ingredient-list li:last-child {
+ border-bottom: none;
+}
+
+.ing-qty {
+ min-width: 80px;
+ color: var(--primary);
+ font-weight: 600;
+ font-size: 0.85rem;
+ flex-shrink: 0;
+}
+
+/* ── Instructions ─────────────────────────────────────────────────────────── */
+.instructions-block {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.instruction-step {
+ padding: 10px 14px;
+ background: #f9f6f2;
+ border-left: 3px solid var(--primary);
+ border-radius: 0 8px 8px 0;
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+
+/* ── Shopping list ────────────────────────────────────────────────────────── */
+.shopping-item {
+ transition: background 0.15s;
+}
+
+.shopping-item.checked {
+ background: #f8fdf8;
+}
+
+.shopping-item.checked .check-label {
+ text-decoration: line-through;
+ color: var(--muted);
+}
+
+.shopping-item.checked .ing-qty {
+ color: var(--muted);
+}
+
+.source-text {
+ font-style: italic;
+}
+
+/* ── Quick link cards ─────────────────────────────────────────────────────── */
+.quick-link-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 1rem 1.25rem;
+ text-decoration: none;
+ color: var(--text);
+ font-weight: 500;
+ transition: all 0.15s;
+ font-size: 0.9rem;
+}
+
+.quick-link-card:hover {
+ border-color: var(--primary);
+ background: #fdf8f5;
+ color: var(--primary);
+ box-shadow: 0 2px 8px rgba(181,69,27,0.1);
+}
+
+/* ── Footer ───────────────────────────────────────────────────────────────── */
+footer {
+ border-top: 1px solid var(--border);
+ background: var(--bg);
+}
+
+/* ── AI Chat ──────────────────────────────────────────────────────────────── */
+.chat-window {
+ background: #f9f6f2;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ min-height: 200px;
+}
+
+.message {
+ display: flex;
+ margin-bottom: 12px;
+}
+
+.message-user {
+ justify-content: flex-end;
+}
+
+.message-assistant {
+ justify-content: flex-start;
+}
+
+.message-bubble {
+ max-width: 80%;
+ padding: 10px 14px;
+ border-radius: 12px;
+ font-size: 0.9rem;
+ line-height: 1.55;
+}
+
+.message-user .message-bubble {
+ background: var(--primary);
+ color: #fff;
+ border-bottom-right-radius: 4px;
+}
+
+.message-assistant .message-bubble {
+ background: #ffffff;
+ border: 1px solid var(--border);
+ border-bottom-left-radius: 4px;
+}
+
+.recipe-added-card {
+ background: #f0fdf4 !important;
+ border-color: #86efac !important;
+}
+
+.chat-input-area textarea {
+ resize: none;
+ border-radius: 10px 0 0 10px;
+ font-size: 0.9rem;
+}
+
+.chat-input-area .btn {
+ border-radius: 0 10px 10px 0;
+}
+
+/* ── Star rating ─────────────────────────────────────────────────────────── */
+.star-rating .star {
+ color: #f59e0b;
+ font-size: 1.25rem;
+ transition: transform 0.1s;
+}
+.star-rating .star[style*="cursor:pointer"]:hover {
+ transform: scale(1.2);
+}
+
+/* ── Print ────────────────────────────────────────────────────────────────── */
+@media print {
+ .no-print, nav, footer, button, .btn { display: none !important; }
+ body { background: white; }
+ .card { border: 1px solid #ddd !important; box-shadow: none !important; }
+ .shopping-item.checked .check-label { text-decoration: none; color: #000; }
+}
diff --git a/app/static/js/main.js b/app/static/js/main.js
new file mode 100644
index 0000000..ed1147b
--- /dev/null
+++ b/app/static/js/main.js
@@ -0,0 +1,18 @@
+// Global JSON fetch helper — redirects to /login on 401
+async function api(url, options = {}) {
+ const res = await fetch(url, {
+ headers: {'Content-Type': 'application/json'},
+ ...options,
+ });
+ if (res.status === 401) {
+ window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname);
+ return null;
+ }
+ return res;
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el =>
+ new bootstrap.Tooltip(el)
+ );
+});
diff --git a/app/templates/ai_chat.html b/app/templates/ai_chat.html
new file mode 100644
index 0000000..82d2604
--- /dev/null
+++ b/app/templates/ai_chat.html
@@ -0,0 +1,241 @@
+{% extends "base.html" %}
+{% block title %}Recipe Assistant — Menu Planner{% endblock %}
+
+{% block content %}
+<div class="ai-page d-flex flex-column" style="height: calc(100vh - 130px)">
+
+ <!-- Header -->
+ <div class="d-flex align-items-center justify-content-between mb-3 flex-shrink-0">
+ <div>
+ <h1 class="h3 fw-bold mb-0">Recipe Assistant</h1>
+ <p class="text-muted small mb-0">Paste any recipe — I'll parse it, adapt it for low-carb, and add it to the library.</p>
+ </div>
+ <button id="newChatBtn" class="btn btn-outline-secondary btn-sm">
+ <i class="bi bi-arrow-counterclockwise me-1"></i>New Chat
+ </button>
+ </div>
+
+ {% if not has_api_key %}
+ <div class="alert alert-warning">
+ <i class="bi bi-exclamation-triangle me-2"></i>
+ <strong>API key not configured.</strong> Set <code>ANTHROPIC_API_KEY</code> in the Docker environment to enable the recipe assistant.
+ </div>
+ {% else %}
+
+ <!-- Chat messages -->
+ <div id="chatMessages" class="chat-window flex-grow-1 overflow-auto mb-3 p-3">
+ <div class="message message-assistant">
+ <div class="message-bubble">
+ <p class="mb-1">👋 Hi! I can add recipes to the planner for you. Just paste a recipe — from a website, cookbook, or your own notes — and I'll handle the rest.</p>
+ <p class="mb-0 text-muted small">I'll parse ingredients, estimate nutrition, categorize it, and adapt for low-carb if needed.</p>
+ </div>
+ </div>
+ </div>
+
+ <!-- Input -->
+ <div class="chat-input-area flex-shrink-0">
+ <div id="attachBadge" class="d-none mb-1">
+ <span class="badge bg-secondary"><i class="bi bi-paperclip me-1"></i><span id="attachName"></span>
+ <button type="button" class="btn-close btn-close-white ms-1" id="clearAttach" style="font-size:0.6rem"></button>
+ </span>
+ </div>
+ <div class="input-group">
+ <label class="btn btn-outline-secondary" title="Attach file" style="cursor:pointer">
+ <i class="bi bi-paperclip"></i>
+ <input type="file" id="fileInput" class="d-none"
+ accept=".txt,.html,.htm,.md,.json,.csv,.xml,image/*">
+ </label>
+ <textarea id="chatInput" class="form-control" rows="3"
+ placeholder="Paste a recipe here, or ask me something…"></textarea>
+ <button id="sendBtn" class="btn btn-primary px-4">
+ <span id="sendLabel"><i class="bi bi-send"></i></span>
+ <span id="sendSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
+ </button>
+ </div>
+ <div class="d-flex justify-content-between mt-1">
+ <small class="text-muted">Shift+Enter for new line · Enter to send</small>
+ <small class="text-muted" id="charCount"></small>
+ </div>
+ </div>
+
+ {% endif %}
+</div>
+{% endblock %}
+
+{% block scripts %}
+{% if has_api_key %}
+<script>
+let history = [];
+let busy = false;
+let attachment = null; // {name, type, text} or {name, type, b64, mime}
+
+const chatMessages = document.getElementById('chatMessages');
+const chatInput = document.getElementById('chatInput');
+const sendBtn = document.getElementById('sendBtn');
+const sendLabel = document.getElementById('sendLabel');
+const sendSpinner = document.getElementById('sendSpinner');
+const charCount = document.getElementById('charCount');
+
+// Auto-resize textarea
+chatInput.addEventListener('input', () => {
+ chatInput.style.height = 'auto';
+ chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
+ charCount.textContent = chatInput.value.length > 0 ? chatInput.value.length + ' chars' : '';
+});
+
+// Enter to send (Shift+Enter = newline)
+chatInput.addEventListener('keydown', e => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ send();
+ }
+});
+
+sendBtn.addEventListener('click', send);
+document.getElementById('newChatBtn').addEventListener('click', () => {
+ history = [];
+ chatMessages.innerHTML = `
+ <div class="message message-assistant">
+ <div class="message-bubble">
+ <p class="mb-0">New chat started. Paste a recipe or ask me anything.</p>
+ </div>
+ </div>`;
+ chatInput.value = '';
+ chatInput.style.height = 'auto';
+});
+
+// File attachment
+const fileInput = document.getElementById('fileInput');
+const attachBadge = document.getElementById('attachBadge');
+const attachName = document.getElementById('attachName');
+fileInput.addEventListener('change', function() {
+ const file = this.files[0];
+ if (!file) return;
+ const isImage = file.type.startsWith('image/');
+ const reader = new FileReader();
+ reader.onload = e => {
+ if (isImage) {
+ const b64 = e.target.result.split(',')[1];
+ attachment = {name: file.name, type: 'image', b64, mime: file.type};
+ } else {
+ attachment = {name: file.name, type: 'text', text: e.target.result};
+ }
+ attachName.textContent = file.name;
+ attachBadge.classList.remove('d-none');
+ };
+ if (isImage) reader.readAsDataURL(file);
+ else reader.readAsText(file);
+ this.value = '';
+});
+document.getElementById('clearAttach').addEventListener('click', () => {
+ attachment = null;
+ attachBadge.classList.add('d-none');
+});
+
+function setLoading(on) {
+ busy = on;
+ sendBtn.disabled = on;
+ chatInput.disabled = on;
+ sendLabel.classList.toggle('d-none', on);
+ sendSpinner.classList.toggle('d-none', !on);
+}
+
+function appendMessage(role, html) {
+ const div = document.createElement('div');
+ div.className = `message message-${role}`;
+ div.innerHTML = `<div class="message-bubble">${html}</div>`;
+ chatMessages.appendChild(div);
+ chatMessages.scrollTop = chatMessages.scrollHeight;
+}
+
+function appendRecipeCard(recipe) {
+ const div = document.createElement('div');
+ div.className = 'message message-assistant';
+ div.innerHTML = `
+ <div class="message-bubble recipe-added-card">
+ <div class="d-flex align-items-center gap-2">
+ <i class="bi bi-check-circle-fill text-success fs-5"></i>
+ <div>
+ <div class="fw-semibold">${escapeHtml(recipe.name)} added!</div>
+ <a href="/recipes/${recipe.id}" class="small">View recipe →</a>
+ </div>
+ </div>
+ </div>`;
+ chatMessages.appendChild(div);
+ chatMessages.scrollTop = chatMessages.scrollHeight;
+}
+
+function escapeHtml(s) {
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+}
+
+function mdToHtml(text) {
+ // Very simple markdown: **bold**, line breaks
+ return escapeHtml(text)
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
+ .replace(/\n/g, '<br>');
+}
+
+async function send() {
+ if (busy) return;
+ const msg = chatInput.value.trim();
+ if (!msg && !attachment) return;
+
+ const displayMsg = attachment ? `📎 ${attachment.name}${msg ? '\n' + msg : ''}` : msg;
+ appendMessage('user', mdToHtml(displayMsg));
+ chatInput.value = '';
+ chatInput.style.height = 'auto';
+ charCount.textContent = '';
+ const sentAttachment = attachment;
+ attachment = null;
+ attachBadge.classList.add('d-none');
+ setLoading(true);
+
+ // Thinking indicator
+ const thinking = document.createElement('div');
+ thinking.className = 'message message-assistant thinking-msg';
+ thinking.innerHTML = `<div class="message-bubble text-muted fst-italic">
+ <span class="spinner-border spinner-border-sm me-2" style="width:.75rem;height:.75rem"></span>Thinking…
+ </div>`;
+ chatMessages.appendChild(thinking);
+ chatMessages.scrollTop = chatMessages.scrollHeight;
+
+ try {
+ const payload = { message: msg, history };
+ if (sentAttachment) {
+ if (sentAttachment.type === 'image') {
+ payload.image_b64 = sentAttachment.b64;
+ payload.image_mime = sentAttachment.mime;
+ payload.image_name = sentAttachment.name;
+ } else {
+ payload.file_text = sentAttachment.text;
+ payload.file_name = sentAttachment.name;
+ }
+ }
+ const res = await api('/ai/chat', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ if (!res) return; // 401 redirect handled by api()
+
+ const data = await res.json();
+ thinking.remove();
+
+ if (data.error) {
+ appendMessage('assistant', `<span class="text-danger">${escapeHtml(data.error)}</span>`);
+ } else {
+ history = data.history;
+ if (data.response) appendMessage('assistant', mdToHtml(data.response));
+ (data.recipes_added || []).forEach(appendRecipeCard);
+ }
+ } catch(e) {
+ thinking.remove();
+ appendMessage('assistant', `<span class="text-danger">Network error — please try again.</span>`);
+ } finally {
+ setLoading(false);
+ chatInput.focus();
+ }
+}
+</script>
+{% endif %}
+{% endblock %}
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000..b58ef64
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,99 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{% block title %}Menu Planner{% endblock %}</title>
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
+</head>
+<body>
+
+<nav class="navbar navbar-expand-lg navbar-dark sticky-top">
+ <div class="container-fluid px-4">
+ <a class="navbar-brand fw-bold" href="/">
+ <i class="bi bi-journal-richtext me-2"></i>Menu Planner
+ </a>
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <div class="collapse navbar-collapse" id="navMain">
+ <ul class="navbar-nav me-auto gap-1">
+ <li class="nav-item">
+ <a class="nav-link {% if request.endpoint == 'index' %}active{% endif %}" href="/">
+ <i class="bi bi-house me-1"></i>Dashboard
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link {% if request.endpoint in ['recipes','recipe_detail','add_recipe'] %}active{% endif %}" href="/recipes">
+ <i class="bi bi-book me-1"></i>Recipes
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link {% if request.endpoint == 'meal_plan' %}active{% endif %}" href="/meal-plan">
+ <i class="bi bi-calendar-week me-1"></i>Meal Plan
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link {% if request.endpoint == 'shopping_list' %}active{% endif %}" href="/shopping-list">
+ <i class="bi bi-cart me-1"></i>Shopping List
+ </a>
+ </li>
+ </ul>
+
+ <ul class="navbar-nav ms-auto gap-1 align-items-center">
+ {% if current_user.is_authenticated %}
+ <li class="nav-item">
+ <a class="nav-link {% if request.endpoint == 'ai_chat_page' %}active{% endif %}" href="/ai">
+ <i class="bi bi-stars me-1"></i>AI Assistant
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link {% if request.endpoint == 'add_recipe' %}active{% endif %}" href="/recipes/add">
+ <i class="bi bi-plus-circle me-1"></i>Add Recipe
+ </a>
+ </li>
+ <li class="nav-item dropdown">
+ <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown">
+ <i class="bi bi-person-circle me-1"></i>{{ current_user.username }}
+ </a>
+ <ul class="dropdown-menu dropdown-menu-end">
+ <li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-right me-2"></i>Log out</a></li>
+ </ul>
+ </li>
+ {% else %}
+ <li class="nav-item">
+ <a class="nav-link btn btn-sm btn-outline-light px-3 ms-1" href="/login">
+ <i class="bi bi-person me-1"></i>Log in
+ </a>
+ </li>
+ {% endif %}
+ </ul>
+ </div>
+ </div>
+</nav>
+
+<main class="container-fluid px-4 py-4">
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+ {% for category, message in messages %}
+ <div class="alert alert-{{ category }} alert-dismissible fade show mb-3" role="alert">
+ {{ message }}
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+ </div>
+ {% endfor %}
+ {% endif %}
+ {% endwith %}
+ {% block content %}{% endblock %}
+</main>
+
+<footer class="text-center py-3 mt-4 no-print">
+ <small class="text-muted">Menu Planner &mdash; Low-carb, big flavor</small>
+</footer>
+
+<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
+<script src="{{ url_for('static', filename='js/main.js') }}"></script>
+{% block scripts %}{% endblock %}
+</body>
+</html>
diff --git a/app/templates/index.html b/app/templates/index.html
new file mode 100644
index 0000000..c33e348
--- /dev/null
+++ b/app/templates/index.html
@@ -0,0 +1,116 @@
+{% extends "base.html" %}
+{% block title %}Dashboard — Menu Planner{% endblock %}
+
+{% block content %}
+<div class="d-flex align-items-center justify-content-between mb-4">
+ <div>
+ <h1 class="h3 mb-0 fw-bold">This Week's Plan</h1>
+ <p class="text-muted mb-0">{{ week_start.strftime('%B %d') }} – {{ (dates[-1]).strftime('%B %d, %Y') }}</p>
+ </div>
+ <div class="d-flex gap-2">
+ <a href="/meal-plan" class="btn btn-primary"><i class="bi bi-pencil-square me-1"></i>Edit Plan</a>
+ <a href="/shopping-list" class="btn btn-outline-secondary"><i class="bi bi-cart me-1"></i>Shopping List</a>
+ </div>
+</div>
+
+<!-- Stats row -->
+<div class="row g-3 mb-4">
+ <div class="col-6 col-md-3">
+ <div class="stat-card">
+ <div class="stat-number">{{ plan | length }}</div>
+ <div class="stat-label">Meals Planned</div>
+ </div>
+ </div>
+ <div class="col-6 col-md-3">
+ <div class="stat-card">
+ <div class="stat-number">{{ stat_map.get('favorited', 0) }}</div>
+ <div class="stat-label">Favorited Recipes</div>
+ </div>
+ </div>
+ <div class="col-6 col-md-3">
+ <div class="stat-card">
+ <div class="stat-number">{{ stat_map.get('candidate', 0) + stat_map.get('favorited', 0) }}</div>
+ <div class="stat-label">Active Recipes</div>
+ </div>
+ </div>
+ <div class="col-6 col-md-3">
+ <div class="stat-card">
+ <div class="stat-number">{{ 21 - (plan | length) }}</div>
+ <div class="stat-label">Open Slots</div>
+ </div>
+ </div>
+</div>
+
+<!-- Weekly grid -->
+<div class="card shadow-sm mb-4">
+ <div class="card-body p-0">
+ <div class="table-responsive">
+ <table class="table table-bordered mb-0 week-table">
+ <thead class="table-dark">
+ <tr>
+ <th style="width:100px"></th>
+ {% for d in dates %}
+ <th class="text-center {% if d.isoformat() == today_str %}today-col{% endif %}">
+ <div class="fw-semibold">{{ d.strftime('%a') }}</div>
+ <div class="text-muted small">{{ d.strftime('%b %d') }}</div>
+ </th>
+ {% endfor %}
+ </tr>
+ </thead>
+ <tbody>
+ {% for mt in meal_types %}
+ <tr>
+ <td class="meal-type-label text-capitalize fw-semibold">{{ mt }}</td>
+ {% for d in dates %}
+ {% set key = d.isoformat() + '_' + mt %}
+ <td class="plan-cell">
+ {% if key in plan %}
+ {% set entry = plan[key] %}
+ <div class="plan-entry">
+ <span class="cuisine-badge">{{ cuisine_emoji.get(entry.cuisine, '') }}</span>
+ <span class="recipe-name">{{ entry.recipe_name }}</span>
+ </div>
+ {% else %}
+ <span class="empty-slot">—</span>
+ {% endif %}
+ </td>
+ {% endfor %}
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ </div>
+</div>
+
+<!-- Quick links -->
+<div class="row g-3">
+ <div class="col-md-4">
+ <a href="/recipes?status=favorited" class="quick-link-card">
+ <i class="bi bi-heart-fill text-danger"></i>
+ <span>View Favorites</span>
+ <i class="bi bi-arrow-right ms-auto"></i>
+ </a>
+ </div>
+ <div class="col-md-4">
+ <a href="/recipes?cuisine=Italian" class="quick-link-card">
+ <span>🇮🇹</span>
+ <span>Italian Recipes</span>
+ <i class="bi bi-arrow-right ms-auto"></i>
+ </a>
+ </div>
+ <div class="col-md-4">
+ <a href="/recipes?cuisine=French" class="quick-link-card">
+ <span>🇫🇷</span>
+ <span>French Recipes</span>
+ <i class="bi bi-arrow-right ms-auto"></i>
+ </a>
+ </div>
+</div>
+{% endblock %}
+
+{% block scripts %}
+<script>
+ const todayStr = '{{ today_str }}';
+</script>
+{% endblock %}
diff --git a/app/templates/login.html b/app/templates/login.html
new file mode 100644
index 0000000..2d7254f
--- /dev/null
+++ b/app/templates/login.html
@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+{% block title %}Log In — Menu Planner{% endblock %}
+
+{% block content %}
+<div class="row justify-content-center mt-5">
+ <div class="col-sm-8 col-md-5 col-lg-4">
+ <div class="card shadow-sm">
+ <div class="card-body p-4">
+ <div class="text-center mb-4">
+ <i class="bi bi-journal-richtext fs-1" style="color:var(--primary)"></i>
+ <h2 class="h4 fw-bold mt-2">Menu Planner</h2>
+ <p class="text-muted small">Log in to plan meals and manage recipes</p>
+ </div>
+
+ <form method="POST" action="/login{% if request.args.get('next') %}?next={{ request.args.get('next') }}{% endif %}">
+ <div class="mb-3">
+ <label class="form-label fw-semibold" for="username">Username</label>
+ <input type="text" class="form-control" id="username" name="username"
+ autocomplete="username" autofocus required>
+ </div>
+ <div class="mb-4">
+ <label class="form-label fw-semibold" for="password">Password</label>
+ <input type="password" class="form-control" id="password" name="password"
+ autocomplete="current-password" required>
+ </div>
+ <button type="submit" class="btn btn-primary w-100">
+ <i class="bi bi-box-arrow-in-right me-2"></i>Log in
+ </button>
+ </form>
+
+ <hr class="my-3">
+ <p class="text-center text-muted small mb-0">
+ <i class="bi bi-eye me-1"></i>Browsing and viewing is open to everyone.
+ </p>
+ </div>
+ </div>
+ </div>
+</div>
+{% endblock %}
diff --git a/app/templates/meal_plan.html b/app/templates/meal_plan.html
new file mode 100644
index 0000000..3c3a932
--- /dev/null
+++ b/app/templates/meal_plan.html
@@ -0,0 +1,265 @@
+{% extends "base.html" %}
+{% block title %}Meal Plan — Menu Planner{% endblock %}
+
+{% block content %}
+<div class="d-flex align-items-center justify-content-between mb-4">
+ <div>
+ <h1 class="h3 fw-bold mb-0">Meal Plan</h1>
+ <p class="text-muted mb-0">{{ week_start.strftime('%B %d') }} – {{ dates[-1].strftime('%B %d, %Y') }}</p>
+ </div>
+ <div class="d-flex align-items-center gap-2">
+ <a href="/meal-plan?week={{ prev_week }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-chevron-left"></i></a>
+ <a href="/meal-plan" class="btn btn-outline-secondary btn-sm">This Week</a>
+ <a href="/meal-plan?week={{ next_week }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-chevron-right"></i></a>
+ <button onclick="window.print()" class="btn btn-outline-secondary btn-sm">
+ <i class="bi bi-printer me-1"></i>Print
+ </button>
+ {% if current_user.is_authenticated %}
+ <button id="generateShoppingBtn" class="btn btn-success btn-sm">
+ <i class="bi bi-cart-plus me-1"></i>Generate Shopping List
+ </button>
+ {% endif %}
+ </div>
+</div>
+
+{% if not current_user.is_authenticated %}
+<div class="alert alert-info d-flex align-items-center gap-2 mb-3">
+ <i class="bi bi-info-circle-fill"></i>
+ <span><a href="/login">Log in</a> to add or remove meals from the plan.</span>
+</div>
+{% endif %}
+
+<!-- Plan grid -->
+<div class="card shadow-sm mb-4">
+ <div class="card-body p-0">
+ <div class="table-responsive">
+ <table class="table table-bordered mb-0 plan-table">
+ <thead class="table-dark">
+ <tr>
+ <th style="width:110px"></th>
+ {% for d in dates %}
+ <th class="text-center {% if d.isoformat() == today_str %}today-col{% endif %}">
+ <div class="fw-semibold">{{ d.strftime('%A') }}</div>
+ <div class="small text-muted">{{ d.strftime('%b %d') }}</div>
+ </th>
+ {% endfor %}
+ </tr>
+ </thead>
+ <tbody>
+ {% for mt in meal_types %}
+ <tr>
+ <td class="meal-type-label text-capitalize fw-semibold align-middle">{{ mt }}</td>
+ {% for d in dates %}
+ {% set key = d.isoformat() + '_' + mt %}
+ <td class="plan-cell" data-date="{{ d.isoformat() }}" data-meal="{{ mt }}">
+ {% if key in plan %}
+ {% set entry = plan[key] %}
+ <div class="planned-meal" data-meal-id="{{ entry.id }}">
+ <div class="d-flex align-items-start justify-content-between gap-1">
+ <div class="flex-grow-1 overflow-hidden">
+ <div class="d-flex align-items-center gap-1">
+ <span class="cuisine-badge">{{ cuisine_emoji.get(entry.cuisine, '') }}</span>
+ <span class="meal-recipe-name">{{ entry.recipe_name }}</span>
+ </div>
+ <div class="meal-macros">
+ <span>{{ entry.calories_per_serving|int }} cal</span>
+ <span class="mx-1">·</span>
+ <span>{{ entry.carbs_per_serving|int }}g carbs</span>
+ <span class="mx-1">·</span>
+ <span class="fw-semibold">{{ entry.servings }} ppl</span>
+ </div>
+ </div>
+ {% if current_user.is_authenticated %}
+ <button class="btn btn-sm text-muted border-0 remove-meal p-0 flex-shrink-0" title="Remove">
+ <i class="bi bi-x-lg" style="font-size:0.7rem"></i>
+ </button>
+ {% endif %}
+ </div>
+ </div>
+ {% else %}
+ {% if current_user.is_authenticated %}
+ <button class="btn btn-sm add-meal-btn w-100" title="Add meal">
+ <i class="bi bi-plus text-muted"></i>
+ </button>
+ {% else %}
+ <span class="empty-slot">—</span>
+ {% endif %}
+ {% endif %}
+ </td>
+ {% endfor %}
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ </div>
+</div>
+
+<!-- Toast -->
+<div class="position-fixed bottom-0 end-0 p-3 no-print" style="z-index:1100">
+ <div id="toast" class="toast align-items-center text-bg-success border-0" role="alert">
+ <div class="d-flex">
+ <div class="toast-body" id="toastBody">Done!</div>
+ <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
+ </div>
+ </div>
+</div>
+
+<!-- Recipe picker modal -->
+<div class="modal fade" id="recipeModal" tabindex="-1">
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">Choose a Recipe</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+ </div>
+ <div class="modal-body">
+
+ <!-- People / servings picker -->
+ <div class="d-flex align-items-center gap-3 mb-3 p-3 bg-light rounded">
+ <label class="fw-semibold mb-0 text-nowrap">
+ <i class="bi bi-people me-1"></i>People eating:
+ </label>
+ <div class="d-flex align-items-center gap-2">
+ <button type="button" id="servingsMinus" class="btn btn-outline-secondary btn-sm px-2">−</button>
+ <span id="servingsDisplay" class="fw-bold fs-5 px-2">2</span>
+ <button type="button" id="servingsPlus" class="btn btn-outline-secondary btn-sm px-2">+</button>
+ </div>
+ <span class="text-muted small">Shopping list quantities will scale accordingly</span>
+ </div>
+
+ <!-- Cuisine quick-filter -->
+ <div id="cuisineFilter" class="d-flex gap-1 flex-wrap mb-3">
+ <button class="btn btn-sm btn-dark cf-btn" data-cuisine="all">All</button>
+ {% for c in cuisines %}
+ <button class="btn btn-sm btn-outline-secondary cf-btn" data-cuisine="{{ c }}">{{ cuisine_emoji.get(c) }} {{ c }}</button>
+ {% endfor %}
+ </div>
+
+ <!-- Search -->
+ <input type="text" id="recipeSearch" class="form-control mb-3" placeholder="Search recipes…">
+
+ <!-- Recipe list -->
+ <div id="recipePickerList" class="row g-2"></div>
+ </div>
+ </div>
+ </div>
+</div>
+{% endblock %}
+
+{% block scripts %}
+<script>
+const allRecipes = {{ recipes_json | safe }};
+let activeDate = null, activeMeal = null, currentCuisine = 'all', currentServings = 2;
+
+const modal = new bootstrap.Modal(document.getElementById('recipeModal'));
+const toast = new bootstrap.Toast(document.getElementById('toast'), {delay: 2500});
+
+function renderPickerList(filter='', cuisine='all') {
+ const list = document.getElementById('recipePickerList');
+ const filtered = allRecipes.filter(r =>
+ (cuisine === 'all' || r.cuisine === cuisine) &&
+ r.name.toLowerCase().includes(filter.toLowerCase())
+ );
+ if (!filtered.length) {
+ list.innerHTML = '<div class="col-12 text-center text-muted py-3">No recipes found</div>';
+ return;
+ }
+ list.innerHTML = filtered.map(r => `
+ <div class="col-12 col-md-6">
+ <button class="btn btn-outline-secondary text-start w-100 recipe-pick-btn p-2" data-id="${r.id}">
+ <div class="fw-semibold">${r.name}</div>
+ <div class="small text-muted">${r.cuisine} &nbsp;·&nbsp; ${Math.round(r.calories_per_serving || 0)} cal/serving</div>
+ </button>
+ </div>
+ `).join('');
+ list.querySelectorAll('.recipe-pick-btn').forEach(btn =>
+ btn.addEventListener('click', () => selectRecipe(parseInt(btn.dataset.id)))
+ );
+}
+
+async function selectRecipe(recipeId) {
+ const res = await api('/meal-plan/add', {
+ method: 'POST',
+ body: JSON.stringify({date: activeDate, meal_type: activeMeal, recipe_id: recipeId, servings: currentServings}),
+ });
+ if (!res) return;
+ const data = await res.json();
+ if (data.success) { modal.hide(); location.reload(); }
+}
+
+// Open modal
+document.querySelectorAll('.add-meal-btn').forEach(btn => {
+ btn.addEventListener('click', function() {
+ const cell = this.closest('.plan-cell');
+ activeDate = cell.dataset.date;
+ activeMeal = cell.dataset.meal;
+ currentCuisine = 'all';
+ document.getElementById('recipeSearch').value = '';
+ document.querySelectorAll('.cf-btn').forEach(b => {
+ b.classList.toggle('btn-dark', b.dataset.cuisine === 'all');
+ b.classList.toggle('btn-outline-secondary', b.dataset.cuisine !== 'all');
+ });
+ renderPickerList('', 'all');
+ modal.show();
+ });
+});
+
+// Remove meal
+document.querySelectorAll('.remove-meal').forEach(btn => {
+ btn.addEventListener('click', async function(e) {
+ e.stopPropagation();
+ const cell = this.closest('.plan-cell');
+ const res = await api('/meal-plan/remove', {
+ method: 'POST',
+ body: JSON.stringify({date: cell.dataset.date, meal_type: cell.dataset.meal}),
+ });
+ if (res && (await res.json()).success) location.reload();
+ });
+});
+
+// Servings +/−
+const servingsDisplay = document.getElementById('servingsDisplay');
+document.getElementById('servingsMinus').addEventListener('click', () => {
+ if (currentServings > 1) { currentServings--; servingsDisplay.textContent = currentServings; }
+});
+document.getElementById('servingsPlus').addEventListener('click', () => {
+ if (currentServings < 20) { currentServings++; servingsDisplay.textContent = currentServings; }
+});
+
+// Cuisine filter
+document.querySelectorAll('.cf-btn').forEach(btn => {
+ btn.addEventListener('click', function() {
+ document.querySelectorAll('.cf-btn').forEach(b => {
+ b.classList.remove('btn-dark'); b.classList.add('btn-outline-secondary');
+ });
+ this.classList.add('btn-dark'); this.classList.remove('btn-outline-secondary');
+ currentCuisine = this.dataset.cuisine;
+ renderPickerList(document.getElementById('recipeSearch').value, currentCuisine);
+ });
+});
+
+document.getElementById('recipeSearch').addEventListener('input', function() {
+ renderPickerList(this.value, currentCuisine);
+});
+
+// Generate shopping list
+document.getElementById('generateShoppingBtn')?.addEventListener('click', async () => {
+ const week = '{{ week_start.isoformat() }}';
+ const res = await api('/shopping-list/generate', {
+ method: 'POST', body: JSON.stringify({week}),
+ });
+ if (!res) return;
+ const data = await res.json();
+ if (data.success) {
+ document.getElementById('toastBody').textContent = 'Shopping list generated!';
+ toast.show();
+ setTimeout(() => window.location.href = `/shopping-list?week=${week}`, 1500);
+ } else {
+ document.getElementById('toast').className = 'toast align-items-center text-bg-danger border-0';
+ document.getElementById('toastBody').textContent = data.error || 'Error';
+ toast.show();
+ }
+});
+</script>
+{% endblock %}
diff --git a/app/templates/recipe_add.html b/app/templates/recipe_add.html
new file mode 100644
index 0000000..f0a0538
--- /dev/null
+++ b/app/templates/recipe_add.html
@@ -0,0 +1,216 @@
+{% extends "base.html" %}
+{% block title %}Add Recipe — Menu Planner{% endblock %}
+
+{% block content %}
+<div class="mb-3">
+ <a href="/recipes" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Recipes</a>
+</div>
+
+<h1 class="h3 fw-bold mb-4">Add a New Recipe</h1>
+
+<form method="POST" action="/recipes/add" id="recipeForm">
+ <div class="row g-4">
+
+ <!-- Left column: basic info + instructions -->
+ <div class="col-lg-7">
+
+ <!-- Basic info -->
+ <div class="card shadow-sm mb-4">
+ <div class="card-header fw-semibold">Basic Information</div>
+ <div class="card-body">
+ <div class="mb-3">
+ <label class="form-label fw-semibold">Recipe Name <span class="text-danger">*</span></label>
+ <input type="text" name="name" class="form-control" required
+ value="{{ form.get('name','') }}" placeholder="e.g. Chicken Marsala">
+ </div>
+ <div class="row g-3 mb-3">
+ <div class="col-sm-6">
+ <label class="form-label fw-semibold">Cuisine <span class="text-danger">*</span></label>
+ <input type="text" name="cuisine" class="form-control" required
+ list="cuisine-list" autocomplete="off"
+ placeholder="e.g. Italian, Mexican…"
+ value="{{ form.get('cuisine', '') }}">
+ <datalist id="cuisine-list">
+ {% for c in cuisines %}<option value="{{ c }}">{% endfor %}
+ </datalist>
+ </div>
+ <div class="col-sm-6">
+ <label class="form-label fw-semibold">Default Servings</label>
+ <input type="number" name="servings" class="form-control" min="1" max="20"
+ value="{{ form.get('servings', 2) }}">
+ <div class="form-text">Base recipe yields (shopping list scales from this)</div>
+ </div>
+ </div>
+ <div class="mb-0">
+ <label class="form-label fw-semibold">Description</label>
+ <textarea name="description" class="form-control" rows="2"
+ placeholder="One sentence that makes this dish sound irresistible…">{{ form.get('description','') }}</textarea>
+ </div>
+ </div>
+ </div>
+
+ <!-- Timing -->
+ <div class="card shadow-sm mb-4">
+ <div class="card-header fw-semibold">Timing</div>
+ <div class="card-body">
+ <div class="row g-3">
+ <div class="col-sm-6">
+ <label class="form-label fw-semibold">Prep Time (minutes)</label>
+ <input type="number" name="prep_time" class="form-control" min="0"
+ value="{{ form.get('prep_time', '') }}" placeholder="15">
+ </div>
+ <div class="col-sm-6">
+ <label class="form-label fw-semibold">Cook Time (minutes)</label>
+ <input type="number" name="cook_time" class="form-control" min="0"
+ value="{{ form.get('cook_time', '') }}" placeholder="30">
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Instructions -->
+ <div class="card shadow-sm mb-4">
+ <div class="card-header fw-semibold">Instructions</div>
+ <div class="card-body">
+ <textarea name="instructions" class="form-control font-monospace" rows="10"
+ placeholder="1. First step.&#10;2. Second step.&#10;3. Third step.">{{ form.get('instructions','') }}</textarea>
+ <div class="form-text mt-1">Number each step: <code>1. Heat oil…</code></div>
+ </div>
+ </div>
+
+ </div>
+
+ <!-- Right column: nutrition + ingredients -->
+ <div class="col-lg-5">
+
+ <!-- Nutrition -->
+ <div class="card shadow-sm mb-4">
+ <div class="card-header fw-semibold">Nutrition <span class="text-muted small fw-normal">(per serving)</span></div>
+ <div class="card-body">
+ <div class="row g-2">
+ <div class="col-6">
+ <label class="form-label small fw-semibold">Calories</label>
+ <div class="input-group input-group-sm">
+ <input type="number" name="calories_per_serving" class="form-control"
+ min="0" step="5" value="{{ form.get('calories_per_serving','') }}" placeholder="400">
+ <span class="input-group-text">kcal</span>
+ </div>
+ </div>
+ <div class="col-6">
+ <label class="form-label small fw-semibold">Net Carbs</label>
+ <div class="input-group input-group-sm">
+ <input type="number" name="carbs_per_serving" class="form-control"
+ min="0" step="0.5" value="{{ form.get('carbs_per_serving','') }}" placeholder="8">
+ <span class="input-group-text">g</span>
+ </div>
+ </div>
+ <div class="col-6">
+ <label class="form-label small fw-semibold">Protein</label>
+ <div class="input-group input-group-sm">
+ <input type="number" name="protein_per_serving" class="form-control"
+ min="0" step="0.5" value="{{ form.get('protein_per_serving','') }}" placeholder="40">
+ <span class="input-group-text">g</span>
+ </div>
+ </div>
+ <div class="col-6">
+ <label class="form-label small fw-semibold">Fat</label>
+ <div class="input-group input-group-sm">
+ <input type="number" name="fat_per_serving" class="form-control"
+ min="0" step="0.5" value="{{ form.get('fat_per_serving','') }}" placeholder="20">
+ <span class="input-group-text">g</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Ingredients -->
+ <div class="card shadow-sm">
+ <div class="card-header d-flex align-items-center justify-content-between fw-semibold">
+ Ingredients
+ <button type="button" id="addIngBtn" class="btn btn-sm btn-outline-primary">
+ <i class="bi bi-plus"></i> Add Row
+ </button>
+ </div>
+ <div class="card-body p-2">
+ <table class="table table-sm mb-0" id="ingTable">
+ <thead class="table-light">
+ <tr>
+ <th style="width:70px">Qty</th>
+ <th style="width:85px">Unit</th>
+ <th>Ingredient</th>
+ <th style="width:120px">Category</th>
+ <th style="width:30px"></th>
+ </tr>
+ </thead>
+ <tbody id="ingBody">
+ <!-- 3 starter rows -->
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ </div>
+ </div>
+
+ <div class="d-flex gap-2 mt-4 justify-content-end">
+ <a href="/recipes" class="btn btn-outline-secondary">Cancel</a>
+ <button type="submit" class="btn btn-primary px-4">
+ <i class="bi bi-check-circle me-1"></i>Save Recipe
+ </button>
+ </div>
+</form>
+{% endblock %}
+
+{% block scripts %}
+<script>
+const UNITS = {{ units | tojson }};
+const CATS = {{ categories | tojson }};
+
+function unitOptions(selected='whole') {
+ return UNITS.map(u => `<option value="${u}"${u===selected?' selected':''}>${u}</option>`).join('');
+}
+function catOptions(selected='Produce') {
+ return CATS.map(c => `<option value="${c}"${c===selected?' selected':''}>${c}</option>`).join('');
+}
+
+function newRow(qty='', unit='whole', name='', cat='Produce') {
+ const tr = document.createElement('tr');
+ tr.className = 'ing-row';
+ tr.innerHTML = `
+ <td><input type="number" name="ing_qty" class="form-control form-control-sm" min="0" step="0.25" value="${qty}" placeholder="1"></td>
+ <td><select name="ing_unit" class="form-select form-select-sm">${unitOptions(unit)}</select></td>
+ <td><input type="text" name="ing_name" class="form-control form-control-sm" value="${name}" placeholder="Ingredient…"></td>
+ <td><select name="ing_category" class="form-select form-select-sm">${catOptions(cat)}</select></td>
+ <td><button type="button" class="btn btn-sm btn-link text-danger p-0 remove-row" title="Remove"><i class="bi bi-x-lg"></i></button></td>
+ `;
+ return tr;
+}
+
+const body = document.getElementById('ingBody');
+
+// Seed 4 empty rows
+for (let i = 0; i < 4; i++) body.appendChild(newRow());
+
+document.getElementById('addIngBtn').addEventListener('click', () => {
+ body.appendChild(newRow());
+ body.lastElementChild.querySelector('input[name="ing_name"]').focus();
+});
+
+body.addEventListener('click', e => {
+ const btn = e.target.closest('.remove-row');
+ if (btn) {
+ const rows = body.querySelectorAll('.ing-row');
+ if (rows.length > 1) btn.closest('tr').remove();
+ }
+});
+
+// Don't submit empty ingredient rows
+document.getElementById('recipeForm').addEventListener('submit', function() {
+ body.querySelectorAll('.ing-row').forEach(row => {
+ const name = row.querySelector('input[name="ing_name"]').value.trim();
+ if (!name) row.querySelectorAll('input, select').forEach(el => el.disabled = true);
+ });
+});
+</script>
+{% endblock %}
diff --git a/app/templates/recipe_detail.html b/app/templates/recipe_detail.html
new file mode 100644
index 0000000..65aa0b0
--- /dev/null
+++ b/app/templates/recipe_detail.html
@@ -0,0 +1,290 @@
+{% extends "base.html" %}
+{% block title %}{{ recipe.name }} — Menu Planner{% endblock %}
+
+{% block content %}
+<div class="mb-3 d-flex gap-2">
+ <a href="/recipes" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Recipes</a>
+ <button onclick="window.print()" class="btn btn-sm btn-outline-secondary"><i class="bi bi-printer me-1"></i>Print</button>
+ <button id="shareBtn" class="btn btn-sm btn-outline-secondary"><i class="bi bi-link-45deg me-1"></i>Copy Link</button>
+</div>
+
+<div class="row g-4">
+ <!-- Left: recipe info -->
+ <div class="col-lg-8">
+ <div class="card shadow-sm">
+ <div class="card-body">
+ <div class="d-flex align-items-start justify-content-between mb-3">
+ <div>
+ <span class="cuisine-pill mb-2 d-inline-block">{{ cuisine_emoji.get(recipe.cuisine, '') }} {{ recipe.cuisine }}</span>
+ <h1 class="h2 fw-bold mb-1">{{ recipe.name }}</h1>
+ <p class="text-muted">{{ recipe.description }}</p>
+ {% if recipe.added_by %}
+ <p class="text-muted small mb-0"><i class="bi bi-person me-1"></i>Added by {{ recipe.added_by }}</p>
+ {% endif %}
+ </div>
+ <div class="ms-3 text-center">
+ {% if recipe.status == 'favorited' %}
+ <i class="bi bi-heart-fill text-danger fs-3"></i><br>
+ <small class="text-muted">Favorited</small>
+ {% endif %}
+ </div>
+ </div>
+
+ <!-- Nutrition + time badges -->
+ <div class="d-flex flex-wrap gap-2 mb-4">
+ <span class="info-badge bg-warning-subtle text-warning-emphasis">
+ <i class="bi bi-lightning-charge-fill"></i> {{ recipe.calories_per_serving|int }} cal / serving
+ </span>
+ <span class="info-badge bg-info-subtle text-info-emphasis">
+ <i class="bi bi-circle-half"></i> {{ recipe.carbs_per_serving|int }}g net carbs
+ </span>
+ {% if recipe.protein_per_serving %}
+ <span class="info-badge bg-success-subtle text-success-emphasis">
+ <i class="bi bi-droplet-fill"></i> {{ recipe.protein_per_serving|int }}g protein
+ </span>
+ {% endif %}
+ {% if recipe.fat_per_serving %}
+ <span class="info-badge bg-secondary-subtle text-secondary-emphasis">
+ <i class="bi bi-droplet"></i> {{ recipe.fat_per_serving|int }}g fat
+ </span>
+ {% endif %}
+ <span class="info-badge bg-light text-dark border">
+ <i class="bi bi-clock"></i> Prep: {{ recipe.prep_time }} min
+ </span>
+ <span class="info-badge bg-light text-dark border">
+ <i class="bi bi-fire"></i> Cook: {{ recipe.cook_time }} min
+ </span>
+ <span class="info-badge bg-light text-dark border">
+ <i class="bi bi-people"></i> {{ recipe.servings }} servings
+ </span>
+ </div>
+
+ <!-- Rating -->
+ <div class="mb-4 d-flex align-items-center gap-2">
+ <span class="text-muted small">Your rating:</span>
+ <span class="star-rating" id="starRating" data-rating="{{ recipe.rating or 0 }}" {% if not current_user.is_authenticated %}title="Log in to rate"{% endif %}>
+ {% for i in range(1, 6) %}
+ <i class="bi bi-star{% if recipe.rating and recipe.rating >= i %}-fill{% endif %} star"
+ data-value="{{ i }}" {% if current_user.is_authenticated %}style="cursor:pointer"{% endif %}></i>
+ {% endfor %}
+ </span>
+ {% if recipe.rating %}<span class="text-muted small">({{ recipe.rating }}/5)</span>{% endif %}
+ </div>
+
+ <!-- Ingredients -->
+ <h4 class="fw-semibold mb-3"><i class="bi bi-basket me-2"></i>Ingredients</h4>
+ {% set categories = ingredients | map(attribute='category') | list | unique | list %}
+ {% for cat in ['Meat & Poultry','Seafood','Dairy & Eggs','Produce','Pantry','Spices & Herbs'] %}
+ {% set cat_ings = ingredients | selectattr('category', 'equalto', cat) | list %}
+ {% if cat_ings %}
+ <div class="mb-3">
+ <h6 class="text-muted text-uppercase small fw-bold mb-2">{{ cat }}</h6>
+ <ul class="ingredient-list">
+ {% for ing in cat_ings %}
+ <li>
+ <span class="ing-qty">
+ {% if ing.quantity == ing.quantity|int %}{{ ing.quantity|int }}{% else %}{{ ing.quantity }}{% endif %}
+ {{ ing.unit }}
+ </span>
+ {{ ing.name }}
+ </li>
+ {% endfor %}
+ </ul>
+ </div>
+ {% endif %}
+ {% endfor %}
+
+ <!-- Instructions -->
+ <h4 class="fw-semibold mt-4 mb-3"><i class="bi bi-list-ol me-2"></i>Instructions</h4>
+ <div class="instructions-block">
+ {% for line in recipe.instructions.strip().split('\n') %}
+ {% if line.strip() %}
+ <div class="instruction-step">{{ line.strip() }}</div>
+ {% endif %}
+ {% endfor %}
+ </div>
+ </div>
+ </div>
+
+ <!-- Comments -->
+ <div class="card shadow-sm mt-4" id="comments">
+ <div class="card-header fw-semibold"><i class="bi bi-chat-left-text me-2"></i>Comments
+ {% if comments %}<span class="badge bg-secondary ms-1">{{ comments|length }}</span>{% endif %}
+ </div>
+ <div class="card-body">
+ {% for c in comments %}
+ <div class="mb-3 pb-3 {% if not loop.last %}border-bottom{% endif %}">
+ <div class="d-flex justify-content-between align-items-start">
+ <div>
+ <span class="fw-semibold">{{ c.username }}</span>
+ <span class="text-muted small ms-2">{{ c.created_at[:16].replace('T',' ') }}</span>
+ </div>
+ {% if current_user.is_authenticated and current_user.username == c.username %}
+ <form method="POST" action="/recipes/{{ recipe.id }}/comments/{{ c.id }}/delete" class="no-print">
+ <button type="submit" class="btn btn-sm btn-link text-danger p-0" title="Delete">
+ <i class="bi bi-trash"></i>
+ </button>
+ </form>
+ {% endif %}
+ </div>
+ <p class="mb-0 mt-1">{{ c.body }}</p>
+ </div>
+ {% else %}
+ <p class="text-muted mb-3 small">No comments yet.</p>
+ {% endfor %}
+
+ {% if current_user.is_authenticated %}
+ <form method="POST" action="/recipes/{{ recipe.id }}/comments" class="no-print">
+ <div class="mb-2">
+ <textarea name="body" class="form-control" rows="2" placeholder="Add a comment…" required></textarea>
+ </div>
+ <button type="submit" class="btn btn-sm btn-primary">Post Comment</button>
+ </form>
+ {% else %}
+ <p class="text-muted small mb-0"><a href="/login">Log in</a> to leave a comment.</p>
+ {% endif %}
+ </div>
+ </div>
+ </div>
+
+ <!-- Right: actions -->
+ <div class="col-lg-4 no-print">
+ {% if current_user.is_authenticated %}
+ <div class="card shadow-sm mb-3">
+ <div class="card-body">
+ <h5 class="fw-semibold mb-3">Add to Meal Plan</h5>
+ <div class="mb-2">
+ <label class="form-label small text-muted">Date</label>
+ <input type="date" id="mealDate" class="form-control">
+ </div>
+ <div class="row g-2 mb-3">
+ <div class="col-7">
+ <label class="form-label small text-muted">Meal</label>
+ <select id="mealType" class="form-select">
+ <option value="breakfast">Breakfast</option>
+ <option value="lunch">Lunch</option>
+ <option value="dinner" selected>Dinner</option>
+ </select>
+ </div>
+ <div class="col-5">
+ <label class="form-label small text-muted">People</label>
+ <input type="number" id="mealServings" class="form-control" min="1" max="20" value="2">
+ </div>
+ </div>
+ <button id="addToMealPlan" class="btn btn-primary w-100">
+ <i class="bi bi-plus-circle me-1"></i>Add to Plan
+ </button>
+ <div id="addResult" class="mt-2 d-none"></div>
+ </div>
+ </div>
+
+ <div class="card shadow-sm">
+ <div class="card-body">
+ <h5 class="fw-semibold mb-3">Recipe Status</h5>
+ <div class="d-flex flex-column gap-2">
+ <button class="btn {% if recipe.status=='favorited' %}btn-danger{% else %}btn-outline-danger{% endif %} status-action" data-status="favorited">
+ <i class="bi bi-heart{% if recipe.status=='favorited' %}-fill{% endif %} me-2"></i>
+ {% if recipe.status=='favorited' %}Favorited{% else %}Add to Favorites{% endif %}
+ </button>
+ <button class="btn {% if recipe.status=='candidate' %}btn-success{% else %}btn-outline-success{% endif %} status-action" data-status="candidate">
+ <i class="bi bi-check-circle{% if recipe.status=='candidate' %}-fill{% endif %} me-2"></i>
+ {% if recipe.status=='candidate' %}Candidate{% else %}Mark as Candidate{% endif %}
+ </button>
+ <button class="btn {% if recipe.status=='ignored' %}btn-secondary{% else %}btn-outline-secondary{% endif %} status-action" data-status="ignored">
+ <i class="bi bi-eye-slash{% if recipe.status=='ignored' %}-fill{% endif %} me-2"></i>
+ {% if recipe.status=='ignored' %}Ignored{% else %}Ignore Recipe{% endif %}
+ </button>
+ </div>
+ </div>
+ </div>
+ {% else %}
+ <div class="card shadow-sm">
+ <div class="card-body text-center text-muted py-4">
+ <i class="bi bi-lock fs-2 mb-2 d-block"></i>
+ <p class="mb-2 small">Log in to add this to your meal plan or update its status.</p>
+ <a href="/login" class="btn btn-primary btn-sm">Log in</a>
+ </div>
+ </div>
+ {% endif %}
+ </div>
+</div>
+{% endblock %}
+
+{% block scripts %}
+<script>
+const recipeId = {{ recipe.id }};
+
+// Default date to today
+document.getElementById('mealDate').value = new Date().toISOString().split('T')[0];
+
+document.getElementById('addToMealPlan').addEventListener('click', async () => {
+ const mealDate = document.getElementById('mealDate').value;
+ const mealType = document.getElementById('mealType').value;
+ const servings = parseInt(document.getElementById('mealServings').value) || 2;
+ if (!mealDate) return;
+ const res = await api('/meal-plan/add', {
+ method: 'POST',
+ body: JSON.stringify({date: mealDate, meal_type: mealType, recipe_id: recipeId, servings}),
+ });
+ if (!res) return;
+ const data = await res.json();
+ const el = document.getElementById('addResult');
+ el.classList.remove('d-none','alert-danger');
+ if (data.success) {
+ el.className = 'mt-2 alert alert-success py-2 small';
+ el.textContent = `Added to ${mealType} on ${mealDate} for ${servings} people`;
+ } else {
+ el.className = 'mt-2 alert alert-danger py-2 small';
+ el.textContent = data.error || 'Error adding to plan';
+ }
+});
+
+document.querySelectorAll('.status-action').forEach(btn => {
+ btn.addEventListener('click', async function() {
+ const res = await api(`/recipes/${recipeId}/status`, {
+ method: 'POST',
+ body: JSON.stringify({status: this.dataset.status}),
+ });
+ if (res && (await res.json()).success) location.reload();
+ });
+});
+
+// Share / copy link
+document.getElementById('shareBtn').addEventListener('click', function() {
+ navigator.clipboard.writeText(window.location.href).then(() => {
+ this.innerHTML = '<i class="bi bi-check2 me-1"></i>Copied!';
+ setTimeout(() => { this.innerHTML = '<i class="bi bi-link-45deg me-1"></i>Copy Link'; }, 2000);
+ });
+});
+
+// Star rating
+const starWidget = document.getElementById('starRating');
+if (starWidget) {
+ const stars = starWidget.querySelectorAll('.star');
+ const isAuth = {{ 'true' if current_user.is_authenticated else 'false' }};
+ if (isAuth) {
+ stars.forEach(star => {
+ star.addEventListener('mouseover', function() {
+ const val = parseInt(this.dataset.value);
+ stars.forEach(s => {
+ s.className = 'bi star ' + (parseInt(s.dataset.value) <= val ? 'bi-star-fill' : 'bi-star');
+ });
+ });
+ star.addEventListener('mouseout', function() {
+ const cur = parseInt(starWidget.dataset.rating) || 0;
+ stars.forEach(s => {
+ s.className = 'bi star ' + (parseInt(s.dataset.value) <= cur ? 'bi-star-fill' : 'bi-star');
+ });
+ });
+ star.addEventListener('click', async function() {
+ const val = parseInt(this.dataset.value);
+ const res = await api(`/recipes/${recipeId}/rate`, {
+ method: 'POST', body: JSON.stringify({rating: val}),
+ });
+ if (res) location.reload();
+ });
+ });
+ }
+}
+</script>
+{% endblock %}
diff --git a/app/templates/recipes.html b/app/templates/recipes.html
new file mode 100644
index 0000000..92a13bb
--- /dev/null
+++ b/app/templates/recipes.html
@@ -0,0 +1,121 @@
+{% extends "base.html" %}
+{% block title %}Recipes — Menu Planner{% endblock %}
+
+{% block content %}
+<div class="d-flex align-items-center justify-content-between mb-3">
+ <h1 class="h3 fw-bold mb-0">Recipe Library</h1>
+ <span class="badge bg-secondary fs-6">{{ recipes | length }} recipes</span>
+</div>
+
+<!-- Filters -->
+<div class="card shadow-sm mb-4">
+ <div class="card-body">
+ <form method="GET" class="row g-2 align-items-end">
+ <div class="col-md-5">
+ <input type="text" name="search" class="form-control" placeholder="Search recipes…" value="{{ search }}">
+ </div>
+ <div class="col-md-3">
+ <select name="cuisine" class="form-select">
+ <option value="all" {% if cuisine=='all' %}selected{% endif %}>All Cuisines</option>
+ {% for c in cuisines %}
+ <option value="{{ c }}" {% if cuisine==c %}selected{% endif %}>{{ cuisine_emoji[c] }} {{ c }}</option>
+ {% endfor %}
+ </select>
+ </div>
+ <div class="col-md-2">
+ <select name="status" class="form-select">
+ <option value="active" {% if status=='active' %}selected{% endif %}>Active</option>
+ <option value="favorited" {% if status=='favorited' %}selected{% endif %}>Favorites</option>
+ <option value="candidate" {% if status=='candidate' %}selected{% endif %}>Candidates</option>
+ <option value="ignored" {% if status=='ignored' %}selected{% endif %}>Ignored</option>
+ <option value="all" {% if status=='all' %}selected{% endif %}>All</option>
+ </select>
+ </div>
+ <div class="col-md-2">
+ <select name="sort" class="form-select">
+ <option value="name" {% if sort=='name' %}selected{% endif %}>Sort: Name</option>
+ <option value="total" {% if sort=='total' %}selected{% endif %}>Sort: Total Time</option>
+ <option value="prep" {% if sort=='prep' %}selected{% endif %}>Sort: Prep Time</option>
+ <option value="cook" {% if sort=='cook' %}selected{% endif %}>Sort: Cook Time</option>
+ <option value="rating" {% if sort=='rating' %}selected{% endif %}>Sort: Rating</option>
+ </select>
+ </div>
+ <div class="col-md-2">
+ <button type="submit" class="btn btn-primary w-100"><i class="bi bi-funnel me-1"></i>Filter</button>
+ </div>
+ </form>
+ </div>
+</div>
+
+<!-- Cuisine tabs shortcut -->
+<div class="mb-3 d-flex gap-2 flex-wrap">
+ <a href="/recipes?cuisine=all&status={{ status }}" class="btn btn-sm {% if cuisine=='all' %}btn-dark{% else %}btn-outline-secondary{% endif %}">All</a>
+ {% for c in cuisines %}
+ <a href="/recipes?cuisine={{ c }}&status={{ status }}" class="btn btn-sm {% if cuisine==c %}btn-dark{% else %}btn-outline-secondary{% endif %}">
+ {{ cuisine_emoji[c] }} {{ c }}
+ </a>
+ {% endfor %}
+</div>
+
+{% if recipes %}
+<div class="row g-3">
+ {% for r in recipes %}
+ <div class="col-sm-6 col-lg-4 col-xl-3">
+ <div class="recipe-card card h-100 shadow-sm {% if r.status == 'ignored' %}opacity-50{% endif %}">
+ <div class="card-body d-flex flex-column">
+ <div class="d-flex align-items-start justify-content-between mb-2">
+ <span class="cuisine-pill">{{ cuisine_emoji.get(r.cuisine, '') }} {{ r.cuisine }}</span>
+ {% if r.status == 'favorited' %}
+ <i class="bi bi-heart-fill text-danger"></i>
+ {% elif r.status == 'ignored' %}
+ <i class="bi bi-eye-slash text-muted"></i>
+ {% endif %}
+ </div>
+ <h5 class="card-title mb-1">{{ r.name }}</h5>
+ <p class="card-text text-muted small flex-grow-1">{{ r.description[:100] }}{% if r.description|length > 100 %}…{% endif %}</p>
+ {% if r.added_by %}<p class="text-muted small mb-1"><i class="bi bi-person me-1"></i>{{ r.added_by }}</p>{% endif %}
+ {% if r.rating %}
+ <div class="mb-1" style="color:#f59e0b;font-size:0.85rem">
+ {% for i in range(1,6) %}<i class="bi bi-star{% if r.rating >= i %}-fill{% endif %}"></i>{% endfor %}
+ </div>
+ {% endif %}
+ <div class="nutrition-row mt-2 mb-3">
+ <span class="nutri-badge"><i class="bi bi-lightning-charge"></i> {{ r.calories_per_serving|int }} cal</span>
+ <span class="nutri-badge"><i class="bi bi-circle-half"></i> {{ r.carbs_per_serving|int }}g carbs</span>
+ <span class="nutri-badge"><i class="bi bi-clock"></i> {{ r.prep_time + r.cook_time }} min</span>
+ </div>
+ <div class="d-flex gap-1 mt-auto">
+ <a href="/recipes/{{ r.id }}" class="btn btn-sm btn-primary flex-grow-1">View</a>
+ <button class="btn btn-sm btn-outline-danger status-btn" data-id="{{ r.id }}" data-status="favorited" title="Favorite">
+ <i class="bi bi-heart{% if r.status=='favorited' %}-fill{% endif %}"></i>
+ </button>
+ <button class="btn btn-sm btn-outline-secondary status-btn" data-id="{{ r.id }}" data-status="{% if r.status=='ignored' %}candidate{% else %}ignored{% endif %}" title="{% if r.status=='ignored' %}Restore{% else %}Ignore{% endif %}">
+ <i class="bi bi-{% if r.status=='ignored' %}eye{% else %}eye-slash{% endif %}"></i>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ {% endfor %}
+</div>
+{% else %}
+<div class="text-center py-5">
+ <i class="bi bi-search fs-1 text-muted"></i>
+ <p class="text-muted mt-2">No recipes found. Try adjusting the filters.</p>
+</div>
+{% endif %}
+{% endblock %}
+
+{% block scripts %}
+<script>
+document.querySelectorAll('.status-btn').forEach(btn => {
+ btn.addEventListener('click', async function() {
+ const res = await api(`/recipes/${this.dataset.id}/status`, {
+ method: 'POST',
+ body: JSON.stringify({status: this.dataset.status}),
+ });
+ if (res && (await res.json()).success) location.reload();
+ });
+});
+</script>
+{% endblock %}
diff --git a/app/templates/shopping_list.html b/app/templates/shopping_list.html
new file mode 100644
index 0000000..e6cfaaf
--- /dev/null
+++ b/app/templates/shopping_list.html
@@ -0,0 +1,167 @@
+{% extends "base.html" %}
+{% block title %}Shopping List — Menu Planner{% endblock %}
+
+{% block content %}
+<div class="d-flex align-items-center justify-content-between mb-4 no-print">
+ <div>
+ <h1 class="h3 fw-bold mb-0">Shopping List</h1>
+ <p class="text-muted mb-0">
+ Week of {{ week_start.strftime('%B %d, %Y') }}
+ {% if total %}&nbsp;·&nbsp; {{ checked }}/{{ total }} checked{% endif %}
+ </p>
+ </div>
+ <div class="d-flex gap-2 align-items-center">
+ <a href="/shopping-list?week={{ prev_week }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-chevron-left"></i></a>
+ <a href="/shopping-list" class="btn btn-outline-secondary btn-sm">This Week</a>
+ <a href="/shopping-list?week={{ next_week }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-chevron-right"></i></a>
+ <button onclick="window.print()" class="btn btn-outline-secondary btn-sm ms-2">
+ <i class="bi bi-printer me-1"></i>Print
+ </button>
+ {% if current_user.is_authenticated %}
+ <button id="regenerateBtn" class="btn btn-success btn-sm">
+ <i class="bi bi-arrow-clockwise me-1"></i>Regenerate
+ </button>
+ {% if total %}
+ <button id="clearBtn" class="btn btn-outline-danger btn-sm">
+ <i class="bi bi-trash me-1"></i>Clear
+ </button>
+ {% endif %}
+ {% endif %}{# end is_authenticated #}
+ </div>
+</div>
+
+{% if categories %}
+
+<!-- Progress bar -->
+{% if total %}
+<div class="mb-4 no-print">
+ <div class="d-flex justify-content-between small text-muted mb-1">
+ <span>{{ checked }} of {{ total }} items collected</span>
+ <span>{{ (checked / total * 100)|int }}%</span>
+ </div>
+ <div class="progress" style="height:8px">
+ <div class="progress-bar bg-success" style="width: {{ (checked / total * 100)|int }}%"></div>
+ </div>
+</div>
+{% endif %}
+
+<div class="row g-4">
+ {% for cat_name, items in categories %}
+ <div class="col-md-6 col-xl-4">
+ <div class="card shadow-sm h-100">
+ <div class="card-header fw-semibold d-flex align-items-center gap-2">
+ {% if cat_name == 'Meat & Poultry' %}<i class="bi bi-egg-fried text-warning"></i>
+ {% elif cat_name == 'Seafood' %}<i class="bi bi-water text-info"></i>
+ {% elif cat_name == 'Dairy & Eggs' %}<i class="bi bi-cup-straw text-warning"></i>
+ {% elif cat_name == 'Produce' %}<i class="bi bi-flower1 text-success"></i>
+ {% elif cat_name == 'Pantry' %}<i class="bi bi-archive text-secondary"></i>
+ {% elif cat_name == 'Spices & Herbs' %}<i class="bi bi-asterisk text-danger"></i>
+ {% else %}<i class="bi bi-box text-muted"></i>{% endif %}
+ {{ cat_name }}
+ <span class="badge bg-secondary ms-auto">{{ items|length }}</span>
+ </div>
+ <ul class="list-group list-group-flush">
+ {% for item in items %}
+ <li class="list-group-item shopping-item {% if item.checked %}checked{% endif %}" data-id="{{ item.id }}">
+ <div class="d-flex align-items-center gap-2">
+ <input type="checkbox" class="form-check-input flex-shrink-0 shop-check" id="item{{ item.id }}"
+ {% if item.checked %}checked{% endif %}>
+ <label for="item{{ item.id }}" class="mb-0 flex-grow-1 check-label">
+ <span class="ing-qty fw-semibold me-1">
+ {% if item.quantity == item.quantity|int %}{{ item.quantity|int }}{% else %}{{ item.quantity }}{% endif %}
+ {{ item.unit }}
+ </span>
+ {{ item.ingredient_name }}
+ </label>
+ {% if item.recipe_sources %}
+ <span class="text-muted small d-none d-sm-inline source-hint" title="{{ item.recipe_sources }}">
+ <i class="bi bi-book"></i>
+ </span>
+ {% endif %}
+ </div>
+ {% if item.recipe_sources %}
+ <div class="small text-muted mt-1 ps-4 source-text">{{ item.recipe_sources }}</div>
+ {% endif %}
+ </li>
+ {% endfor %}
+ </ul>
+ </div>
+ </div>
+ {% endfor %}
+</div>
+
+{% else %}
+<div class="text-center py-5">
+ <i class="bi bi-cart-x fs-1 text-muted"></i>
+ <p class="text-muted mt-3">No shopping list yet for this week.</p>
+ <p class="text-muted small">Add meals to your <a href="/meal-plan">meal plan</a> first, then click Regenerate.</p>
+ <button id="regenerateBtn2" class="btn btn-success mt-2">
+ <i class="bi bi-arrow-clockwise me-1"></i>Generate from Meal Plan
+ </button>
+</div>
+{% endif %}
+
+<!-- Toast -->
+<div class="position-fixed bottom-0 end-0 p-3 no-print" style="z-index:1100">
+ <div id="toast" class="toast align-items-center text-bg-success border-0" role="alert">
+ <div class="d-flex">
+ <div class="toast-body" id="toastBody">Done!</div>
+ <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
+ </div>
+ </div>
+</div>
+{% endblock %}
+
+{% block scripts %}
+<script>
+const weekStart = '{{ week_start.isoformat() }}';
+const toast = new bootstrap.Toast(document.getElementById('toast'), {delay: 2000});
+
+// Checkbox toggle
+document.querySelectorAll('.shop-check').forEach(cb => {
+ cb.addEventListener('change', async function() {
+ const item = this.closest('.shopping-item');
+ const res = await api(`/shopping-list/${item.dataset.id}/check`, {
+ method: 'POST', body: JSON.stringify({checked: this.checked}),
+ });
+ if (res) {
+ item.classList.toggle('checked', this.checked);
+ updateProgress();
+ }
+ });
+});
+
+function updateProgress() {
+ const all = document.querySelectorAll('.shop-check').length;
+ const done = document.querySelectorAll('.shop-check:checked').length;
+ const bar = document.querySelector('.progress-bar');
+ if (bar) bar.style.width = all ? `${Math.round(done/all*100)}%` : '0%';
+}
+
+async function regenerate() {
+ const res = await api('/shopping-list/generate', {
+ method: 'POST', body: JSON.stringify({week: weekStart}),
+ });
+ if (!res) return;
+ const data = await res.json();
+ if (data.success) {
+ location.reload();
+ } else {
+ document.getElementById('toastBody').textContent = data.error || 'No meals planned for this week.';
+ document.getElementById('toast').className = 'toast align-items-center text-bg-danger border-0';
+ toast.show();
+ }
+}
+
+document.getElementById('regenerateBtn')?.addEventListener('click', regenerate);
+document.getElementById('regenerateBtn2')?.addEventListener('click', regenerate);
+
+document.getElementById('clearBtn')?.addEventListener('click', async () => {
+ if (!confirm('Clear the shopping list for this week?')) return;
+ const res = await api('/shopping-list/clear', {
+ method: 'POST', body: JSON.stringify({week: weekStart}),
+ });
+ if (res) location.reload();
+});
+</script>
+{% endblock %}
diff --git a/refresh-token.sh b/refresh-token.sh
new file mode 100755
index 0000000..0f9038b
--- /dev/null
+++ b/refresh-token.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Refresh the Claude OAuth token and update it on menu.jots.org
+# Runs automatically twice daily via systemd timer (menu-token-refresh.timer)
+
+set -e
+
+CREDS="$HOME/.claude/.credentials.json"
+if [ ! -f "$CREDS" ]; then
+ echo "ERROR: $CREDS not found"
+ exit 1
+fi
+
+ACCESS_TOKEN=$(grep -o '"accessToken":"[^"]*"' "$CREDS" | cut -d'"' -f4)
+
+if [ -z "$ACCESS_TOKEN" ]; then
+ echo "ERROR: could not read access token from $CREDS"
+ exit 1
+fi
+
+echo "Pushing fresh token to menu-planner LXC container..."
+ssh root@192.168.10.50 "
+ sed -i 's|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=${ACCESS_TOKEN}|' /var/lib/lxc/menu-planner/rootfs/etc/menu-planner.env &&
+ lxc-attach -n menu-planner -- systemctl restart menu-planner
+"
+
+echo "Done. AI assistant is live with a fresh token."
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..6d317cc
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+Flask==3.0.3
+Flask-Login==0.6.3
+anthropic==0.50.0