设置 (services/settings.py, routes/settings.py) - user_settings 表:身高/体重/出生日期/性别/单位/自动同步开关/同步频率/历史范围 - GET|PUT /api/settings,GET /api/settings/options(取值由后端给,前端不臆造) - GET /api/settings/rating-basis:把每条参考区间的来源公开出来。 一个把数字标成「偏低」的区间是在下判断,用户有权看到依据。 运动详情 (services/garmin.py) - GET /api/garmin/activities/<id>/detail:概览/分段/心率区间/天气/装备/采样曲线 - 首次打开回源 Garmin 并落库,之后走缓存;?refresh=1 强制刷新 - 采样点在写入时抽稀到 300,手机图表画不了更多,也免得整行撑大 身体年龄 (services/fitness_age.py) - 0.2.8 版 garminconnect 没有 fitnessage 接口,改为本地按公开常模推算: VO₂max 对应年龄为基准,静息心率与 BMI 做有上限的修正 - 返回每一步的中间值,界面照实展示,不做成一个不可追溯的分数 - 高于参考表最年轻一档时按 20 岁计——那里外推会得到「11 岁」这种结果 调度器改为每 5 分钟 tick,是否该同步按各账号自己的频率判断 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
214 lines
7.0 KiB
Python
214 lines
7.0 KiB
Python
"""
|
|
Per-user profile and preferences.
|
|
|
|
Two unrelated things share one table because they share a lifetime and an
|
|
edit screen: body measurements (height, weight, birth date, sex) that the
|
|
rating bands and the fitness-age estimate read, and preferences (units, how
|
|
often to sync, how far back to pull).
|
|
|
|
Every value is optional. An account with an empty profile still works — it
|
|
just falls back to general-population bands instead of personalised ones.
|
|
"""
|
|
import datetime
|
|
|
|
from db import execute, query_one
|
|
from config import DB_TYPE
|
|
|
|
# Only these are accepted from a request body; anything else is ignored rather
|
|
# than rejected, so an older client posting an unknown field still saves.
|
|
FIELDS = (
|
|
"height_cm", "weight_kg", "birth_date", "sex", "units",
|
|
"auto_sync", "auto_sync_minutes", "history_days",
|
|
)
|
|
|
|
DEFAULTS = {
|
|
"height_cm": None,
|
|
"weight_kg": None,
|
|
"birth_date": None,
|
|
"sex": None,
|
|
"units": "metric",
|
|
"auto_sync": 1,
|
|
"auto_sync_minutes": 60,
|
|
# How far back a full sync reaches. 0 means "everything Garmin has".
|
|
"history_days": 365,
|
|
}
|
|
|
|
SEXES = ("male", "female", "other")
|
|
UNITS = ("metric", "imperial")
|
|
# Offered in the UI as a picker; anything else is snapped to the nearest.
|
|
INTERVALS = (30, 60, 180, 360, 720, 1440)
|
|
HISTORY = (7, 30, 90, 180, 365, 730, 0)
|
|
|
|
CAMEL = {
|
|
"height_cm": "heightCm",
|
|
"weight_kg": "weightKg",
|
|
"birth_date": "birthDate",
|
|
"sex": "sex",
|
|
"units": "units",
|
|
"auto_sync": "autoSync",
|
|
"auto_sync_minutes": "autoSyncMinutes",
|
|
"history_days": "historyDays",
|
|
}
|
|
|
|
|
|
class InvalidSetting(ValueError):
|
|
"""A value the UI should not have sent; the message is user-facing."""
|
|
|
|
|
|
def _number(value, low, high, label):
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
n = float(value)
|
|
except (TypeError, ValueError):
|
|
raise InvalidSetting(f"{label}必须是数字")
|
|
if not low <= n <= high:
|
|
raise InvalidSetting(f"{label}应在 {low:g}~{high:g} 之间")
|
|
return n
|
|
|
|
|
|
def _date(value, label):
|
|
if not value:
|
|
return None
|
|
text = str(value)[:10]
|
|
try:
|
|
d = datetime.date.fromisoformat(text)
|
|
except ValueError:
|
|
raise InvalidSetting(f"{label}格式应为 YYYY-MM-DD")
|
|
today = datetime.date.today()
|
|
if not 0 < (today - d).days < 365 * 120:
|
|
raise InvalidSetting(f"{label}看起来不对")
|
|
return text
|
|
|
|
|
|
def _choice(value, allowed, label, default=None):
|
|
if value is None or value == "":
|
|
return default
|
|
if value not in allowed:
|
|
raise InvalidSetting(f"{label}只能是 {'/'.join(map(str, allowed))}")
|
|
return value
|
|
|
|
|
|
def _snap(value, allowed, default):
|
|
"""Nearest allowed number rather than an error.
|
|
|
|
The interval and history controls are pickers, so an off-list value means
|
|
a stale client, not a user mistake — snapping keeps them working.
|
|
"""
|
|
if value is None or value == "":
|
|
return default
|
|
try:
|
|
n = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
if n in allowed:
|
|
return n
|
|
if n <= 0:
|
|
return 0 if 0 in allowed else default
|
|
return min((a for a in allowed if a > 0), key=lambda a: abs(a - n))
|
|
|
|
|
|
def clean(patch):
|
|
"""Validate an incoming patch into storable columns."""
|
|
out = {}
|
|
if "heightCm" in patch or "height_cm" in patch:
|
|
out["height_cm"] = _number(
|
|
patch.get("heightCm", patch.get("height_cm")), 80, 250, "身高"
|
|
)
|
|
if "weightKg" in patch or "weight_kg" in patch:
|
|
out["weight_kg"] = _number(
|
|
patch.get("weightKg", patch.get("weight_kg")), 25, 300, "体重"
|
|
)
|
|
if "birthDate" in patch or "birth_date" in patch:
|
|
out["birth_date"] = _date(
|
|
patch.get("birthDate", patch.get("birth_date")), "出生日期"
|
|
)
|
|
if "sex" in patch:
|
|
out["sex"] = _choice(patch.get("sex"), SEXES, "性别")
|
|
if "units" in patch:
|
|
out["units"] = _choice(patch.get("units"), UNITS, "单位", "metric")
|
|
if "autoSync" in patch or "auto_sync" in patch:
|
|
out["auto_sync"] = 1 if patch.get("autoSync", patch.get("auto_sync")) else 0
|
|
if "autoSyncMinutes" in patch or "auto_sync_minutes" in patch:
|
|
out["auto_sync_minutes"] = _snap(
|
|
patch.get("autoSyncMinutes", patch.get("auto_sync_minutes")),
|
|
INTERVALS, DEFAULTS["auto_sync_minutes"],
|
|
)
|
|
if "historyDays" in patch or "history_days" in patch:
|
|
out["history_days"] = _snap(
|
|
patch.get("historyDays", patch.get("history_days")),
|
|
HISTORY, DEFAULTS["history_days"],
|
|
)
|
|
return out
|
|
|
|
|
|
def get_raw(user_id):
|
|
"""Settings as column names, with defaults filled in for missing rows."""
|
|
row = query_one("SELECT * FROM user_settings WHERE user_id = ?", [user_id])
|
|
merged = dict(DEFAULTS)
|
|
if row:
|
|
for key in FIELDS:
|
|
value = row.get(key)
|
|
if value is not None:
|
|
merged[key] = value
|
|
# DATE comes back as a date object from MariaDB and a string from SQLite.
|
|
if merged["birth_date"] is not None:
|
|
merged["birth_date"] = str(merged["birth_date"])[:10]
|
|
return merged
|
|
|
|
|
|
def get_settings(user_id):
|
|
"""Settings in the camelCase the UI consumes, plus derived age."""
|
|
raw = get_raw(user_id)
|
|
out = {CAMEL[k]: raw[k] for k in FIELDS}
|
|
out["autoSync"] = bool(raw["auto_sync"])
|
|
out["age"] = age_from(raw["birth_date"])
|
|
out["bmi"] = bmi_from(raw["height_cm"], raw["weight_kg"])
|
|
return out
|
|
|
|
|
|
def save_settings(user_id, patch):
|
|
changes = clean(patch)
|
|
if not changes:
|
|
return get_settings(user_id)
|
|
|
|
current = get_raw(user_id)
|
|
current.update(changes)
|
|
current["updated_at"] = datetime.datetime.utcnow().isoformat(timespec="seconds")
|
|
|
|
cols = ["user_id"] + list(FIELDS) + ["updated_at"]
|
|
values = [user_id] + [current[k] for k in FIELDS] + [current["updated_at"]]
|
|
placeholders = ", ".join(["?"] * len(cols))
|
|
updatable = [c for c in cols if c != "user_id"]
|
|
if DB_TYPE == "mariadb":
|
|
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
|
|
sql = (f"INSERT INTO user_settings ({', '.join(cols)}) "
|
|
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}")
|
|
else:
|
|
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
|
|
sql = (f"INSERT INTO user_settings ({', '.join(cols)}) "
|
|
f"VALUES ({placeholders}) ON CONFLICT(user_id) DO UPDATE SET {updates}")
|
|
execute(sql, values)
|
|
return get_settings(user_id)
|
|
|
|
|
|
def age_from(birth_date):
|
|
if not birth_date:
|
|
return None
|
|
try:
|
|
born = datetime.date.fromisoformat(str(birth_date)[:10])
|
|
except ValueError:
|
|
return None
|
|
today = datetime.date.today()
|
|
# Subtract a year when this year's birthday has not happened yet.
|
|
return today.year - born.year - (
|
|
(today.month, today.day) < (born.month, born.day)
|
|
)
|
|
|
|
|
|
def bmi_from(height_cm, weight_kg):
|
|
if not height_cm or not weight_kg:
|
|
return None
|
|
metres = float(height_cm) / 100
|
|
return round(float(weight_kg) / (metres * metres), 1)
|