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:
parent
9636d6870e
commit
6d1de53b2e
6 changed files with 181 additions and 2 deletions
38
db.py
38
db.py
|
|
@ -80,6 +80,12 @@ def init_db():
|
||||||
ON events(user_id, created_at);
|
ON events(user_id, created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_kind_created
|
CREATE INDEX IF NOT EXISTS idx_events_kind_created
|
||||||
ON events(kind, created_at);
|
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
|
# Migrations
|
||||||
|
|
@ -373,6 +379,38 @@ def get_events(
|
||||||
return out
|
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:
|
def save_feedback(user_id: int, text: str) -> int:
|
||||||
"""Save user feedback. Returns the feedback id."""
|
"""Save user feedback. Returns the feedback id."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
|
||||||
23
server.py
23
server.py
|
|
@ -17,7 +17,7 @@ from urllib.parse import parse_qs
|
||||||
|
|
||||||
from aiohttp import web
|
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
|
from parser import parse_workout, format_workout
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -302,6 +302,25 @@ async def api_version(request: web.Request):
|
||||||
return web.json_response({"version": _VERSION})
|
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
|
@require_auth
|
||||||
async def api_log_event(request: web.Request):
|
async def api_log_event(request: web.Request):
|
||||||
"""Record a client-emitted event (Mini App telemetry)."""
|
"""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/export/csv", api_export_csv)
|
||||||
app.router.add_get("/api/version", api_version)
|
app.router.add_get("/api/version", api_version)
|
||||||
app.router.add_post("/api/events", api_log_event)
|
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
|
# Serve the webapp/ folder
|
||||||
webapp_dir = pathlib.Path(__file__).parent / "webapp"
|
webapp_dir = pathlib.Path(__file__).parent / "webapp"
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,40 @@ class TestEvents:
|
||||||
assert result == -1
|
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 ───────────────────────────────────────────────
|
# ── update_workout ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,12 @@ let currentExercise = null;
|
||||||
let editingWorkoutId = null; // non-null when editing a saved workout
|
let editingWorkoutId = null; // non-null when editing a saved workout
|
||||||
let lastSetAt = null; // ms-epoch of most recent addSet, or null
|
let lastSetAt = null; // ms-epoch of most recent addSet, or null
|
||||||
let restTimerInterval = 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 ──────────────────────────────────────────────────
|
// ── Rest timer ──────────────────────────────────────────────────
|
||||||
function _fmtRest(ms) {
|
function _fmtRest(ms) {
|
||||||
|
|
@ -216,7 +222,7 @@ function updateRestTimer() {
|
||||||
const el = document.getElementById("rest-timer");
|
const el = document.getElementById("rest-timer");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const setCount = setsList ? setsList.querySelectorAll(".set-entry").length : 0;
|
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");
|
el.classList.add("hidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -883,6 +889,40 @@ function fmtWeight(w) {
|
||||||
return w === Math.floor(w) ? Math.floor(w).toString() : w.toString();
|
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 ───────────────────────────────────────────────
|
// ── Version badge ───────────────────────────────────────────────
|
||||||
async function loadVersion() {
|
async function loadVersion() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -901,6 +941,7 @@ async function init() {
|
||||||
loadVersion();
|
loadVersion();
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
logEvent("miniapp.open");
|
logEvent("miniapp.open");
|
||||||
|
loadSettings(); // fire-and-forget; UI updates when ready
|
||||||
try {
|
try {
|
||||||
const data = await api("GET", "/exercises");
|
const data = await api("GET", "/exercises");
|
||||||
knownExercises = data.exercises || [];
|
knownExercises = data.exercises || [];
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<button class="tab active" data-view="log">Log</button>
|
<button class="tab active" data-view="log">Log</button>
|
||||||
<button class="tab" data-view="history">History</button>
|
<button class="tab" data-view="history">History</button>
|
||||||
<button class="tab" data-view="stats">Stats</button>
|
<button class="tab" data-view="stats">Stats</button>
|
||||||
|
<button class="tab" data-view="settings">Settings</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- ═══ LOG VIEW ═══ -->
|
<!-- ═══ LOG VIEW ═══ -->
|
||||||
|
|
@ -92,6 +93,19 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<footer id="app-footer">
|
||||||
<span id="version-badge"></span>
|
<span id="version-badge"></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,37 @@ details[open] .raw-toggle::before {
|
||||||
opacity: 1;
|
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 ──────────────────────────────────── */
|
/* ── Footer / version badge ──────────────────────────────────── */
|
||||||
|
|
||||||
#app-footer {
|
#app-footer {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue