feat(api): 个人资料/单位/同步偏好 + 运动详情 + 身体年龄
设置 (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>
This commit is contained in:
@@ -11,7 +11,7 @@ from flask_cors import CORS
|
|||||||
|
|
||||||
import db
|
import db
|
||||||
from config import CORS_ORIGINS, PORT, STATIC_DIR
|
from config import CORS_ORIGINS, PORT, STATIC_DIR
|
||||||
from routes import auth, garmin, health, analysis
|
from routes import auth, garmin, health, analysis, settings
|
||||||
from services import scheduler
|
from services import scheduler
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ def create_app():
|
|||||||
app.register_blueprint(garmin.bp, url_prefix="/api/garmin")
|
app.register_blueprint(garmin.bp, url_prefix="/api/garmin")
|
||||||
app.register_blueprint(health.bp, url_prefix="/api/health")
|
app.register_blueprint(health.bp, url_prefix="/api/health")
|
||||||
app.register_blueprint(analysis.bp, url_prefix="/api/analysis")
|
app.register_blueprint(analysis.bp, url_prefix="/api/analysis")
|
||||||
|
app.register_blueprint(settings.bp, url_prefix="/api/settings")
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found(_e):
|
def not_found(_e):
|
||||||
|
|||||||
@@ -156,6 +156,38 @@ CREATE TABLE IF NOT EXISTS garmin_mfa_sessions (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Per-user profile and preferences.
|
||||||
|
-- Height/weight/birth date/sex are here rather than on `users` because they
|
||||||
|
-- are body measurements the owner edits over time, not identity; and because
|
||||||
|
-- the rating bands and the fitness-age estimate need them, an account without
|
||||||
|
-- them still works, just with fewer personalised readings.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
|
user_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
height_cm DOUBLE,
|
||||||
|
weight_kg DOUBLE,
|
||||||
|
birth_date DATE,
|
||||||
|
sex VARCHAR(16),
|
||||||
|
units VARCHAR(16),
|
||||||
|
auto_sync INT,
|
||||||
|
auto_sync_minutes INT,
|
||||||
|
history_days INT,
|
||||||
|
updated_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Full detail for one activity, exactly as Garmin returned it.
|
||||||
|
-- The list view stores only the summary columns; opening an activity needs
|
||||||
|
-- laps, heart-rate zones and the sampled series, which are far too large to
|
||||||
|
-- carry on every list request. Fetched on demand and kept, so the second
|
||||||
|
-- visit costs nothing and works offline.
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_details (
|
||||||
|
activity_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
payload MEDIUMTEXT,
|
||||||
|
fetched_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
-- One cached LLM answer per user. Generating one takes minutes against a
|
-- One cached LLM answer per user. Generating one takes minutes against a
|
||||||
-- large reasoning model, which is far too slow to sit in a page load, so the
|
-- large reasoning model, which is far too slow to sit in a page load, so the
|
||||||
-- result is stored and reused until the underlying data changes.
|
-- result is stored and reused until the underlying data changes.
|
||||||
|
|||||||
@@ -146,5 +146,27 @@ def sync_latest():
|
|||||||
@bp.route("/auto-sync", methods=["GET"])
|
@bp.route("/auto-sync", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def auto_sync_status():
|
def auto_sync_status():
|
||||||
"""When the scheduler last ran and when it runs next."""
|
"""When the scheduler last ran, and when this account is next due."""
|
||||||
return jsonify(scheduler.status())
|
return jsonify(scheduler.status(g.user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/activities/<activity_id>/detail", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def activity_detail(activity_id):
|
||||||
|
"""Everything Garmin holds for one activity: stats, laps, zones, series.
|
||||||
|
|
||||||
|
The first open goes out to Garmin and takes a few seconds; after that it
|
||||||
|
is served from the stored copy. `?refresh=1` forces a refetch.
|
||||||
|
"""
|
||||||
|
if not garmin_svc.has_token(g.user_id):
|
||||||
|
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
|
||||||
|
|
||||||
|
refresh = request.args.get("refresh") in ("1", "true", "yes")
|
||||||
|
try:
|
||||||
|
return jsonify(
|
||||||
|
garmin_svc.get_activity_detail(g.user_id, activity_id, refresh=refresh)
|
||||||
|
)
|
||||||
|
except garmin_svc.MFARequired as e:
|
||||||
|
return jsonify({"error": str(e)}), 401
|
||||||
|
except Exception as e: # noqa: BLE001 - surfaced to the user verbatim
|
||||||
|
return jsonify({"error": garmin_svc.describe(e)}), 502
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Health data routes: summary / steps / heart-rate / sleep / activities."""
|
"""Health data routes: summary / steps / heart-rate / sleep / activities."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
from flask import Blueprint, request, g, jsonify
|
from flask import Blueprint, request, g, jsonify
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from services import health as health_svc
|
from services import health as health_svc
|
||||||
|
from services import settings as settings_svc
|
||||||
|
from services import fitness_age
|
||||||
|
|
||||||
bp = Blueprint("health", __name__)
|
bp = Blueprint("health", __name__)
|
||||||
|
|
||||||
@@ -46,6 +50,33 @@ def activities():
|
|||||||
return jsonify(health_svc.get_activities(g.user_id, s, e))
|
return jsonify(health_svc.get_activities(g.user_id, s, e))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/fitness-age", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def body_age():
|
||||||
|
"""身体年龄, computed from the profile plus the most recent readings.
|
||||||
|
|
||||||
|
VO2max only refreshes after an outdoor run or ride, so the search window
|
||||||
|
is wide: the last recorded value is still the current one.
|
||||||
|
"""
|
||||||
|
profile = settings_svc.get_raw(g.user_id)
|
||||||
|
start = (datetime.date.today() - datetime.timedelta(days=180)).isoformat()
|
||||||
|
days = health_svc.get_summary(g.user_id, start, None)
|
||||||
|
|
||||||
|
def latest(key):
|
||||||
|
for day in reversed(days):
|
||||||
|
if day.get(key):
|
||||||
|
return day[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
return jsonify(fitness_age.estimate(
|
||||||
|
age=settings_svc.age_from(profile["birth_date"]),
|
||||||
|
sex=profile["sex"],
|
||||||
|
vo2max=latest("vo2max"),
|
||||||
|
resting_hr=latest("heartRate"),
|
||||||
|
bmi=settings_svc.bmi_from(profile["height_cm"], profile["weight_kg"]),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/badges", methods=["GET"])
|
@bp.route("/badges", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def badges():
|
def badges():
|
||||||
|
|||||||
85
backend/routes/settings.py
Normal file
85
backend/routes/settings.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Profile, units and sync preferences."""
|
||||||
|
from flask import Blueprint, request, g, jsonify
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from services import settings as settings_svc
|
||||||
|
from services import fitness_age
|
||||||
|
|
||||||
|
bp = Blueprint("settings", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("", methods=["GET"])
|
||||||
|
@bp.route("/", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def get_settings():
|
||||||
|
return jsonify(settings_svc.get_settings(g.user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("", methods=["PUT"])
|
||||||
|
@bp.route("/", methods=["PUT"])
|
||||||
|
@require_auth
|
||||||
|
def put_settings():
|
||||||
|
try:
|
||||||
|
return jsonify(settings_svc.save_settings(g.user_id, request.json or {}))
|
||||||
|
except settings_svc.InvalidSetting as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/options", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def options():
|
||||||
|
"""The values the pickers may offer, so the UI never invents one the
|
||||||
|
backend would reject."""
|
||||||
|
return jsonify({
|
||||||
|
"sexes": list(settings_svc.SEXES),
|
||||||
|
"units": list(settings_svc.UNITS),
|
||||||
|
"autoSyncMinutes": list(settings_svc.INTERVALS),
|
||||||
|
"historyDays": list(settings_svc.HISTORY),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/rating-basis", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def rating_basis():
|
||||||
|
"""Where every rating in the app comes from.
|
||||||
|
|
||||||
|
Surfaced in 设置 because a band that colours a number 偏低 is making a
|
||||||
|
claim, and the user is entitled to see what that claim rests on.
|
||||||
|
"""
|
||||||
|
return jsonify({
|
||||||
|
"fitnessAge": fitness_age.BASIS,
|
||||||
|
"bands": BAND_SOURCES,
|
||||||
|
"note": (
|
||||||
|
"参考区间用于给数字一个位置感,不是诊断标准。"
|
||||||
|
"AI 只负责解读这些结果,不参与设定任何阈值。"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# Kept beside the UI's RANGES table (client/src/lib/ranges.ts). Each entry says
|
||||||
|
# where that metric's band edges came from — the honest answer for several of
|
||||||
|
# them is "general-population orientation figures", and it says so.
|
||||||
|
BAND_SOURCES = [
|
||||||
|
{"metric": "步数", "bands": "<5k 偏低 · 5–8k 一般 · 8–12k 达标 · >12k 优秀",
|
||||||
|
"source": "步数与死亡率的队列研究(约 8000 步起获益明显,12000 步后趋平)"},
|
||||||
|
{"metric": "静息心率", "bands": "<50 很低 · 50–65 正常 · 65–75 偏高 · >75 较高",
|
||||||
|
"source": "健康成人静息心率 60–100 bpm 为正常范围,规律运动者常低于 60"},
|
||||||
|
{"metric": "心率变异性", "bands": "<25 偏低 · 25–40 一般 · 40–70 良好 · >70 很好",
|
||||||
|
"source": "夜间 RMSSD 的一般人群分布;个体差异极大,趋势比绝对值更有意义"},
|
||||||
|
{"metric": "睡眠时长", "bands": "<6h 不足 · 6–7h 偏少 · 7–9h 充足 · >9h 偏多",
|
||||||
|
"source": "美国睡眠医学会 / 睡眠研究会成人 7–9 小时建议"},
|
||||||
|
{"metric": "压力", "bands": "0–25 休息 · 26–50 偏低 · 51–75 中等 · >75 偏高",
|
||||||
|
"source": "Garmin 官方压力分级,与手表显示一致"},
|
||||||
|
{"metric": "身体电量", "bands": "0–25 很低 · 26–50 偏低 · 51–75 良好 · >75 充足",
|
||||||
|
"source": "Garmin 官方 Body Battery 分级"},
|
||||||
|
{"metric": "血氧", "bands": "<90 偏低 · 90–94 略低 · ≥95 正常",
|
||||||
|
"source": "静息血氧饱和度常用临床参考;腕表光学测量误差较大,仅供趋势参考"},
|
||||||
|
{"metric": "呼吸频率", "bands": "<12 偏低 · 12–20 正常 · >20 偏高",
|
||||||
|
"source": "成人静息呼吸频率 12–20 次/分"},
|
||||||
|
{"metric": "强度分钟", "bands": "<10 偏少 · 10–21 一般 · ≥21 达标",
|
||||||
|
"source": "WHO 每周 150 分钟中等强度活动,折合每天约 21 分钟"},
|
||||||
|
{"metric": "训练准备度", "bands": "0–25 很低 · 26–50 偏低 · 51–75 就绪 · >75 很好",
|
||||||
|
"source": "Garmin 官方 Training Readiness 分级"},
|
||||||
|
{"metric": "爬楼", "bands": "<5 偏少 · 5–10 达标 · >10 优秀",
|
||||||
|
"source": "一般性活动量参考,无权威标准"},
|
||||||
|
]
|
||||||
177
backend/services/fitness_age.py
Normal file
177
backend/services/fitness_age.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""
|
||||||
|
身体年龄 (body age) — a deterministic estimate, with its working exposed.
|
||||||
|
|
||||||
|
This is NOT Garmin's Fitness Age. Garmin's model is proprietary and cannot be
|
||||||
|
reproduced; asking a language model to invent a number would produce something
|
||||||
|
unverifiable that changes between runs while looking authoritative. So the
|
||||||
|
estimate here is computed from published population reference values, and every
|
||||||
|
step it took is returned alongside the number for the UI to display.
|
||||||
|
|
||||||
|
Method
|
||||||
|
------
|
||||||
|
1. Base age from VO2max: the age at which the user's VO2max equals the median
|
||||||
|
for their sex, interpolated over the reference table below. VO2max is the
|
||||||
|
single strongest fitness predictor and is what Garmin's own model leans on.
|
||||||
|
2. Resting-heart-rate adjustment, relative to a 60 bpm reference.
|
||||||
|
3. BMI adjustment, relative to the healthy 18.5–24.9 band.
|
||||||
|
4. Clamped to within 20 years of chronological age — beyond that the
|
||||||
|
extrapolation says more about the table's edges than about the person.
|
||||||
|
|
||||||
|
Reference values are 50th-percentile VO2max (ml/kg/min) by age and sex, from
|
||||||
|
the widely published ACSM / Cooper Institute cardiorespiratory fitness norms.
|
||||||
|
They are population averages for healthy adults, not clinical thresholds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# (age, median VO2max) — men and women tabulated separately because the
|
||||||
|
# distributions differ by roughly 6–8 ml/kg/min at every age.
|
||||||
|
VO2_MEDIAN = {
|
||||||
|
"male": [(25, 44.0), (35, 41.0), (45, 37.0), (55, 33.0), (65, 29.0)],
|
||||||
|
"female": [(25, 37.0), (35, 34.0), (45, 31.0), (55, 27.0), (65, 24.0)],
|
||||||
|
}
|
||||||
|
|
||||||
|
RHR_REFERENCE = 60.0 # bpm
|
||||||
|
RHR_YEARS_PER_10BPM = 2.0
|
||||||
|
RHR_CAP = 5.0
|
||||||
|
|
||||||
|
BMI_LOW, BMI_HIGH = 18.5, 24.9
|
||||||
|
BMI_YEARS_PER_UNIT = 0.5
|
||||||
|
BMI_CAP = 5.0
|
||||||
|
|
||||||
|
MAX_DEVIATION = 20.0 # years either side of chronological age
|
||||||
|
AGE_FLOOR, AGE_CEILING = 20.0, 85.0
|
||||||
|
|
||||||
|
# Rendered verbatim in 设置 → 评分依据. Kept here, next to the constants it
|
||||||
|
# describes, so the two cannot drift apart.
|
||||||
|
BASIS = {
|
||||||
|
"title": "身体年龄的算法",
|
||||||
|
"summary": (
|
||||||
|
"由你的 VO₂max、静息心率、BMI 按公开人群参考值推算,"
|
||||||
|
"不是 Garmin 的 Fitness Age,也不是医学评估。"
|
||||||
|
),
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "基准:VO₂max 对应年龄",
|
||||||
|
"detail": "找出你的 VO₂max 相当于同性别人群哪个年龄的中位水平,"
|
||||||
|
"在参考表上线性插值;高于最年轻一档时按 20 岁计,"
|
||||||
|
"参考表再往上说明不了更多。",
|
||||||
|
"source": "ACSM / Cooper Institute 心肺适能人群常模(50 百分位)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "静息心率修正",
|
||||||
|
"detail": f"以 {RHR_REFERENCE:.0f} bpm 为参照,"
|
||||||
|
f"每高 10 bpm +{RHR_YEARS_PER_10BPM:.0f} 岁,"
|
||||||
|
f"每低 10 bpm −{RHR_YEARS_PER_10BPM:.0f} 岁,"
|
||||||
|
f"最多 ±{RHR_CAP:.0f} 岁。",
|
||||||
|
"source": "静息心率与心肺适能、全因死亡率的流行病学关联",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BMI 修正",
|
||||||
|
"detail": f"BMI 在 {BMI_LOW}~{BMI_HIGH} 之间不修正;"
|
||||||
|
f"每偏离 1 +{BMI_YEARS_PER_UNIT} 岁,最多 +{BMI_CAP:.0f} 岁。",
|
||||||
|
"source": "WHO 成人 BMI 分类",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "收敛",
|
||||||
|
"detail": f"结果限制在实际年龄 ±{MAX_DEVIATION:.0f} 岁以内,"
|
||||||
|
f"并落在 {AGE_FLOOR:.0f}~{AGE_CEILING:.0f} 岁区间。",
|
||||||
|
"source": "参考表边界外的外推不可靠",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"caveat": "仅供长期趋势参考,不能用于诊断。有健康疑问请咨询医生。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _interpolate_age(vo2, table):
|
||||||
|
"""The age whose median VO2max equals `vo2`.
|
||||||
|
|
||||||
|
Above the youngest reference row the answer is simply "fitter than the
|
||||||
|
median 25-year-old", and the table cannot say more: extrapolating its slope
|
||||||
|
there gives absurdities (VO2max 48 reads as an eleven-year-old), so the
|
||||||
|
result floors instead.
|
||||||
|
"""
|
||||||
|
first_age, first_vo2 = table[0]
|
||||||
|
last_age, last_vo2 = table[-1]
|
||||||
|
|
||||||
|
if vo2 >= first_vo2:
|
||||||
|
return AGE_FLOOR
|
||||||
|
if vo2 <= last_vo2:
|
||||||
|
slope = (last_age - table[-2][0]) / (last_vo2 - table[-2][1])
|
||||||
|
return last_age + (vo2 - last_vo2) * slope
|
||||||
|
|
||||||
|
for (age_a, vo2_a), (age_b, vo2_b) in zip(table, table[1:]):
|
||||||
|
if vo2_b <= vo2 <= vo2_a:
|
||||||
|
share = (vo2_a - vo2) / (vo2_a - vo2_b)
|
||||||
|
return age_a + share * (age_b - age_a)
|
||||||
|
return last_age
|
||||||
|
|
||||||
|
|
||||||
|
def estimate(*, age, sex, vo2max, resting_hr=None, bmi=None):
|
||||||
|
"""Body age plus the arithmetic that produced it.
|
||||||
|
|
||||||
|
Returns None when the inputs cannot support an estimate, so the caller can
|
||||||
|
tell the user what is missing instead of showing a fabricated number.
|
||||||
|
"""
|
||||||
|
missing = []
|
||||||
|
if age is None:
|
||||||
|
missing.append("出生日期")
|
||||||
|
if sex not in VO2_MEDIAN:
|
||||||
|
missing.append("性别")
|
||||||
|
if not vo2max:
|
||||||
|
missing.append("VO₂max(需要一次户外跑步或骑行才会生成)")
|
||||||
|
if missing:
|
||||||
|
return {"value": None, "missing": missing, "basis": BASIS}
|
||||||
|
|
||||||
|
table = VO2_MEDIAN[sex]
|
||||||
|
base = _interpolate_age(float(vo2max), table)
|
||||||
|
steps = [{
|
||||||
|
"label": "VO₂max 基准",
|
||||||
|
"input": f"{float(vo2max):.0f} ml/kg/min",
|
||||||
|
"years": round(base, 1),
|
||||||
|
"kind": "base",
|
||||||
|
}]
|
||||||
|
|
||||||
|
total = base
|
||||||
|
|
||||||
|
if resting_hr:
|
||||||
|
delta = (float(resting_hr) - RHR_REFERENCE) / 10.0 * RHR_YEARS_PER_10BPM
|
||||||
|
delta = max(-RHR_CAP, min(RHR_CAP, delta))
|
||||||
|
total += delta
|
||||||
|
steps.append({
|
||||||
|
"label": "静息心率",
|
||||||
|
"input": f"{float(resting_hr):.0f} bpm",
|
||||||
|
"years": round(delta, 1),
|
||||||
|
"kind": "adjust",
|
||||||
|
})
|
||||||
|
|
||||||
|
if bmi:
|
||||||
|
value = float(bmi)
|
||||||
|
if value < BMI_LOW:
|
||||||
|
off = BMI_LOW - value
|
||||||
|
elif value > BMI_HIGH:
|
||||||
|
off = value - BMI_HIGH
|
||||||
|
else:
|
||||||
|
off = 0.0
|
||||||
|
delta = min(BMI_CAP, off * BMI_YEARS_PER_UNIT)
|
||||||
|
total += delta
|
||||||
|
steps.append({
|
||||||
|
"label": "BMI",
|
||||||
|
"input": f"{value:.1f}",
|
||||||
|
"years": round(delta, 1),
|
||||||
|
"kind": "adjust",
|
||||||
|
})
|
||||||
|
|
||||||
|
chronological = float(age)
|
||||||
|
clamped = max(chronological - MAX_DEVIATION,
|
||||||
|
min(chronological + MAX_DEVIATION, total))
|
||||||
|
clamped = max(AGE_FLOOR, min(AGE_CEILING, clamped))
|
||||||
|
value = int(round(clamped))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"value": value,
|
||||||
|
"chronologicalAge": int(chronological),
|
||||||
|
"delta": value - int(chronological),
|
||||||
|
"steps": steps,
|
||||||
|
"clamped": abs(clamped - total) > 0.05,
|
||||||
|
"missing": [],
|
||||||
|
"basis": BASIS,
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ The last two are easy to confuse: `get_activities` takes an offset and a count,
|
|||||||
so passing it a date silently asks for activity number "2026-08-23".
|
so passing it a date silently asks for activity number "2026-08-23".
|
||||||
"""
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@@ -415,6 +416,172 @@ def _sync_activities(client, user_id, start_date, end_date):
|
|||||||
return stored
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
# --- one activity, in full ---------------------------------------------------
|
||||||
|
|
||||||
|
# Garmin will return thousands of samples per activity. A phone chart cannot
|
||||||
|
# draw more than a few hundred usefully, and the payload is stored as a row, so
|
||||||
|
# the series are thinned on the way in rather than on every read.
|
||||||
|
DETAIL_MAX_POINTS = 300
|
||||||
|
|
||||||
|
# Descriptor key -> the name the UI charts by. Anything not listed is dropped:
|
||||||
|
# the full descriptor set runs to dozens of fields, most of them empty.
|
||||||
|
SERIES_KEYS = {
|
||||||
|
"directTimestamp": "timestamp",
|
||||||
|
"sumElapsedDuration": "elapsed",
|
||||||
|
"sumDuration": "duration",
|
||||||
|
"sumDistance": "distance",
|
||||||
|
"directHeartRate": "heartRate",
|
||||||
|
"directSpeed": "speed",
|
||||||
|
"directElevation": "elevation",
|
||||||
|
"directRunCadence": "cadence",
|
||||||
|
"directBikeCadence": "cadence",
|
||||||
|
"directDoubleCadence": "cadence",
|
||||||
|
"directPower": "power",
|
||||||
|
"directAirTemperature": "temperature",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _thin(values, limit=DETAIL_MAX_POINTS):
|
||||||
|
"""Evenly sample a list down to `limit` points, keeping first and last."""
|
||||||
|
if len(values) <= limit:
|
||||||
|
return values
|
||||||
|
step = (len(values) - 1) / (limit - 1)
|
||||||
|
return [values[int(round(i * step))] for i in range(limit)]
|
||||||
|
|
||||||
|
|
||||||
|
def _series_from_details(details):
|
||||||
|
"""Turn Garmin's column-store detail payload into per-metric arrays.
|
||||||
|
|
||||||
|
The response is a descriptor list plus rows of parallel values, so every
|
||||||
|
metric has to be read out by the index its descriptor names.
|
||||||
|
"""
|
||||||
|
descriptors = details.get("metricDescriptors") or []
|
||||||
|
rows = details.get("activityDetailMetrics") or []
|
||||||
|
if not descriptors or not rows:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
index = {}
|
||||||
|
for d in descriptors:
|
||||||
|
name = SERIES_KEYS.get(d.get("key"))
|
||||||
|
if name and name not in index:
|
||||||
|
index[name] = d.get("metricsIndex")
|
||||||
|
|
||||||
|
rows = _thin(rows)
|
||||||
|
out = {}
|
||||||
|
for name, position in index.items():
|
||||||
|
if position is None:
|
||||||
|
continue
|
||||||
|
column = []
|
||||||
|
for row in rows:
|
||||||
|
metrics = row.get("metrics") or []
|
||||||
|
column.append(metrics[position] if position < len(metrics) else None)
|
||||||
|
# A column of nothing but nulls is a sensor the watch does not have.
|
||||||
|
if any(v is not None for v in column):
|
||||||
|
out[name] = column
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _lap_rows(splits):
|
||||||
|
laps = []
|
||||||
|
for i, lap in enumerate((splits or {}).get("lapDTOs") or [], start=1):
|
||||||
|
laps.append({
|
||||||
|
"index": lap.get("lapIndex") or i,
|
||||||
|
"duration": _num(lap.get("duration")),
|
||||||
|
"movingDuration": _num(lap.get("movingDuration")),
|
||||||
|
"distance": _num(lap.get("distance")),
|
||||||
|
"averageSpeed": _num(lap.get("averageSpeed")),
|
||||||
|
"maxSpeed": _num(lap.get("maxSpeed")),
|
||||||
|
"calories": _num(lap.get("calories")),
|
||||||
|
"averageHR": _num(lap.get("averageHR")),
|
||||||
|
"maxHR": _num(lap.get("maxHR")),
|
||||||
|
"elevationGain": _num(lap.get("elevationGain")),
|
||||||
|
"elevationLoss": _num(lap.get("elevationLoss")),
|
||||||
|
})
|
||||||
|
return laps
|
||||||
|
|
||||||
|
|
||||||
|
def _hr_zones(zones):
|
||||||
|
out = []
|
||||||
|
for z in zones or []:
|
||||||
|
out.append({
|
||||||
|
"zone": z.get("zoneNumber"),
|
||||||
|
"seconds": _num(z.get("secsInZone")) or 0,
|
||||||
|
"lowBoundary": _num(z.get("zoneLowBoundary")),
|
||||||
|
})
|
||||||
|
return sorted(out, key=lambda z: z.get("zone") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_detail(client, activity_id):
|
||||||
|
"""Assemble everything Garmin knows about one activity.
|
||||||
|
|
||||||
|
Each call is wrapped: a watch without a barometer has no weather, a
|
||||||
|
treadmill run has no gear, and a missing optional endpoint must leave the
|
||||||
|
rest of the page intact rather than fail the request.
|
||||||
|
"""
|
||||||
|
summary = _safe(lambda: client.get_activity_evaluation(activity_id), {}) or {}
|
||||||
|
details = _safe(
|
||||||
|
lambda: client.get_activity_details(activity_id, maxchart=2000, maxpoly=0), {}
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"activityId": str(activity_id),
|
||||||
|
"summary": summary.get("summaryDTO") or {},
|
||||||
|
"activityName": summary.get("activityName"),
|
||||||
|
"activityType": (summary.get("activityTypeDTO") or {}).get("typeKey"),
|
||||||
|
"eventType": (summary.get("eventTypeDTO") or {}).get("typeKey"),
|
||||||
|
"laps": _lap_rows(_safe(lambda: client.get_activity_splits(activity_id), {})),
|
||||||
|
"hrZones": _hr_zones(
|
||||||
|
_safe(lambda: client.get_activity_hr_in_timezones(activity_id), [])
|
||||||
|
),
|
||||||
|
"weather": _safe(lambda: client.get_activity_weather(activity_id), {}) or {},
|
||||||
|
"gear": _safe(lambda: client.get_activity_gear(activity_id), []) or [],
|
||||||
|
"exerciseSets": (
|
||||||
|
_safe(lambda: client.get_activity_exercise_sets(activity_id), {}) or {}
|
||||||
|
).get("exerciseSets") or [],
|
||||||
|
"series": _series_from_details(details),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_detail(user_id, activity_id, creds=None, refresh=False):
|
||||||
|
"""Cached detail for one activity, fetched from Garmin on first open."""
|
||||||
|
activity_id = str(activity_id)
|
||||||
|
|
||||||
|
if not refresh:
|
||||||
|
row = query_one(
|
||||||
|
"SELECT payload FROM activity_details "
|
||||||
|
"WHERE user_id = ? AND activity_id = ?",
|
||||||
|
[user_id, activity_id],
|
||||||
|
)
|
||||||
|
if row and row.get("payload"):
|
||||||
|
try:
|
||||||
|
cached = json.loads(row["payload"])
|
||||||
|
cached["cached"] = True
|
||||||
|
return cached
|
||||||
|
except ValueError:
|
||||||
|
# A truncated row is worth refetching, not worth crashing on.
|
||||||
|
pass
|
||||||
|
|
||||||
|
client = _connect(creds or {}, user_id=user_id)
|
||||||
|
detail = _build_detail(client, activity_id)
|
||||||
|
|
||||||
|
cols = ["activity_id", "user_id", "payload", "fetched_at"]
|
||||||
|
values = [activity_id, user_id, json.dumps(detail, default=str),
|
||||||
|
datetime.datetime.utcnow().isoformat(timespec="seconds")]
|
||||||
|
placeholders = ", ".join(["?"] * len(cols))
|
||||||
|
if DB_TYPE == "mariadb":
|
||||||
|
updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "activity_id")
|
||||||
|
sql = (f"INSERT INTO activity_details ({', '.join(cols)}) "
|
||||||
|
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}")
|
||||||
|
else:
|
||||||
|
updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "activity_id")
|
||||||
|
sql = (f"INSERT INTO activity_details ({', '.join(cols)}) VALUES "
|
||||||
|
f"({placeholders}) ON CONFLICT(activity_id) DO UPDATE SET {updates}")
|
||||||
|
execute(sql, values)
|
||||||
|
|
||||||
|
detail["cached"] = False
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
# Above this many days a sync is long enough that the caller must not block
|
# Above this many days a sync is long enough that the caller must not block
|
||||||
# on it — a year takes roughly 20 minutes at ~3s per day.
|
# on it — a year takes roughly 20 minutes at ~3s per day.
|
||||||
BACKGROUND_THRESHOLD_DAYS = 14
|
BACKGROUND_THRESHOLD_DAYS = 14
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import time
|
|||||||
from config import DB_TYPE
|
from config import DB_TYPE
|
||||||
from db import execute, query_one, query_all
|
from db import execute, query_one, query_all
|
||||||
from services import garmin as garmin_svc
|
from services import garmin as garmin_svc
|
||||||
|
from services import settings as settings_svc
|
||||||
|
|
||||||
JOB_NAME = "garmin_auto_sync"
|
JOB_NAME = "garmin_auto_sync"
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ INTERVAL_SECONDS = int(os.environ.get("AUTO_SYNC_INTERVAL_SECONDS") or 3600)
|
|||||||
SYNC_DAYS = int(os.environ.get("AUTO_SYNC_DAYS") or 2)
|
SYNC_DAYS = int(os.environ.get("AUTO_SYNC_DAYS") or 2)
|
||||||
ENABLED = (os.environ.get("AUTO_SYNC") or "true").lower() not in ("0", "false", "no")
|
ENABLED = (os.environ.get("AUTO_SYNC") or "true").lower() not in ("0", "false", "no")
|
||||||
|
|
||||||
|
# The loop wakes on this cadence; whether an account is actually due is then
|
||||||
|
# decided per account from its own 同步频率 setting. A single global interval
|
||||||
|
# would mean one user's choice of 30 minutes silently applied to everyone.
|
||||||
|
TICK_SECONDS = 300
|
||||||
|
|
||||||
# A claim older than this is treated as abandoned — the worker holding it died
|
# A claim older than this is treated as abandoned — the worker holding it died
|
||||||
# mid-run, and without expiry the job would never run again.
|
# mid-run, and without expiry the job would never run again.
|
||||||
CLAIM_TIMEOUT_SECONDS = 1800
|
CLAIM_TIMEOUT_SECONDS = 1800
|
||||||
@@ -99,14 +105,45 @@ def release(name=JOB_NAME, ran=True):
|
|||||||
execute("UPDATE job_locks SET claimed_at = NULL WHERE name = ?", [name])
|
execute("UPDATE job_locks SET claimed_at = NULL WHERE name = ?", [name])
|
||||||
|
|
||||||
|
|
||||||
def sync_all_accounts(days=None):
|
def due_at(user_id):
|
||||||
"""Sync every account that has a stored token. Returns a per-user result."""
|
"""When this account may next be synced automatically, or None if never.
|
||||||
|
|
||||||
|
None means auto-sync is switched off for them; a time in the past means
|
||||||
|
they are due now.
|
||||||
|
"""
|
||||||
|
prefs = settings_svc.get_raw(user_id)
|
||||||
|
if not prefs.get("auto_sync"):
|
||||||
|
return None
|
||||||
|
minutes = prefs.get("auto_sync_minutes") or (INTERVAL_SECONDS // 60)
|
||||||
|
last = _parse((garmin_svc.get_sync_status(user_id) or {}).get("lastSyncTime"))
|
||||||
|
if not last:
|
||||||
|
return _now() - datetime.timedelta(seconds=1)
|
||||||
|
return last + datetime.timedelta(minutes=minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_all_accounts(days=None, respect_schedule=False):
|
||||||
|
"""Sync every account that has a stored token. Returns a per-user result.
|
||||||
|
|
||||||
|
`respect_schedule` is what the background loop passes: it skips accounts
|
||||||
|
that have auto-sync off or that were synced recently enough. A direct call
|
||||||
|
(a manual "sync everything") leaves it False and syncs unconditionally.
|
||||||
|
"""
|
||||||
days = days or SYNC_DAYS
|
days = days or SYNC_DAYS
|
||||||
rows = query_all("SELECT user_id FROM garmin_tokens")
|
rows = query_all("SELECT user_id FROM garmin_tokens")
|
||||||
results = []
|
results = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
uid = row["user_id"]
|
uid = row["user_id"]
|
||||||
try:
|
try:
|
||||||
|
if respect_schedule:
|
||||||
|
due = due_at(uid)
|
||||||
|
if due is None:
|
||||||
|
results.append({"user": uid, "status": "skipped",
|
||||||
|
"reason": "auto-sync off"})
|
||||||
|
continue
|
||||||
|
if due > _now():
|
||||||
|
results.append({"user": uid, "status": "skipped",
|
||||||
|
"reason": "not due"})
|
||||||
|
continue
|
||||||
out = garmin_svc.sync_data(uid, {}, days=days)
|
out = garmin_svc.sync_data(uid, {}, days=days)
|
||||||
results.append({"user": uid, "status": out.get("status"),
|
results.append({"user": uid, "status": out.get("status"),
|
||||||
"records": out.get("recordsSynced")})
|
"records": out.get("recordsSynced")})
|
||||||
@@ -118,16 +155,16 @@ def sync_all_accounts(days=None):
|
|||||||
def _loop():
|
def _loop():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if claim():
|
if claim(interval=TICK_SECONDS):
|
||||||
try:
|
try:
|
||||||
sync_all_accounts()
|
sync_all_accounts(respect_schedule=True)
|
||||||
finally:
|
finally:
|
||||||
release()
|
release()
|
||||||
except Exception as e: # noqa: BLE001 - the loop must outlive any single failure
|
except Exception as e: # noqa: BLE001 - the loop must outlive any single failure
|
||||||
print(f"[scheduler] tick failed: {e}")
|
print(f"[scheduler] tick failed: {e}")
|
||||||
# Checked more often than the interval so a worker that starts late
|
# Checked more often than the interval so a worker that starts late
|
||||||
# still picks the job up promptly rather than waiting a full hour.
|
# still picks the job up promptly rather than waiting a full hour.
|
||||||
time.sleep(min(300, INTERVAL_SECONDS))
|
time.sleep(TICK_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def start():
|
||||||
@@ -144,14 +181,27 @@ def start():
|
|||||||
print(f"[scheduler] auto-sync every {INTERVAL_SECONDS}s, {SYNC_DAYS} day(s) back")
|
print(f"[scheduler] auto-sync every {INTERVAL_SECONDS}s, {SYNC_DAYS} day(s) back")
|
||||||
|
|
||||||
|
|
||||||
def status():
|
def status(user_id=None):
|
||||||
|
"""Scheduler state, and — when a user is named — their own next due time."""
|
||||||
row = query_one("SELECT * FROM job_locks WHERE name = ?", [JOB_NAME])
|
row = query_one("SELECT * FROM job_locks WHERE name = ?", [JOB_NAME])
|
||||||
last = _parse(row.get("last_run_at")) if row else None
|
last = _parse(row.get("last_run_at")) if row else None
|
||||||
return {
|
|
||||||
|
out = {
|
||||||
"enabled": ENABLED,
|
"enabled": ENABLED,
|
||||||
"intervalSeconds": INTERVAL_SECONDS,
|
"intervalSeconds": INTERVAL_SECONDS,
|
||||||
|
"tickSeconds": TICK_SECONDS,
|
||||||
"days": SYNC_DAYS,
|
"days": SYNC_DAYS,
|
||||||
"lastRunAt": _iso(last) if last else None,
|
"lastRunAt": _iso(last) if last else None,
|
||||||
"nextRunAt": _iso(last + datetime.timedelta(seconds=INTERVAL_SECONDS)) if last else None,
|
"nextRunAt": _iso(last + datetime.timedelta(seconds=TICK_SECONDS)) if last else None,
|
||||||
"running": bool(row and row.get("claimed_at")),
|
"running": bool(row and row.get("claimed_at")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
prefs = settings_svc.get_raw(user_id)
|
||||||
|
due = due_at(user_id)
|
||||||
|
out["account"] = {
|
||||||
|
"autoSync": bool(prefs.get("auto_sync")),
|
||||||
|
"intervalMinutes": prefs.get("auto_sync_minutes"),
|
||||||
|
"dueAt": _iso(due) if due else None,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|||||||
213
backend/services/settings.py
Normal file
213
backend/services/settings.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user