From 55bcec90c14db6f2956ed51cf4df1503c0767f81 Mon Sep 17 00:00:00 2001 From: Ken D'Ambrosio Date: Mon, 25 May 2026 00:46:10 +0000 Subject: =?UTF-8?q?Initial=20commit=20=E2=80=94=20menu.jots.org=20Flask/SQ?= =?UTF-8?q?Lite=20meal=20planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-featured weekly menu planner with: - Recipe library with ratings, comments, cuisine/nationality, added-by attribution - AI recipe assistant (Claude) with URL fetching and file upload - Weekly meal plan grid with shopping list generation - Sort by name, prep/cook time, or rating - Print and copy-link support - Deployed on LXC container (192.168.10.51) behind Apache reverse proxy Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 8 + app/app.py | 883 +++++++++++++++++++++++++++++++++ app/db.py | 140 ++++++ app/seed_data.py | 1007 ++++++++++++++++++++++++++++++++++++++ app/static/css/style.css | 369 ++++++++++++++ app/static/js/main.js | 18 + app/templates/ai_chat.html | 241 +++++++++ app/templates/base.html | 99 ++++ app/templates/index.html | 116 +++++ app/templates/login.html | 39 ++ app/templates/meal_plan.html | 265 ++++++++++ app/templates/recipe_add.html | 216 ++++++++ app/templates/recipe_detail.html | 290 +++++++++++ app/templates/recipes.html | 121 +++++ app/templates/shopping_list.html | 167 +++++++ refresh-token.sh | 26 + requirements.txt | 3 + 17 files changed, 4008 insertions(+) create mode 100644 .gitignore create mode 100644 app/app.py create mode 100644 app/db.py create mode 100644 app/seed_data.py create mode 100644 app/static/css/style.css create mode 100644 app/static/js/main.js create mode 100644 app/templates/ai_chat.html create mode 100644 app/templates/base.html create mode 100644 app/templates/index.html create mode 100644 app/templates/login.html create mode 100644 app/templates/meal_plan.html create mode 100644 app/templates/recipe_add.html create mode 100644 app/templates/recipe_detail.html create mode 100644 app/templates/recipes.html create mode 100644 app/templates/shopping_list.html create mode 100755 refresh-token.sh create mode 100644 requirements.txt 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/') +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//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//comments//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//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//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//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 %} +
+ + +
+
+

Recipe Assistant

+

Paste any recipe — I'll parse it, adapt it for low-carb, and add it to the library.

+
+ +
+ + {% if not has_api_key %} +
+ + API key not configured. Set ANTHROPIC_API_KEY in the Docker environment to enable the recipe assistant. +
+ {% else %} + + +
+
+
+

👋 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.

+

I'll parse ingredients, estimate nutrition, categorize it, and adapt for low-carb if needed.

+
+
+
+ + +
+
+ + + +
+
+ + + +
+
+ Shift+Enter for new line · Enter to send + +
+
+ + {% endif %} +
+{% endblock %} + +{% block scripts %} +{% if has_api_key %} + +{% 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 @@ + + + + + + {% block title %}Menu Planner{% endblock %} + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+ Menu Planner — Low-carb, big flavor +
+ + + +{% block scripts %}{% endblock %} + + 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 %} +
+
+

This Week's Plan

+

{{ week_start.strftime('%B %d') }} – {{ (dates[-1]).strftime('%B %d, %Y') }}

+
+ +
+ + +
+
+
+
{{ plan | length }}
+
Meals Planned
+
+
+
+
+
{{ stat_map.get('favorited', 0) }}
+
Favorited Recipes
+
+
+
+
+
{{ stat_map.get('candidate', 0) + stat_map.get('favorited', 0) }}
+
Active Recipes
+
+
+
+
+
{{ 21 - (plan | length) }}
+
Open Slots
+
+
+
+ + +
+
+
+ + + + + {% for d in dates %} + + {% endfor %} + + + + {% for mt in meal_types %} + + + {% for d in dates %} + {% set key = d.isoformat() + '_' + mt %} + + {% endfor %} + + {% endfor %} + +
+
{{ d.strftime('%a') }}
+
{{ d.strftime('%b %d') }}
+
{{ mt }} + {% if key in plan %} + {% set entry = plan[key] %} +
+ {{ cuisine_emoji.get(entry.cuisine, '') }} + {{ entry.recipe_name }} +
+ {% else %} + + {% endif %} +
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% 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 %} +
+
+
+
+
+ +

