diff --git a/.gitignore b/.gitignore index 4c49bd7..e4a2e85 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .env +meals.db +__pycache__/ +.pytest_cache/ diff --git a/Dockerfile.meal b/Dockerfile.meal new file mode 100644 index 0000000..6efbb1c --- /dev/null +++ b/Dockerfile.meal @@ -0,0 +1,24 @@ +FROM python:3.13-alpine + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY app.py models.py auth.py meals.py ./ +COPY templates/ templates/ +COPY static/ static/ + +# Create persistent data directory +RUN mkdir -p /data +ENV DB_PATH=/data/meals.db + +EXPOSE 5000 + +# Generate a default secret key at build time (override in prod) +ENV SECRET_KEY=change-me-in-production + +# Run in production mode (no debug, use a WSGI server) +CMD ["flask", "--app", "app:create_app()", "run", "--host=0.0.0.0", "--port=5000"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..46426af --- /dev/null +++ b/app.py @@ -0,0 +1,27 @@ +"""Meal Tracker — Flask application entry point.""" + +import os + +from flask import Flask + +from models import init_db +from auth import auth_bp +from meals import meals_bp + + +def create_app() -> Flask: + app = Flask(__name__) + app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-change-me") + + app.register_blueprint(auth_bp) + app.register_blueprint(meals_bp) + + with app.app_context(): + init_db() + + return app + + +if __name__ == "__main__": + app = create_app() + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..ee8fce5 --- /dev/null +++ b/auth.py @@ -0,0 +1,71 @@ +"""Authentication blueprint — login, register, logout.""" + +from flask import Blueprint, request, render_template, redirect, url_for, session, flash +from werkzeug.security import generate_password_hash, check_password_hash + +from models import get_user_by_username, create_user, count_users + +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if not username or not password: + flash("Username and password are required.", "error") + return render_template("login.html") + + user = get_user_by_username(username) + if user is None or not check_password_hash(user["password_hash"], password): + flash("Invalid username or password.", "error") + return render_template("login.html") + + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(url_for("meals.dashboard")) + + return render_template("login.html") + + +@auth_bp.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + confirm = request.form.get("confirm", "") + + errors = [] + if not username or len(username) < 2: + errors.append("Username must be at least 2 characters.") + if not password or len(password) < 4: + errors.append("Password must be at least 4 characters.") + if password != confirm: + errors.append("Passwords do not match.") + if get_user_by_username(username): + errors.append("Username already taken.") + + if errors: + for e in errors: + flash(e, "error") + return render_template("register.html") + + # First user to register becomes admin + is_admin = count_users() == 0 + password_hash = generate_password_hash(password) + create_user(username, password_hash, is_admin=is_admin) + + flash("Account created! Please log in.", "success") + return redirect(url_for("auth.login")) + + return render_template("register.html") + + +@auth_bp.route("/logout") +def logout(): + session.clear() + return redirect(url_for("auth.login")) diff --git a/docker-compose.meal.yml b/docker-compose.meal.yml new file mode 100644 index 0000000..643c664 --- /dev/null +++ b/docker-compose.meal.yml @@ -0,0 +1,15 @@ +services: + meal-tracker: + build: + context: . + dockerfile: Dockerfile.meal + ports: + - "5000:5000" + volumes: + - meal-data:/data + environment: + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + restart: unless-stopped + +volumes: + meal-data: diff --git a/meals.py b/meals.py new file mode 100644 index 0000000..8d895a6 --- /dev/null +++ b/meals.py @@ -0,0 +1,119 @@ +"""Meals blueprint — dashboard, responses, history.""" + +from datetime import date, datetime, timedelta + +from flask import ( + Blueprint, + render_template, + request, + redirect, + url_for, + session, + flash, + jsonify, +) + +from models import get_dashboard_data, upsert_response, ensure_meal_periods + +meals_bp = Blueprint("meals", __name__) + + +def login_required(f): + """Decorator: redirect to login if not authenticated.""" + from functools import wraps + + @wraps(f) + def wrapper(*args, **kwargs): + if "user_id" not in session: + return redirect(url_for("auth.login")) + return f(*args, **kwargs) + + return wrapper + + +@meals_bp.route("/") +def index(): + if "user_id" in session: + return redirect(url_for("meals.dashboard")) + return redirect(url_for("auth.login")) + + +@meals_bp.route("/dashboard") +@login_required +def dashboard(): + today = date.today() + + # Allow viewing other dates via query param + date_str = request.args.get("date") + if date_str: + try: + today = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + flash("Invalid date format. Use YYYY-MM-DD.", "error") + + data = get_dashboard_data(today) + return render_template( + "dashboard.html", + dashboard=data, + viewing_date=today, + today=date.today(), + prev_date=today - timedelta(days=1), + next_date=today + timedelta(days=1), + ) + + +@meals_bp.route("/respond", methods=["POST"]) +@login_required +def respond(): + meal_type = request.form.get("meal_type", "").strip() + status = request.form.get("status", "").strip() + date_str = request.form.get("date", date.today().isoformat()) + + if meal_type not in ("lunch", "dinner"): + flash("Invalid meal type.", "error") + return redirect(url_for("meals.dashboard")) + + if status not in ("yes", "no"): + flash("Invalid status.", "error") + return redirect(url_for("meals.dashboard")) + + try: + target_date = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + flash("Invalid date.", "error") + return redirect(url_for("meals.dashboard")) + + # Only allow responding to today (and not in the past) + if target_date < date.today(): + flash("Cannot change responses for past dates.", "error") + return redirect(url_for("meals.dashboard")) + + periods = ensure_meal_periods(target_date) + period = next((p for p in periods if p["meal_type"] == meal_type), None) + if period is None: + flash("Meal period not found.", "error") + return redirect(url_for("meals.dashboard")) + + upsert_response(session["user_id"], period["id"], status) + return redirect(url_for("meals.dashboard")) + + +@meals_bp.route("/history") +@login_required +def history(): + date_str = request.args.get("date", date.today().isoformat()) + try: + target_date = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + target_date = date.today() + + data = get_dashboard_data(target_date) + return render_template( + "dashboard.html", + dashboard=data, + viewing_date=target_date, + today=date.today(), + prev_date=target_date - timedelta(days=1), + next_date=target_date + timedelta(days=1), + history_mode=True, + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..5d6a8a3 --- /dev/null +++ b/models.py @@ -0,0 +1,229 @@ +"""Database models and helpers for the meal tracker.""" + +import sqlite3 +import os +from datetime import date, datetime + +def _db_path() -> str: + return os.environ.get( + "DB_PATH", os.path.join(os.path.dirname(__file__), "meals.db") + ) + + +def get_db() -> sqlite3.Connection: + """Return a connection to the SQLite database with foreign keys enabled.""" + conn = sqlite3.connect(_db_path()) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db() -> None: + """Create tables if they don't exist.""" + with get_db() as db: + db.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS meal_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + meal_type TEXT NOT NULL CHECK(meal_type IN ('lunch', 'dinner')), + UNIQUE(date, meal_type) + ); + + CREATE TABLE IF NOT EXISTS responses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + period_id INTEGER NOT NULL REFERENCES meal_periods(id), + status TEXT NOT NULL DEFAULT 'not_answered' + CHECK(status IN ('yes', 'no', 'not_answered')), + changed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, period_id) + ); + + CREATE INDEX IF NOT EXISTS idx_responses_user + ON responses(user_id); + CREATE INDEX IF NOT EXISTS idx_responses_period + ON responses(period_id); + CREATE INDEX IF NOT EXISTS idx_meal_periods_date + ON meal_periods(date); + """) + + +def ensure_meal_periods(for_date: date) -> list[dict]: + """Ensure lunch and dinner periods exist for a given date. + + Returns the two period rows (as dicts) for that date. + """ + date_str = for_date.isoformat() + with get_db() as db: + db.execute( + "INSERT OR IGNORE INTO meal_periods (date, meal_type) VALUES (?, 'lunch')", + (date_str,), + ) + db.execute( + "INSERT OR IGNORE INTO meal_periods (date, meal_type) VALUES (?, 'dinner')", + (date_str,), + ) + rows = db.execute( + "SELECT id, date, meal_type FROM meal_periods WHERE date = ? ORDER BY meal_type", + (date_str,), + ).fetchall() + return [dict(r) for r in rows] + + +def get_user_by_id(user_id: int) -> dict | None: + """Fetch a single user by ID.""" + with get_db() as db: + row = db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return dict(row) if row else None + + +def get_user_by_username(username: str) -> dict | None: + """Fetch a single user by username.""" + with get_db() as db: + row = db.execute( + "SELECT * FROM users WHERE username = ?", (username,) + ).fetchone() + return dict(row) if row else None + + +def create_user(username: str, password_hash: str, is_admin: bool = False) -> dict: + """Insert a new user. Returns the user row as a dict.""" + with get_db() as db: + cur = db.execute( + "INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)", + (username, password_hash, int(is_admin)), + ) + user_id = cur.lastrowid + db.commit() # Make visible to get_user_by_id (opens a new connection) + return get_user_by_id(user_id) + + +def get_all_users() -> list[dict]: + """Return all registered users.""" + with get_db() as db: + rows = db.execute( + "SELECT id, username, is_admin, created_at FROM users ORDER BY username" + ).fetchall() + return [dict(r) for r in rows] + + +def count_users() -> int: + """Return total number of registered users.""" + with get_db() as db: + return db.execute("SELECT COUNT(*) FROM users").fetchone()[0] + + +def get_dashboard_data(for_date: date) -> dict: + """Return all users and their responses for a given date. + + Returns: + { + "date": "YYYY-MM-DD", + "periods": [{"id": 1, "date": "...", "meal_type": "lunch"}, ...], + "users": [ + { + "id": 1, + "username": "alice", + "responses": { + "lunch": {"status": "yes", "changed_at": None}, + "dinner": {"status": "no", "changed_at": "2025-01-15 16:30:00"}, + } + }, + ... + ] + } + """ + periods = ensure_meal_periods(for_date) + users = get_all_users() + + with get_db() as db: + period_ids = [p["id"] for p in periods] + if not period_ids: + return {"date": for_date.isoformat(), "periods": periods, "users": []} + + placeholders = ",".join("?" * len(period_ids)) + resp_rows = db.execute( + f"SELECT user_id, period_id, status, changed_at " + f"FROM responses " + f"WHERE period_id IN ({placeholders})", + period_ids, + ).fetchall() + + # Build a lookup: (user_id, period_id) → response + resp_map = {} + for r in resp_rows: + resp_map[(r["user_id"], r["period_id"])] = { + "status": r["status"], + "changed_at": r["changed_at"], + } + + period_map = {p["meal_type"]: p["id"] for p in periods} + + enriched_users = [] + for u in users: + user_responses = {} + for meal_type, pid in period_map.items(): + key = (u["id"], pid) + if key in resp_map: + user_responses[meal_type] = resp_map[key] + else: + user_responses[meal_type] = {"status": "not_answered", "changed_at": None} + enriched_users.append({**u, "responses": user_responses}) + + return { + "date": for_date.isoformat(), + "periods": periods, + "users": enriched_users, + } + + +def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | None: + """Insert or update a response. Handles changed_at logic. + + changed_at is set when flipping between 'yes' and 'no'. + It stays NULL on first answer or when status doesn't change. + """ + with get_db() as db: + existing = db.execute( + "SELECT status, changed_at FROM responses WHERE user_id = ? AND period_id = ?", + (user_id, period_id), + ).fetchone() + + if existing is None: + # First response ever + db.execute( + "INSERT INTO responses (user_id, period_id, status) VALUES (?, ?, ?)", + (user_id, period_id, new_status), + ) + elif existing["status"] == new_status: + # No change — nothing to update + pass + elif existing["status"] in ("yes", "no") and new_status in ("yes", "no"): + # Flipping between yes/no → mind changed + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + db.execute( + "UPDATE responses SET status = ?, changed_at = ? " + "WHERE user_id = ? AND period_id = ?", + (new_status, now, user_id, period_id), + ) + else: + # Transition from 'not_answered' → 'yes'/'no' (first answer) + db.execute( + "UPDATE responses SET status = ? WHERE user_id = ? AND period_id = ?", + (new_status, user_id, period_id), + ) + + row = db.execute( + "SELECT * FROM responses WHERE user_id = ? AND period_id = ?", + (user_id, period_id), + ).fetchone() + return dict(row) if row else None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0405816 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +Werkzeug>=3.0 +pytest>=7.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..1d188d7 --- /dev/null +++ b/static/style.css @@ -0,0 +1,178 @@ +/* ── Reset & Base ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f5f5f5; + --surface: #fff; + --text: #222; + --muted: #888; + --border: #ddd; + --green: #2e7d32; + --green-bg: #e8f5e9; + --red: #c62828; + --red-bg: #ffebee; + --orange: #e65100; + --orange-bg: #fff3e0; + --gray-bg: #f0f0f0; + --primary: #1565c0; + --radius: 8px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +/* ── Nav ── */ +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.brand { font-size: 1.2rem; font-weight: 700; } +.nav-right { display: flex; align-items: center; gap: 12px; } +.nav-user { font-weight: 500; color: var(--muted); } + +/* ── Buttons ── */ +.btn { + display: inline-block; + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + cursor: pointer; + font-size: 0.9rem; + text-decoration: none; + color: var(--text); + transition: background 0.15s; +} +.btn:hover { background: #eee; } +.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); } +.btn-primary:hover { background: #0d47a1; } +.btn-sm { padding: 4px 10px; font-size: 0.8rem; } + +/* ── Container ── */ +.container { max-width: 720px; margin: 32px auto; padding: 0 16px; } + +/* ── Forms ── */ +.form-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; + max-width: 380px; + margin: 40px auto; +} +.form-card h2 { margin-bottom: 20px; } +.form-card label { display: block; margin: 12px 0 4px; font-weight: 500; font-size: 0.9rem; } +.form-card input[type="text"], +.form-card input[type="password"] { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 1rem; +} +.form-card button[type="submit"] { margin-top: 20px; width: 100%; } +.form-footer { margin-top: 16px; text-align: center; font-size: 0.85rem; color: var(--muted); } + +/* ── Flashes ── */ +.flashes { margin-bottom: 16px; } +.flash { + padding: 10px 16px; + border-radius: var(--radius); + margin-bottom: 8px; + font-size: 0.9rem; +} +.flash-error { background: #ffebee; color: #c62828; border: 1px solid #ef9a9a; } +.flash-success { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; } + +/* ── Dashboard ── */ +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} +.dashboard-header h2 { font-size: 1.3rem; } +.date-nav { display: flex; align-items: center; gap: 8px; } +.date-display { font-weight: 600; font-size: 0.95rem; } + +.table-wrapper { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow-x: auto; +} +.meal-table { width: 100%; border-collapse: collapse; } +.meal-table th, .meal-table td { + padding: 12px 16px; + text-align: center; + border-bottom: 1px solid var(--border); +} +.meal-table th { + background: #fafafa; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} +.meal-table th:first-child, .meal-table td:first-child { text-align: left; } +.user-cell { font-weight: 600; min-width: 100px; } +.meal-col { min-width: 180px; } + +/* ── Response Cells ── */ +.response-cell { vertical-align: middle; } +.response-cell.status-yes { background: var(--green-bg); } +.response-cell.status-no { background: var(--red-bg); } +.response-cell.status-not_answered { background: var(--gray-bg); } + +.btn-row { display: flex; gap: 6px; justify-content: center; } + +/* ── Chips ── */ +.chip { + padding: 6px 18px; + border: 2px solid var(--border); + border-radius: 20px; + background: var(--surface); + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.15s; +} +.chip:hover:not(:disabled) { border-color: #999; } +.chip:disabled { opacity: 0.4; cursor: not-allowed; } +.chip-yes.active { background: var(--green); color: #fff; border-color: var(--green); } +.chip-no.active { background: var(--red); color: #fff; border-color: var(--red); } + +/* ── Changed-mind badge ── */ +.changed-badge { + font-size: 0.7rem; + color: var(--orange); + font-weight: 600; + margin-top: 4px; +} +.response-cell.changed-mind { + box-shadow: inset 0 0 0 2px var(--orange); +} + +/* ── Static badge (history view for other users) ── */ +.badge { + display: inline-block; + padding: 4px 14px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 500; +} +.badge-yes { background: var(--green); color: #fff; } +.badge-no { background: var(--red); color: #fff; } +.muted { color: var(--muted); } + +.empty-state { text-align: center; color: var(--muted); margin: 40px 0; } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f77deb7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,34 @@ + + +
+ + +No registered users yet. Ask household members to register!
+{% else %} +| User | + {% for period in dashboard.periods %} +{{ period.meal_type|capitalize }} | + {% endfor %} +
|---|---|
| {{ user.username }} | + {% for period in dashboard.periods %} + {% set resp = user.responses[period.meal_type] %} +
+ {% if viewing_date >= today or session.is_admin %}
+
+ {% if resp.changed_at %}
+ ↻ {{ resp.changed_at[11:16] }}
+ {% endif %}
+ {% elif resp.status == 'not_answered' %}
+ —
+ {% else %}
+ {{ 'Home' if resp.status == 'yes' else 'Out' }}
+ {% endif %}
+ |
+ {% endfor %}
+