Spaces:
Sleeping
Sleeping
| """ | |
| utils/db.py β All Supabase + SQLite database operations for FitPlan Pro. | |
| Handles: plans, plan_days, daily_progress, streaks. | |
| Falls back to SQLite if Supabase is not configured. | |
| """ | |
| import os, json, time, uuid, sqlite3 | |
| import requests | |
| SUPABASE_URL = os.getenv("SUPABASE_URL", "").strip() | |
| SUPABASE_KEY = os.getenv("SUPABASE_KEY", "").strip() | |
| DB_PATH = "/tmp/fitplan_plans.db" | |
| USE_SUPABASE = bool(SUPABASE_URL and SUPABASE_KEY) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SQLite setup (fallback) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_sqlite(): | |
| """Create tables if they don't exist.""" | |
| conn = sqlite3.connect(DB_PATH) | |
| c = conn.cursor() | |
| c.execute(""" | |
| CREATE TABLE IF NOT EXISTS plans ( | |
| plan_id TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| created_at REAL, | |
| dietary_type TEXT, | |
| total_days INTEGER, | |
| is_active INTEGER DEFAULT 1 | |
| ) | |
| """) | |
| c.execute(""" | |
| CREATE TABLE IF NOT EXISTS plan_days ( | |
| id TEXT PRIMARY KEY, | |
| plan_id TEXT, | |
| day_number INTEGER, | |
| muscle_group TEXT, | |
| is_rest_day INTEGER DEFAULT 0, | |
| workout_json TEXT, | |
| dietary_json TEXT, | |
| pre_stretch_json TEXT, | |
| post_stretch_json TEXT | |
| ) | |
| """) | |
| # Add columns if upgrading existing DB | |
| try: | |
| c.execute("ALTER TABLE plan_days ADD COLUMN muscle_group TEXT DEFAULT 'Full Body'") | |
| except Exception: | |
| pass | |
| try: | |
| c.execute("ALTER TABLE plan_days ADD COLUMN is_rest_day INTEGER DEFAULT 0") | |
| except Exception: | |
| pass | |
| c.execute(""" | |
| CREATE TABLE IF NOT EXISTS daily_progress ( | |
| id TEXT PRIMARY KEY, | |
| username TEXT, | |
| plan_id TEXT, | |
| day_number INTEGER, | |
| date TEXT, | |
| workout_checks TEXT, | |
| dietary_checks TEXT, | |
| day_completed INTEGER DEFAULT 0, | |
| completed_at REAL | |
| ) | |
| """) | |
| c.execute(""" | |
| CREATE TABLE IF NOT EXISTS streaks ( | |
| username TEXT PRIMARY KEY, | |
| current_streak INTEGER DEFAULT 0, | |
| longest_streak INTEGER DEFAULT 0, | |
| last_completed_date TEXT, | |
| streak_history TEXT DEFAULT '[]', | |
| opted_in INTEGER DEFAULT 0, | |
| display_name TEXT DEFAULT '', | |
| goal TEXT DEFAULT '' | |
| ) | |
| """) | |
| for _col_def in [ | |
| "ALTER TABLE streaks ADD COLUMN opted_in INTEGER DEFAULT 0", | |
| "ALTER TABLE streaks ADD COLUMN display_name TEXT DEFAULT ''", | |
| "ALTER TABLE streaks ADD COLUMN goal TEXT DEFAULT ''", | |
| ]: | |
| try: c.execute(_col_def) | |
| except Exception: pass | |
| conn.commit() | |
| conn.close() | |
| _init_sqlite() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Helpers | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _headers(): | |
| return { | |
| "apikey": SUPABASE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_KEY}", | |
| "Content-Type": "application/json", | |
| "Prefer": "return=representation" | |
| } | |
| def _sb_get(table, filters=""): | |
| """GET from Supabase.""" | |
| r = requests.get( | |
| f"{SUPABASE_URL}/rest/v1/{table}?{filters}", | |
| headers=_headers(), timeout=10 | |
| ) | |
| return r.json() if r.ok else [] | |
| def _sb_post(table, data, upsert=False): | |
| """POST / upsert to Supabase.""" | |
| h = _headers() | |
| if upsert: | |
| h["Prefer"] = "resolution=merge-duplicates,return=representation" | |
| r = requests.post( | |
| f"{SUPABASE_URL}/rest/v1/{table}", | |
| headers=h, json=data, timeout=10 | |
| ) | |
| return r.ok | |
| def _sb_patch(table, filters, data): | |
| """PATCH (update) Supabase rows.""" | |
| r = requests.patch( | |
| f"{SUPABASE_URL}/rest/v1/{table}?{filters}", | |
| headers=_headers(), json=data, timeout=10 | |
| ) | |
| return r.ok | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PLAN OPERATIONS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_plan(username, dietary_type, total_days, days_data): | |
| """ | |
| Save a newly generated plan + all day data. | |
| Deactivates any previous active plan for this user. | |
| Returns: plan_id (str) | |
| """ | |
| plan_id = str(uuid.uuid4()) | |
| if USE_SUPABASE: | |
| # Deactivate old plans | |
| _sb_patch("plans", f"username=eq.{username}&is_active=eq.true", | |
| {"is_active": False}) | |
| # Insert new plan | |
| _sb_post("plans", { | |
| "plan_id": plan_id, | |
| "username": username, | |
| "created_at": time.time(), | |
| "dietary_type": dietary_type, | |
| "total_days": total_days, | |
| "is_active": True | |
| }) | |
| # Insert each day | |
| for day_num, day in enumerate(days_data, 1): | |
| _sb_post("plan_days", { | |
| "id": str(uuid.uuid4()), | |
| "plan_id": plan_id, | |
| "day_number": day_num, | |
| "muscle_group": day.get("muscle_group", "Full Body"), | |
| "is_rest_day": day.get("is_rest_day", False), | |
| "workout_json": json.dumps(day.get("workout", [])), | |
| "dietary_json": json.dumps(day.get("dietary", {})), | |
| "pre_stretch_json": json.dumps(day.get("pre_stretch", [])), | |
| "post_stretch_json": json.dumps(day.get("post_stretch", [])) | |
| }) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| c = conn.cursor() | |
| # Deactivate old plans | |
| c.execute("UPDATE plans SET is_active=0 WHERE username=?", (username,)) | |
| # Insert new plan | |
| c.execute( | |
| "INSERT INTO plans VALUES (?,?,?,?,?,?)", | |
| (plan_id, username, time.time(), dietary_type, total_days, 1) | |
| ) | |
| # Insert each day (with muscle_group and is_rest_day) | |
| for day_num, day in enumerate(days_data, 1): | |
| c.execute( | |
| "INSERT INTO plan_days VALUES (?,?,?,?,?,?,?,?,?)", | |
| ( | |
| str(uuid.uuid4()), plan_id, day_num, | |
| day.get("muscle_group", "Full Body"), | |
| int(day.get("is_rest_day", False)), | |
| json.dumps(day.get("workout", [])), | |
| json.dumps(day.get("dietary", {})), | |
| json.dumps(day.get("pre_stretch", [])), | |
| json.dumps(day.get("post_stretch", [])) | |
| ) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| return plan_id | |
| def get_active_plan(username): | |
| """ | |
| Get user's current active plan with all days. | |
| Returns dict with plan metadata + days list, or None. | |
| """ | |
| if USE_SUPABASE: | |
| plans = _sb_get("plans", | |
| f"username=eq.{username}&is_active=eq.true&limit=1") | |
| if not plans: | |
| return None | |
| plan = plans[0] | |
| days = _sb_get("plan_days", | |
| f"plan_id=eq.{plan['plan_id']}&order=day_number.asc") | |
| plan["days"] = days | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| c = conn.cursor() | |
| c.execute( | |
| "SELECT * FROM plans WHERE username=? AND is_active=1 LIMIT 1", | |
| (username,) | |
| ) | |
| row = c.fetchone() | |
| if not row: | |
| conn.close() | |
| return None | |
| plan = dict(row) | |
| c.execute( | |
| "SELECT * FROM plan_days WHERE plan_id=? ORDER BY day_number ASC", | |
| (plan["plan_id"],) | |
| ) | |
| rows = [dict(r) for r in c.fetchall()] | |
| # Ensure day_number field exists | |
| for row in rows: | |
| row.setdefault("day_number", row.get("day_number", 1)) | |
| plan["days"] = rows | |
| conn.close() | |
| # Add human-readable date | |
| import datetime | |
| try: | |
| ts = plan.get("created_at", 0) | |
| plan["created_at_date"] = datetime.datetime.fromtimestamp(ts).date().isoformat() if ts else datetime.date.today().isoformat() | |
| except Exception: | |
| plan["created_at_date"] = datetime.date.today().isoformat() | |
| # Parse JSON strings in days | |
| for day in plan.get("days", []): | |
| for field in ["workout_json", "dietary_json", | |
| "pre_stretch_json", "post_stretch_json"]: | |
| if isinstance(day.get(field), str): | |
| try: | |
| day[field] = json.loads(day[field]) | |
| except Exception: | |
| day[field] = [] if field != "dietary_json" else {} | |
| return plan | |
| def delete_active_plan(username): | |
| """Deactivate (soft-delete) the user's current plan.""" | |
| if USE_SUPABASE: | |
| _sb_patch("plans", f"username=eq.{username}&is_active=eq.true", | |
| {"is_active": False}) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| "UPDATE plans SET is_active=0 WHERE username=?", (username,) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PROGRESS OPERATIONS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_progress(username, plan_id, day_number, workout_checks, dietary_checks): | |
| """ | |
| Save checkbox progress for a specific day. | |
| Returns: True if day is fully completed, False otherwise. | |
| """ | |
| from datetime import date as _date | |
| today = str(_date.today()) | |
| all_workout = all(workout_checks.values()) if workout_checks else False | |
| all_dietary = all(dietary_checks.values()) if dietary_checks else False | |
| day_completed = all_workout and all_dietary | |
| row_id = f"{username}_{plan_id}_{day_number}" | |
| payload = { | |
| "id": row_id, | |
| "username": username, | |
| "plan_id": plan_id, | |
| "day_number": day_number, | |
| "date": today, | |
| "workout_checks": json.dumps(workout_checks), | |
| "dietary_checks": json.dumps(dietary_checks), | |
| "day_completed": day_completed, | |
| "completed_at": time.time() if day_completed else None | |
| } | |
| if USE_SUPABASE: | |
| _sb_post("daily_progress", payload, upsert=True) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| INSERT OR REPLACE INTO daily_progress | |
| VALUES (?,?,?,?,?,?,?,?,?) | |
| """, ( | |
| row_id, username, plan_id, day_number, today, | |
| json.dumps(workout_checks), json.dumps(dietary_checks), | |
| int(day_completed), | |
| time.time() if day_completed else None | |
| )) | |
| conn.commit() | |
| conn.close() | |
| return day_completed | |
| def get_progress(username, plan_id, day_number): | |
| """ | |
| Get saved checkbox state for a specific day. | |
| Returns dict: {workout_checks, dietary_checks, day_completed} | |
| """ | |
| if USE_SUPABASE: | |
| rows = _sb_get( | |
| "daily_progress", | |
| f"username=eq.{username}&plan_id=eq.{plan_id}" | |
| f"&day_number=eq.{day_number}&limit=1" | |
| ) | |
| row = rows[0] if rows else None | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| c = conn.cursor() | |
| c.execute( | |
| "SELECT * FROM daily_progress WHERE username=? AND plan_id=? AND day_number=?", | |
| (username, plan_id, day_number) | |
| ) | |
| r = c.fetchone() | |
| row = dict(r) if r else None | |
| conn.close() | |
| if not row: | |
| return {"workout_checks": {}, "dietary_checks": {}, "day_completed": False} | |
| return { | |
| "workout_checks": json.loads(row.get("workout_checks") or "{}"), | |
| "dietary_checks": json.loads(row.get("dietary_checks") or "{}"), | |
| "day_completed": bool(row.get("day_completed", False)) | |
| } | |
| def get_all_progress(username, plan_id): | |
| """ | |
| Get progress for ALL days of a plan. | |
| Returns list of progress dicts. | |
| """ | |
| if USE_SUPABASE: | |
| rows = _sb_get( | |
| "daily_progress", | |
| f"username=eq.{username}&plan_id=eq.{plan_id}&order=day_number.asc" | |
| ) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| c = conn.cursor() | |
| c.execute( | |
| "SELECT * FROM daily_progress WHERE username=? AND plan_id=? ORDER BY day_number ASC", | |
| (username, plan_id) | |
| ) | |
| rows = [dict(r) for r in c.fetchall()] | |
| conn.close() | |
| result = [] | |
| for row in rows: | |
| result.append({ | |
| "day_number": row["day_number"], | |
| "workout_checks": json.loads(row.get("workout_checks") or "{}"), | |
| "dietary_checks": json.loads(row.get("dietary_checks") or "{}"), | |
| "day_completed": bool(row.get("day_completed", False)), | |
| "date": row.get("date", "") | |
| }) | |
| return result | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STREAK OPERATIONS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_streak(username): | |
| """ | |
| Get current streak data for a user. | |
| Returns dict: {current_streak, longest_streak, last_completed_date, streak_history} | |
| """ | |
| if USE_SUPABASE: | |
| rows = _sb_get("streaks", f"username=eq.{username}&limit=1") | |
| row = rows[0] if rows else None | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| c = conn.cursor() | |
| c.execute("SELECT * FROM streaks WHERE username=?", (username,)) | |
| r = c.fetchone() | |
| row = dict(r) if r else None | |
| conn.close() | |
| if not row: | |
| return { | |
| "username": username, | |
| "current_streak": 0, | |
| "longest_streak": 0, | |
| "last_completed_date": None, | |
| "streak_history": [] | |
| } | |
| return { | |
| "username": row["username"], | |
| "current_streak": row.get("current_streak", 0), | |
| "longest_streak": row.get("longest_streak", 0), | |
| "last_completed_date": row.get("last_completed_date"), | |
| "streak_history": json.loads(row.get("streak_history") or "[]") | |
| } | |
| def save_streak(streak_data): | |
| """ | |
| Save/update streak data for a user. | |
| streak_data must contain 'username'. | |
| """ | |
| payload = { | |
| "username": streak_data["username"], | |
| "current_streak": streak_data.get("current_streak", 0), | |
| "longest_streak": streak_data.get("longest_streak", 0), | |
| "last_completed_date": streak_data.get("last_completed_date"), | |
| "streak_history": json.dumps(streak_data.get("streak_history", [])), | |
| "opted_in": int(streak_data.get("opted_in", False)), | |
| "display_name": streak_data.get("display_name", ""), | |
| "goal": streak_data.get("goal", ""), | |
| } | |
| if USE_SUPABASE: | |
| _sb_post("streaks", payload, upsert=True) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| INSERT OR REPLACE INTO streaks | |
| (username,current_streak,longest_streak,last_completed_date,streak_history,opted_in,display_name,goal) | |
| VALUES (?,?,?,?,?,?,?,?) | |
| """, ( | |
| payload["username"], payload["current_streak"], payload["longest_streak"], | |
| payload["last_completed_date"], payload["streak_history"], | |
| payload["opted_in"], payload["display_name"], payload["goal"] | |
| )) | |
| conn.commit() | |
| conn.close() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # USER PROFILE OPERATIONS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_profile_table(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS user_profiles ( | |
| username TEXT PRIMARY KEY, | |
| name TEXT, | |
| age INTEGER, | |
| gender TEXT, | |
| height REAL, | |
| weight REAL, | |
| fitness_level TEXT, | |
| goal TEXT, | |
| days_per_week INTEGER, | |
| months INTEGER, | |
| total_days INTEGER, | |
| equipment TEXT, | |
| home_eq TEXT, | |
| gym_eq TEXT, | |
| no_eq INTEGER DEFAULT 0, | |
| updated_at REAL | |
| ) | |
| """) | |
| conn.commit() | |
| conn.close() | |
| _init_profile_table() | |
| def save_user_profile(username, profile_data): | |
| """Save user profile to DB. Upsert.""" | |
| payload = { | |
| "username": username, | |
| "name": profile_data.get("name", ""), | |
| "age": profile_data.get("age", 25), | |
| "gender": profile_data.get("gender", "Male"), | |
| "height": profile_data.get("height", 170), | |
| "weight": profile_data.get("weight", 70), | |
| "fitness_level": profile_data.get("level", "Beginner"), | |
| "goal": profile_data.get("goal", "General Fitness"), | |
| "days_per_week": profile_data.get("days_per_week", 5), | |
| "months": profile_data.get("months", 1), | |
| "total_days": profile_data.get("total_days", 20), | |
| "equipment": json.dumps(profile_data.get("equipment", [])), | |
| "home_eq": json.dumps(profile_data.get("home_eq", [])), | |
| "gym_eq": json.dumps(profile_data.get("gym_eq", [])), | |
| "no_eq": int(profile_data.get("no_eq", False)), | |
| "updated_at": time.time(), | |
| } | |
| if USE_SUPABASE: | |
| import urllib.parse, urllib.request | |
| headers = { | |
| "apikey": SUPABASE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_KEY}", | |
| "Content-Type": "application/json", | |
| "Prefer": "resolution=merge-duplicates,return=representation" | |
| } | |
| data = json.dumps(payload).encode() | |
| req = urllib.request.Request( | |
| f"{SUPABASE_URL}/rest/v1/user_profiles", | |
| data=data, headers=headers, method="POST" | |
| ) | |
| try: | |
| urllib.request.urlopen(req, timeout=10) | |
| except Exception: | |
| pass | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| INSERT OR REPLACE INTO user_profiles | |
| (username,name,age,gender,height,weight,fitness_level,goal, | |
| days_per_week,months,total_days,equipment,home_eq,gym_eq,no_eq,updated_at) | |
| VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) | |
| """, ( | |
| payload["username"], payload["name"], payload["age"], | |
| payload["gender"], payload["height"], payload["weight"], | |
| payload["fitness_level"], payload["goal"], | |
| payload["days_per_week"], payload["months"], payload["total_days"], | |
| payload["equipment"], payload["home_eq"], payload["gym_eq"], | |
| payload["no_eq"], payload["updated_at"] | |
| )) | |
| conn.commit() | |
| conn.close() | |
| def get_user_profile(username): | |
| """Load user profile from DB. Returns dict or None.""" | |
| if USE_SUPABASE: | |
| import urllib.request, urllib.parse | |
| headers = { | |
| "apikey": SUPABASE_KEY, | |
| "Authorization": f"Bearer {SUPABASE_KEY}", | |
| "Content-Type": "application/json", | |
| } | |
| url = f"{SUPABASE_URL}/rest/v1/user_profiles?username=eq.{urllib.parse.quote(username)}&select=*&limit=1" | |
| req = urllib.request.Request(url, headers=headers) | |
| try: | |
| with urllib.request.urlopen(req, timeout=8) as r: | |
| rows = json.loads(r.read().decode()) | |
| row = rows[0] if rows else None | |
| except Exception: | |
| row = None | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| c = conn.cursor() | |
| c.execute("SELECT * FROM user_profiles WHERE username=?", (username,)) | |
| r = c.fetchone() | |
| row = dict(r) if r else None | |
| conn.close() | |
| if not row: | |
| return None | |
| return { | |
| "name": row.get("name", ""), | |
| "age": row.get("age", 25), | |
| "gender": row.get("gender", "Male"), | |
| "height": row.get("height", 170), | |
| "weight": row.get("weight", 70), | |
| "level": row.get("fitness_level", "Beginner"), | |
| "goal": row.get("goal", "General Fitness"), | |
| "days_per_week":row.get("days_per_week", 5), | |
| "months": row.get("months", 1), | |
| "total_days": row.get("total_days", 20), | |
| "equipment": json.loads(row.get("equipment") or "[]"), | |
| "home_eq": json.loads(row.get("home_eq") or "[]"), | |
| "gym_eq": json.loads(row.get("gym_eq") or "[]"), | |
| "no_eq": bool(row.get("no_eq", False)), | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # WEIGHT LOG | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_weight_log(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS weight_log ( | |
| id TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| date TEXT NOT NULL, | |
| weight_kg REAL NOT NULL | |
| ) | |
| """) | |
| conn.commit(); conn.close() | |
| _init_weight_log() | |
| def save_weight_log(username, date_str, weight_kg): | |
| row_id = f"{username}_{date_str}" | |
| if USE_SUPABASE: | |
| _sb_post("weight_log", { | |
| "id": row_id, "username": username, | |
| "date": date_str, "weight_kg": weight_kg | |
| }, upsert=True) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("INSERT OR REPLACE INTO weight_log VALUES (?,?,?,?)", | |
| (row_id, username, date_str, weight_kg)) | |
| conn.commit(); conn.close() | |
| def get_weight_log(username, limit=60): | |
| if USE_SUPABASE: | |
| rows = _sb_get("weight_log", | |
| f"username=eq.{username}&order=date.asc&limit={limit}") | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| rows = [dict(r) for r in conn.execute( | |
| "SELECT * FROM weight_log WHERE username=? ORDER BY date ASC LIMIT ?", | |
| (username, limit)).fetchall()] | |
| conn.close() | |
| return [{"date": r["date"], "weight_kg": r["weight_kg"]} for r in rows] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # WORKOUT NOTES | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_workout_notes(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS workout_notes ( | |
| id TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| plan_id TEXT NOT NULL, | |
| day_number INTEGER NOT NULL, | |
| exercise_idx INTEGER NOT NULL, | |
| note TEXT DEFAULT '' | |
| ) | |
| """) | |
| conn.commit(); conn.close() | |
| _init_workout_notes() | |
| def save_workout_note(username, plan_id, day_number, exercise_idx, note): | |
| row_id = f"{username}_{plan_id}_{day_number}_{exercise_idx}" | |
| if USE_SUPABASE: | |
| _sb_post("workout_notes", { | |
| "id": row_id, "username": username, "plan_id": plan_id, | |
| "day_number": day_number, "exercise_idx": exercise_idx, "note": note | |
| }, upsert=True) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("INSERT OR REPLACE INTO workout_notes VALUES (?,?,?,?,?,?)", | |
| (row_id, username, plan_id, day_number, exercise_idx, note)) | |
| conn.commit(); conn.close() | |
| def get_workout_notes(username, plan_id, day_number): | |
| """Returns dict: {exercise_idx: note_text}""" | |
| if USE_SUPABASE: | |
| rows = _sb_get("workout_notes", | |
| f"username=eq.{username}&plan_id=eq.{plan_id}&day_number=eq.{day_number}") | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| rows = [dict(r) for r in conn.execute( | |
| "SELECT * FROM workout_notes WHERE username=? AND plan_id=? AND day_number=?", | |
| (username, plan_id, day_number)).fetchall()] | |
| conn.close() | |
| return {r["exercise_idx"]: r["note"] for r in rows if r.get("note")} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # WATER TRACKER | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_water_tracker(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS water_tracker ( | |
| id TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| date TEXT NOT NULL, | |
| glasses INTEGER DEFAULT 0 | |
| ) | |
| """) | |
| conn.commit(); conn.close() | |
| _init_water_tracker() | |
| def save_water(username, date_str, glasses): | |
| row_id = f"{username}_{date_str}" | |
| if USE_SUPABASE: | |
| _sb_post("water_tracker", { | |
| "id": row_id, "username": username, | |
| "date": date_str, "glasses": glasses | |
| }, upsert=True) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("INSERT OR REPLACE INTO water_tracker VALUES (?,?,?,?)", | |
| (row_id, username, date_str, glasses)) | |
| conn.commit(); conn.close() | |
| def get_water(username, date_str): | |
| if USE_SUPABASE: | |
| rows = _sb_get("water_tracker", | |
| f"username=eq.{username}&date=eq.{date_str}&limit=1") | |
| return rows[0]["glasses"] if rows else 0 | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| row = conn.execute( | |
| "SELECT glasses FROM water_tracker WHERE username=? AND date=?", | |
| (username, date_str)).fetchone() | |
| conn.close() | |
| return row[0] if row else 0 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PERSONAL RECORDS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_personal_records(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS personal_records ( | |
| id TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| exercise TEXT NOT NULL, | |
| value REAL NOT NULL, | |
| unit TEXT DEFAULT 'reps', | |
| date TEXT NOT NULL, | |
| note TEXT DEFAULT '' | |
| ) | |
| """) | |
| conn.commit(); conn.close() | |
| _init_personal_records() | |
| def save_personal_record(username, exercise, value, unit, date_str, note=""): | |
| row_id = str(uuid.uuid4()) | |
| payload = {"id": row_id, "username": username, "exercise": exercise, | |
| "value": value, "unit": unit, "date": date_str, "note": note} | |
| if USE_SUPABASE: | |
| _sb_post("personal_records", payload) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("INSERT INTO personal_records VALUES (?,?,?,?,?,?,?)", | |
| (row_id, username, exercise, value, unit, date_str, note)) | |
| conn.commit(); conn.close() | |
| def get_personal_records(username): | |
| if USE_SUPABASE: | |
| rows = _sb_get("personal_records", | |
| f"username=eq.{username}&order=date.asc") | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| rows = [dict(r) for r in conn.execute( | |
| "SELECT * FROM personal_records WHERE username=? ORDER BY date ASC", | |
| (username,)).fetchall()] | |
| conn.close() | |
| return rows | |
| def delete_personal_record(record_id): | |
| if USE_SUPABASE: | |
| import requests as _req | |
| _req.delete(f"{SUPABASE_URL}/rest/v1/personal_records?id=eq.{record_id}", | |
| headers=_headers(), timeout=10) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("DELETE FROM personal_records WHERE id=?", (record_id,)) | |
| conn.commit(); conn.close() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # BODY MEASUREMENTS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_measurements(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS body_measurements ( | |
| id TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| date TEXT NOT NULL, | |
| chest REAL, | |
| waist REAL, | |
| hips REAL, | |
| arms REAL, | |
| thighs REAL | |
| ) | |
| """) | |
| conn.commit(); conn.close() | |
| _init_measurements() | |
| def save_measurements(username, date_str, chest, waist, hips, arms, thighs): | |
| row_id = f"{username}_{date_str}" | |
| payload = {"id": row_id, "username": username, "date": date_str, | |
| "chest": chest, "waist": waist, "hips": hips, | |
| "arms": arms, "thighs": thighs} | |
| if USE_SUPABASE: | |
| _sb_post("body_measurements", payload, upsert=True) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("INSERT OR REPLACE INTO body_measurements VALUES (?,?,?,?,?,?,?,?)", | |
| (row_id, username, date_str, chest, waist, hips, arms, thighs)) | |
| conn.commit(); conn.close() | |
| def get_measurements(username, limit=20): | |
| if USE_SUPABASE: | |
| rows = _sb_get("body_measurements", | |
| f"username=eq.{username}&order=date.asc&limit={limit}") | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| rows = [dict(r) for r in conn.execute( | |
| "SELECT * FROM body_measurements WHERE username=? ORDER BY date ASC LIMIT ?", | |
| (username, limit)).fetchall()] | |
| conn.close() | |
| return rows | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CHAT HISTORY | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_chat(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS chat_history ( | |
| id TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| role TEXT NOT NULL, | |
| content TEXT NOT NULL, | |
| created_at REAL | |
| ) | |
| """) | |
| conn.commit(); conn.close() | |
| _init_chat() | |
| def save_chat_message(username, role, content): | |
| row_id = str(uuid.uuid4()) | |
| if USE_SUPABASE: | |
| _sb_post("chat_history", {"id": row_id, "username": username, | |
| "role": role, "content": content, "created_at": time.time()}) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("INSERT INTO chat_history VALUES (?,?,?,?,?)", | |
| (row_id, username, role, content, time.time())) | |
| conn.commit(); conn.close() | |
| def get_chat_history(username, limit=50): | |
| if USE_SUPABASE: | |
| rows = _sb_get("chat_history", | |
| f"username=eq.{username}&order=created_at.asc&limit={limit}") | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| rows = [dict(r) for r in conn.execute( | |
| "SELECT * FROM chat_history WHERE username=? ORDER BY created_at ASC LIMIT ?", | |
| (username, limit)).fetchall()] | |
| conn.close() | |
| return [{"role": r["role"], "content": r["content"]} for r in rows] | |
| def clear_chat_history(username): | |
| if USE_SUPABASE: | |
| import requests as _req | |
| _req.delete(f"{SUPABASE_URL}/rest/v1/chat_history?username=eq.{username}", | |
| headers=_headers(), timeout=10) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("DELETE FROM chat_history WHERE username=?", (username,)) | |
| conn.commit(); conn.close() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SAVE CHAT HISTORY (bulk β replaces per-message save for AI Coach fix #8) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_chat_history(username, messages): | |
| """Save full chat history (list of {role,content} dicts).""" | |
| import json | |
| payload = json.dumps(messages) | |
| if USE_SUPABASE: | |
| _sb_post("user_settings", {"username": username, | |
| "setting_key": "chat_history", "setting_value": payload}) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS user_settings ( | |
| username TEXT, setting_key TEXT, setting_value TEXT, | |
| PRIMARY KEY (username, setting_key)) | |
| """) | |
| conn.execute( | |
| "INSERT OR REPLACE INTO user_settings VALUES (?,?,?)", | |
| (username, "chat_history", payload)) | |
| conn.commit(); conn.close() | |
| def get_chat_history(username, limit=20): | |
| """Load full chat history.""" | |
| import json | |
| val = get_user_setting(username, "chat_history") | |
| if val: | |
| try: | |
| msgs = json.loads(val) | |
| return msgs[-limit:] if msgs else [] | |
| except Exception: | |
| pass | |
| return [] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # USER SETTINGS (key-value store β supplements, preferences, etc.) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_user_settings(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS user_settings ( | |
| username TEXT NOT NULL, | |
| setting_key TEXT NOT NULL, | |
| setting_value TEXT, | |
| updated_at REAL, | |
| PRIMARY KEY (username, setting_key) | |
| ) | |
| """) | |
| conn.commit(); conn.close() | |
| _init_user_settings() | |
| def save_user_setting(username, key, value): | |
| if USE_SUPABASE: | |
| _sb_post("user_settings", { | |
| "username": username, "setting_key": key, | |
| "setting_value": str(value), "updated_at": time.time()}) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| "INSERT OR REPLACE INTO user_settings VALUES (?,?,?,?)", | |
| (username, key, str(value), time.time())) | |
| conn.commit(); conn.close() | |
| def get_user_setting(username, key, default=None): | |
| if USE_SUPABASE: | |
| rows = _sb_get("user_settings", | |
| f"username=eq.{username}&setting_key=eq.{key}&limit=1") | |
| if rows: return rows[0].get("setting_value", default) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| row = conn.execute( | |
| "SELECT setting_value FROM user_settings WHERE username=? AND setting_key=?", | |
| (username, key)).fetchone() | |
| conn.close() | |
| if row: return row[0] | |
| return default | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PROGRESS PHOTOS (Fix #10 β persist photos across sessions) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _init_progress_photos(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS progress_photos ( | |
| id TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| photo_date TEXT, | |
| label TEXT, | |
| b64 TEXT, | |
| mime TEXT, | |
| created_at REAL | |
| ) | |
| """) | |
| conn.commit(); conn.close() | |
| _init_progress_photos() | |
| def save_progress_photo(username, photo_dict): | |
| row_id = str(uuid.uuid4()) | |
| if USE_SUPABASE: | |
| _sb_post("progress_photos", { | |
| "id": row_id, "username": username, | |
| "photo_date": photo_dict.get("date",""), | |
| "label": photo_dict.get("label",""), | |
| "b64": photo_dict.get("b64",""), | |
| "mime": photo_dict.get("mime","image/jpeg"), | |
| "created_at": time.time()}) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("INSERT INTO progress_photos VALUES (?,?,?,?,?,?,?)", | |
| (row_id, username, photo_dict.get("date",""), | |
| photo_dict.get("label",""), photo_dict.get("b64",""), | |
| photo_dict.get("mime","image/jpeg"), time.time())) | |
| conn.commit(); conn.close() | |
| def get_progress_photos(username, limit=50): | |
| if USE_SUPABASE: | |
| rows = _sb_get("progress_photos", | |
| f"username=eq.{username}&order=created_at.asc&limit={limit}") | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| rows = [dict(r) for r in conn.execute( | |
| "SELECT * FROM progress_photos WHERE username=? ORDER BY created_at ASC LIMIT ?", | |
| (username, limit)).fetchall()] | |
| conn.close() | |
| return [{"date":r.get("photo_date",""), | |
| "label":r.get("label",""), | |
| "b64":r.get("b64",""), | |
| "mime":r.get("mime","image/jpeg")} for r in rows] | |
| def delete_progress_photo(username, photo_date, label): | |
| if USE_SUPABASE: | |
| import requests as _req | |
| _req.delete(f"{SUPABASE_URL}/rest/v1/progress_photos" | |
| f"?username=eq.{username}&photo_date=eq.{photo_date}&label=eq.{label}", | |
| headers=_headers(), timeout=10) | |
| else: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute("DELETE FROM progress_photos WHERE username=? AND photo_date=? AND label=?", | |
| (username, photo_date, label)) | |
| conn.commit(); conn.close() |