Menu Planner

+

Log in to plan meals and manage recipes

+
+ +
+
+ + +
+
+ + +
+ +
+ +
+

+ Browsing and viewing is open to everyone. +

+
+
+
+
+{% 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 %} +
+
+

Meal Plan

+

{{ week_start.strftime('%B %d') }} – {{ dates[-1].strftime('%B %d, %Y') }}

+
+
+ + This Week + + + {% if current_user.is_authenticated %} + + {% endif %} +
+
+ +{% if not current_user.is_authenticated %} +
+ + Log in to add or remove meals from the plan. +
+{% endif %} + + +
+
+
+ + + + + {% for d in dates %} + + {% endfor %} + + + + {% for mt in meal_types %} + + + {% for d in dates %} + {% set key = d.isoformat() + '_' + mt %} + + {% endfor %} + + {% endfor %} + +
+
{{ d.strftime('%A') }}
+
{{ d.strftime('%b %d') }}
+
{{ mt }} + {% if key in plan %} + {% set entry = plan[key] %} +
+
+
+
+ {{ cuisine_emoji.get(entry.cuisine, '') }} + {{ entry.recipe_name }} +
+
+ {{ entry.calories_per_serving|int }} cal + · + {{ entry.carbs_per_serving|int }}g carbs + · + {{ entry.servings }} ppl +
+
+ {% if current_user.is_authenticated %} + + {% endif %} +
+
+ {% else %} + {% if current_user.is_authenticated %} + + {% else %} + + {% endif %} + {% endif %} +
+
+
+
+ + +
+ +
+ + + +{% endblock %} + +{% block scripts %} + +{% 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 %} + + +

Add a New Recipe

+ +
+
+ + +
+ + +
+
Basic Information
+
+
+ + +
+
+
+ + + + {% for c in cuisines %} +
+
+ + +
Base recipe yields (shopping list scales from this)
+
+
+
+ + +
+
+
+ + +
+
Timing
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
Instructions
+
+ +
Number each step: 1. Heat oil…
+
+
+ +
+ + +
+ + +
+
Nutrition (per serving)
+
+
+
+ +
+ + kcal +
+
+
+ +
+ + g +
+
+
+ +
+ + g +
+
+
+ +
+ + g +
+
+
+
+
+ + +
+
+ Ingredients + +
+
+ + + + + + + + + + + + + +
QtyUnitIngredientCategory
+
+
+ +
+
+ +
+ Cancel + +
+
+{% endblock %} + +{% block scripts %} + +{% 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 %} +
+ Back to Recipes + + +
+ +
+ +
+
+
+
+
+ {{ cuisine_emoji.get(recipe.cuisine, '') }} {{ recipe.cuisine }} +

{{ recipe.name }}

+

{{ recipe.description }}

+ {% if recipe.added_by %} +

Added by {{ recipe.added_by }}

+ {% endif %} +
+
+ {% if recipe.status == 'favorited' %} +
+ Favorited + {% endif %} +
+
+ + +
+ + {{ recipe.calories_per_serving|int }} cal / serving + + + {{ recipe.carbs_per_serving|int }}g net carbs + + {% if recipe.protein_per_serving %} + + {{ recipe.protein_per_serving|int }}g protein + + {% endif %} + {% if recipe.fat_per_serving %} + + {{ recipe.fat_per_serving|int }}g fat + + {% endif %} + + Prep: {{ recipe.prep_time }} min + + + Cook: {{ recipe.cook_time }} min + + + {{ recipe.servings }} servings + +
+ + +
+ Your rating: + + {% for i in range(1, 6) %} + + {% endfor %} + + {% if recipe.rating %}({{ recipe.rating }}/5){% endif %} +
+ + +

Ingredients

