feat: profile/settings (rest timer toggle)

Settings infrastructure + one working preference:

- New user_settings table (JSON blob per user, so adding
  future keys needs no migration).
- db.get_settings / update_settings helpers (merge semantics).
- GET/PUT /api/settings endpoints.
- New Settings tab in the Mini App with a rest-timer on/off
  toggle. Setting is loaded on init and written through on
  change; the rest-timer display now respects it.

Units (kg/lb) and language are intentionally left unwired for
now — each needs end-to-end display/input changes and deserve
focused passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny 2026-04-19 15:36:06 +02:00
parent 9636d6870e
commit 6d1de53b2e
6 changed files with 181 additions and 2 deletions

38
db.py
View file

@ -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:

View file

@ -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"

View file

@ -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 ───────────────────────────────────────────────

View file

@ -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 || [];

View file

@ -13,6 +13,7 @@
<button class="tab active" data-view="log">Log</button>
<button class="tab" data-view="history">History</button>
<button class="tab" data-view="stats">Stats</button>
<button class="tab" data-view="settings">Settings</button>
</nav>
<!-- ═══ LOG VIEW ═══ -->
@ -92,6 +93,19 @@
</div>
</div>
<!-- ═══ SETTINGS VIEW ═══ -->
<div id="view-settings" class="view">
<div class="card">
<label class="settings-row">
<div class="settings-row-label">
<div class="settings-row-title">Rest timer</div>
<div class="settings-row-hint">Show time since the last set was logged.</div>
</div>
<input type="checkbox" id="setting-rest-timer" class="settings-toggle" checked />
</label>
</div>
</div>
<footer id="app-footer">
<span id="version-badge"></span>
</footer>

View file

@ -486,6 +486,37 @@ details[open] .raw-toggle::before {
opacity: 1;
}
/* ── Settings view ───────────────────────────────────────────── */
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
cursor: pointer;
}
.settings-row-label { flex: 1; }
.settings-row-title {
font-size: 15px;
font-weight: 500;
color: var(--tg-theme-text-color, #000);
}
.settings-row-hint {
font-size: 12px;
color: var(--tg-theme-hint-color, #999);
margin-top: 2px;
}
.settings-toggle {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--tg-theme-button-color, #2481cc);
}
/* ── Footer / version badge ──────────────────────────────────── */
#app-footer {