diff --git a/db.py b/db.py index 595ebf7..5b834c4 100644 --- a/db.py +++ b/db.py @@ -80,6 +80,12 @@ def init_db(): ON events(user_id, created_at); CREATE INDEX IF NOT EXISTS idx_events_kind_created ON events(kind, created_at); + + CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY, + data TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); """) # Migrations @@ -373,6 +379,38 @@ def get_events( return out +def get_settings(user_id: int) -> dict: + """Return the user's settings dict (empty dict if none stored).""" + with get_db() as conn: + row = conn.execute( + "SELECT data FROM user_settings WHERE user_id = ?", (user_id,) + ).fetchone() + if not row: + return {} + try: + return json.loads(row["data"]) or {} + except json.JSONDecodeError: + return {} + + +def update_settings(user_id: int, patch: dict) -> dict: + """Merge `patch` into the user's settings and return the new full dict.""" + if not isinstance(patch, dict): + raise TypeError("patch must be a dict") + current = get_settings(user_id) + current.update(patch) + with get_db() as conn: + conn.execute( + """INSERT INTO user_settings (user_id, data, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + data = excluded.data, + updated_at = excluded.updated_at""", + (user_id, json.dumps(current)), + ) + return current + + def save_feedback(user_id: int, text: str) -> int: """Save user feedback. Returns the feedback id.""" with get_db() as conn: diff --git a/server.py b/server.py index 0f94143..8e0ee77 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ from urllib.parse import parse_qs from aiohttp import web -from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event +from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event, get_settings, update_settings from parser import parse_workout, format_workout logging.basicConfig( @@ -302,6 +302,25 @@ async def api_version(request: web.Request): return web.json_response({"version": _VERSION}) +@require_auth +async def api_get_settings(request: web.Request): + """Return the authenticated user's settings dict.""" + return web.json_response({"settings": get_settings(request["user_id"])}) + + +@require_auth +async def api_update_settings(request: web.Request): + """Merge a partial settings patch; returns the full updated settings.""" + try: + body = await request.json() + except (ValueError, json.JSONDecodeError): + return web.json_response({"error": "Invalid JSON"}, status=400) + if not isinstance(body, dict): + return web.json_response({"error": "Body must be an object"}, status=400) + updated = update_settings(request["user_id"], body) + return web.json_response({"settings": updated}) + + @require_auth async def api_log_event(request: web.Request): """Record a client-emitted event (Mini App telemetry).""" @@ -352,6 +371,8 @@ def create_app() -> web.Application: app.router.add_get("/api/export/csv", api_export_csv) app.router.add_get("/api/version", api_version) app.router.add_post("/api/events", api_log_event) + app.router.add_get("/api/settings", api_get_settings) + app.router.add_put("/api/settings", api_update_settings) # Serve the webapp/ folder webapp_dir = pathlib.Path(__file__).parent / "webapp" diff --git a/tests/test_db.py b/tests/test_db.py index 1660b28..82bfd88 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -309,6 +309,40 @@ class TestEvents: assert result == -1 +# ── user settings ──────────────────────────────────────────────── + + +class TestSettings: + def test_default_empty(self, tmp_db): + assert db.get_settings(1) == {} + + def test_update_creates(self, tmp_db): + result = db.update_settings(1, {"rest_timer": False}) + assert result == {"rest_timer": False} + assert db.get_settings(1) == {"rest_timer": False} + + def test_update_merges(self, tmp_db): + db.update_settings(1, {"rest_timer": False}) + result = db.update_settings(1, {"units": "lb"}) + assert result == {"rest_timer": False, "units": "lb"} + + def test_update_overwrites_key(self, tmp_db): + db.update_settings(1, {"rest_timer": False}) + db.update_settings(1, {"rest_timer": True}) + assert db.get_settings(1)["rest_timer"] is True + + def test_settings_are_per_user(self, tmp_db): + db.update_settings(1, {"rest_timer": False}) + db.update_settings(2, {"rest_timer": True}) + assert db.get_settings(1) == {"rest_timer": False} + assert db.get_settings(2) == {"rest_timer": True} + + def test_patch_must_be_dict(self, tmp_db): + import pytest + with pytest.raises(TypeError): + db.update_settings(1, "not a dict") + + # ── update_workout ─────────────────────────────────────────────── diff --git a/webapp/app.js b/webapp/app.js index b3ae722..82d9f23 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -203,6 +203,12 @@ let currentExercise = null; let editingWorkoutId = null; // non-null when editing a saved workout let lastSetAt = null; // ms-epoch of most recent addSet, or null let restTimerInterval = null; +let settings = { rest_timer: true }; + +function settingEnabled(key, def = true) { + const v = settings[key]; + return v === undefined ? def : !!v; +} // ── Rest timer ────────────────────────────────────────────────── function _fmtRest(ms) { @@ -216,7 +222,7 @@ function updateRestTimer() { const el = document.getElementById("rest-timer"); if (!el) return; const setCount = setsList ? setsList.querySelectorAll(".set-entry").length : 0; - if (lastSetAt === null || !currentExercise || setCount === 0) { + if (!settingEnabled("rest_timer") || lastSetAt === null || !currentExercise || setCount === 0) { el.classList.add("hidden"); return; } @@ -883,6 +889,40 @@ function fmtWeight(w) { return w === Math.floor(w) ? Math.floor(w).toString() : w.toString(); } +// ── Settings ──────────────────────────────────────────────────── +async function loadSettings() { + if (!userId) return; + try { + const data = await api("GET", "/settings"); + settings = { rest_timer: true, ...(data.settings || {}) }; + applySettingsToUI(); + updateRestTimer(); + } catch (e) { + console.error("Failed to load settings", e); + } +} + +function applySettingsToUI() { + const restToggle = document.getElementById("setting-rest-timer"); + if (restToggle) restToggle.checked = settingEnabled("rest_timer"); +} + +async function saveSetting(key, value) { + // Optimistic: update locally first, then sync. + settings[key] = value; + updateRestTimer(); + try { + await api("PUT", "/settings", { [key]: value }); + } catch (e) { + showToast("Could not save setting"); + console.error(e); + } +} + +document.getElementById("setting-rest-timer")?.addEventListener("change", (e) => { + saveSetting("rest_timer", e.target.checked); +}); + // ── Version badge ─────────────────────────────────────────────── async function loadVersion() { try { @@ -901,6 +941,7 @@ async function init() { loadVersion(); if (!userId) return; logEvent("miniapp.open"); + loadSettings(); // fire-and-forget; UI updates when ready try { const data = await api("GET", "/exercises"); knownExercises = data.exercises || []; diff --git a/webapp/index.html b/webapp/index.html index 63d8d80..7e8958d 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -13,6 +13,7 @@ + @@ -92,6 +93,19 @@ + +