+ {% 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 %} +
+
{{ cat }}
+
    + {% for ing in cat_ings %} +
  • + + {% if ing.quantity == ing.quantity|int %}{{ ing.quantity|int }}{% else %}{{ ing.quantity }}{% endif %} + {{ ing.unit }} + + {{ ing.name }} +
  • + {% endfor %} +
+
+ {% endif %} + {% endfor %} + + +

Instructions

+
+ {% for line in recipe.instructions.strip().split('\n') %} + {% if line.strip() %} +
{{ line.strip() }}
+ {% endif %} + {% endfor %} +
+
+
+ + +
+
Comments + {% if comments %}{{ comments|length }}{% endif %} +
+
+ {% for c in comments %} +
+
+
+ {{ c.username }} + {{ c.created_at[:16].replace('T',' ') }} +
+ {% if current_user.is_authenticated and current_user.username == c.username %} +
+ +
+ {% endif %} +
+

{{ c.body }}

+
+ {% else %} +

No comments yet.

+ {% endfor %} + + {% if current_user.is_authenticated %} +
+
+ +
+ +
+ {% else %} +

Log in to leave a comment.

+ {% endif %} +
+
+
+ + +
+ {% if current_user.is_authenticated %} +
+
+
Add to Meal Plan
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
Recipe Status
+
+ + + +
+
+
+ {% else %} +
+
+ +

Log in to add this to your meal plan or update its status.

+ Log in +
+
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% 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 %} +
+

Recipe Library

+ {{ recipes | length }} recipes +
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+ All + {% for c in cuisines %} + + {{ cuisine_emoji[c] }} {{ c }} + + {% endfor %} +
+ +{% if recipes %} +
+ {% for r in recipes %} +
+
+
+
+ {{ cuisine_emoji.get(r.cuisine, '') }} {{ r.cuisine }} + {% if r.status == 'favorited' %} + + {% elif r.status == 'ignored' %} + + {% endif %} +
+
{{ r.name }}
+

{{ r.description[:100] }}{% if r.description|length > 100 %}…{% endif %}

+ {% if r.added_by %}

{{ r.added_by }}

{% endif %} + {% if r.rating %} +
+ {% for i in range(1,6) %}{% endfor %} +
+ {% endif %} +
+ {{ r.calories_per_serving|int }} cal + {{ r.carbs_per_serving|int }}g carbs + {{ r.prep_time + r.cook_time }} min +
+
+ View + + +
+
+
+
+ {% endfor %} +
+{% else %} +
+ +

No recipes found. Try adjusting the filters.

+
+{% endif %} +{% endblock %} + +{% block scripts %} + +{% 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 %} +
+
+

Shopping List

+

+ Week of {{ week_start.strftime('%B %d, %Y') }} + {% if total %} ·  {{ checked }}/{{ total }} checked{% endif %} +

+
+
+ + This Week + + + {% if current_user.is_authenticated %} + + {% if total %} + + {% endif %} + {% endif %}{# end is_authenticated #} +
+
+ +{% if categories %} + + +{% if total %} +
+
+ {{ checked }} of {{ total }} items collected + {{ (checked / total * 100)|int }}% +
+
+
+
+
+{% endif %} + +
+ {% for cat_name, items in categories %} +
+
+
+ {% if cat_name == 'Meat & Poultry' %} + {% elif cat_name == 'Seafood' %} + {% elif cat_name == 'Dairy & Eggs' %} + {% elif cat_name == 'Produce' %} + {% elif cat_name == 'Pantry' %} + {% elif cat_name == 'Spices & Herbs' %} + {% else %}{% endif %} + {{ cat_name }} + {{ items|length }} +
+
    + {% for item in items %} +
  • +
    + + + {% if item.recipe_sources %} + + + + {% endif %} +
    + {% if item.recipe_sources %} +
    {{ item.recipe_sources }}
    + {% endif %} +
  • + {% endfor %} +
+
+
+ {% endfor %} +
+ +{% else %} +
+ +

No shopping list yet for this week.

+

Add meals to your meal plan first, then click Regenerate.

+ +
+{% endif %} + + +
+ +
+{% endblock %} + +{% block scripts %} + +{% 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 -- cgit v1.2.3