feat: 补齐 Garmin 未同步的数据,并各自配上界面
审计 57 个接口后,把账号里真有数据却从未入库的部分补上。全部走同步模块, 界面只读本地库。 新增数据 - 体重与身体成分(体脂率/肌肉量/体水分/骨量/内脏脂肪/代谢年龄) - 血压(接口通,账号暂无记录) - 跑步成绩预测(5 公里 / 10 公里 / 半马 / 全马) - 爬坡分、饮水量、出汗量 → health_data 新增七列 - 全天曲线:心率 / 压力 / 身体电量 / 呼吸 / 血氧 - 挑战赛(徽章挑战与好友挑战,与一次性的徽章不同,有周期和进度) - 已配对设备 新增界面 - /body/ 身体成分:体重大数字 + BMI 分级 + 体脂肌肉曲线 + 血压表格 - /race/ 成绩预测:四个距离的预测成绩与配速,以及预测随时间的变化 - /challenges/ 挑战赛:按类型筛选,有目标的显示进度条 - /devices/ 已配对设备 - 每日页新增「全天曲线」,这是存日内采样的主要目的 - 健康页新增「身体成分」分组与「更多」入口,运动页加挑战赛与成绩预测入口 同步开销 - 日内曲线每天五个请求,14 天以内的同步顺带拉,更长的历史交给后台 「补齐详细数据」,否则一年的同步会多出约 1800 个请求 - 原来的「补齐运动详情」扩展为统一的补齐任务,分阶段上报进度 日内采样抽稀到每天 240 点:手机图表分辨不出更多,只会把行撑大。 全量 446 项测试通过。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
100
backend/db.py
100
backend/db.py
@@ -188,6 +188,97 @@ CREATE TABLE IF NOT EXISTS activity_details (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Weight and body composition, one row per measurement day.
|
||||||
|
-- Separate from health_data because it arrives from the scale rather than the
|
||||||
|
-- watch, on its own irregular schedule — most days simply have no row.
|
||||||
|
CREATE TABLE IF NOT EXISTS body_composition (
|
||||||
|
id VARCHAR(96) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
weight_kg DOUBLE,
|
||||||
|
bmi DOUBLE,
|
||||||
|
body_fat_pct DOUBLE,
|
||||||
|
body_water_pct DOUBLE,
|
||||||
|
bone_mass_kg DOUBLE,
|
||||||
|
muscle_mass_kg DOUBLE,
|
||||||
|
physique_rating DOUBLE,
|
||||||
|
visceral_fat DOUBLE,
|
||||||
|
metabolic_age DOUBLE,
|
||||||
|
source VARCHAR(32),
|
||||||
|
UNIQUE(user_id, date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Blood pressure readings. Manually entered in Garmin Connect, so there may
|
||||||
|
-- be none at all; the table exists so that there is somewhere to put them.
|
||||||
|
CREATE TABLE IF NOT EXISTS blood_pressure (
|
||||||
|
id VARCHAR(96) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
measured_at DATETIME NOT NULL,
|
||||||
|
systolic INT,
|
||||||
|
diastolic INT,
|
||||||
|
pulse INT,
|
||||||
|
note TEXT,
|
||||||
|
UNIQUE(user_id, measured_at),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Garmin's predicted race times, in seconds. One row per day it recalculates.
|
||||||
|
CREATE TABLE IF NOT EXISTS race_predictions (
|
||||||
|
id VARCHAR(96) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
time_5k INT,
|
||||||
|
time_10k INT,
|
||||||
|
time_half INT,
|
||||||
|
time_marathon INT,
|
||||||
|
UNIQUE(user_id, date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Within-day sample series: heart rate, stress, body battery, respiration,
|
||||||
|
-- SpO2. One generic table rather than five near-identical ones — they differ
|
||||||
|
-- only in what the numbers mean, and the daily screen reads them the same way.
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_series (
|
||||||
|
id VARCHAR(96) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
kind VARCHAR(32) NOT NULL,
|
||||||
|
payload MEDIUMTEXT,
|
||||||
|
fetched_at DATETIME,
|
||||||
|
UNIQUE(user_id, date, kind),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Badge challenges and ad-hoc challenges. Distinct from `badges`: a badge is
|
||||||
|
-- earned once, a challenge has a period, a target and a standing.
|
||||||
|
CREATE TABLE IF NOT EXISTS challenges (
|
||||||
|
id VARCHAR(96) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
challenge_uuid VARCHAR(96),
|
||||||
|
kind VARCHAR(32),
|
||||||
|
name VARCHAR(255),
|
||||||
|
status VARCHAR(64),
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
payload MEDIUMTEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Paired devices, so the app can say which watch a number came from.
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id VARCHAR(96) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
device_id VARCHAR(96),
|
||||||
|
name VARCHAR(255),
|
||||||
|
model VARCHAR(255),
|
||||||
|
serial VARCHAR(96),
|
||||||
|
software_version VARCHAR(64),
|
||||||
|
last_used_at DATETIME,
|
||||||
|
payload MEDIUMTEXT,
|
||||||
|
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.
|
||||||
@@ -349,6 +440,15 @@ MIGRATIONS = {
|
|||||||
("training_readiness", "INT"),
|
("training_readiness", "INT"),
|
||||||
("vo2max", "DOUBLE"),
|
("vo2max", "DOUBLE"),
|
||||||
("endurance_score", "INT"),
|
("endurance_score", "INT"),
|
||||||
|
# hill score, hydration and weight — daily scalars that were being
|
||||||
|
# fetched from Garmin by nothing at all until now
|
||||||
|
("hill_score", "INT"),
|
||||||
|
("hydration_ml", "INT"),
|
||||||
|
("hydration_goal_ml", "INT"),
|
||||||
|
("sweat_loss_ml", "INT"),
|
||||||
|
("weight_kg", "DOUBLE"),
|
||||||
|
("body_fat_pct", "DOUBLE"),
|
||||||
|
("bmi", "DOUBLE"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ def activity_detail(activity_id):
|
|||||||
@bp.route("/sync-details", methods=["POST"])
|
@bp.route("/sync-details", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def sync_details():
|
def sync_details():
|
||||||
"""Backfill details for activities already stored without one."""
|
"""Backfill activity details and daily curves for existing history."""
|
||||||
if not garmin_svc.has_token(g.user_id):
|
if not garmin_svc.has_token(g.user_id):
|
||||||
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
|
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
|
||||||
|
|
||||||
@@ -181,10 +181,10 @@ def sync_details():
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
limit = None
|
limit = None
|
||||||
|
|
||||||
return jsonify(garmin_svc.start_detail_sync(g.user_id, limit)), 202
|
return jsonify(garmin_svc.start_backfill(g.user_id, limit)), 202
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/sync-details", methods=["GET"])
|
@bp.route("/sync-details", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def sync_details_status():
|
def sync_details_status():
|
||||||
return jsonify(garmin_svc.detail_sync_status(g.user_id))
|
return jsonify(garmin_svc.backfill_status(g.user_id))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 settings as settings_svc
|
||||||
from services import fitness_age
|
from services import fitness_age
|
||||||
|
from services import garmin_extras as extras
|
||||||
|
|
||||||
bp = Blueprint("health", __name__)
|
bp = Blueprint("health", __name__)
|
||||||
|
|
||||||
@@ -88,3 +89,47 @@ def badges():
|
|||||||
@require_auth
|
@require_auth
|
||||||
def personal_records():
|
def personal_records():
|
||||||
return jsonify(health_svc.get_personal_records(g.user_id))
|
return jsonify(health_svc.get_personal_records(g.user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/body-composition", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def body_composition():
|
||||||
|
"""Weight and everything a connected scale reports with it."""
|
||||||
|
s, e = _range()
|
||||||
|
return jsonify(extras.get_body_composition(g.user_id, s, e))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/blood-pressure", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def blood_pressure():
|
||||||
|
return jsonify(extras.get_blood_pressure(g.user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/race-predictions", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def race_predictions():
|
||||||
|
"""Garmin's predicted 5K / 10K / half / marathon times, in seconds."""
|
||||||
|
return jsonify(extras.get_race_predictions(g.user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/series", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def daily_series():
|
||||||
|
"""Within-day curves for one date: heart rate, stress, body battery,
|
||||||
|
respiration, SpO2."""
|
||||||
|
date = request.args.get("date")
|
||||||
|
if not date:
|
||||||
|
return jsonify({"error": "缺少 date 参数"}), 400
|
||||||
|
return jsonify(extras.get_daily_series(g.user_id, date))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/challenges", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def challenges():
|
||||||
|
return jsonify(extras.get_challenges(g.user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/devices", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def devices():
|
||||||
|
return jsonify(extras.get_devices(g.user_id))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import threading
|
|||||||
from db import execute, query_one, query_all
|
from db import execute, query_one, query_all
|
||||||
from config import DB_TYPE
|
from config import DB_TYPE
|
||||||
from services import health
|
from services import health
|
||||||
|
from services import garmin_extras as extras
|
||||||
|
|
||||||
# How many days back a sync reaches.
|
# How many days back a sync reaches.
|
||||||
DEFAULT_SYNC_DAYS = int(os.environ.get("GARMIN_SYNC_DAYS") or 7)
|
DEFAULT_SYNC_DAYS = int(os.environ.get("GARMIN_SYNC_DAYS") or 7)
|
||||||
@@ -647,47 +648,82 @@ def sync_activity_details(client, user_id, limit=None, on_progress=None):
|
|||||||
return stored
|
return stored
|
||||||
|
|
||||||
|
|
||||||
_detail_progress = {}
|
_backfill_progress = {}
|
||||||
|
|
||||||
|
|
||||||
def detail_sync_status(user_id):
|
def backfill_status(user_id):
|
||||||
"""Progress of the detail backfill for this account."""
|
"""Progress of the historical backfill for this account."""
|
||||||
return _detail_progress.get(user_id) or {"running": False, "done": 0, "total": 0}
|
return _backfill_progress.get(user_id) or {
|
||||||
|
"running": False, "stage": None, "done": 0, "total": 0, "error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def start_detail_sync(user_id, limit=None):
|
def _set_backfill(user_id, **fields):
|
||||||
"""Backfill activity details in the background.
|
state = dict(_backfill_progress.get(user_id) or {})
|
||||||
|
state.update(fields)
|
||||||
|
_backfill_progress[user_id] = state
|
||||||
|
|
||||||
Each activity costs several Garmin calls, so 170 of them run for minutes —
|
|
||||||
far too long to hold a request open. The UI polls instead.
|
def days_missing_series(user_id, limit=None):
|
||||||
|
"""Days that have a health row but no within-day curves stored."""
|
||||||
|
rows = query_all(
|
||||||
|
"SELECT h.date FROM health_data h "
|
||||||
|
"LEFT JOIN daily_series s ON s.user_id = h.user_id AND s.date = h.date "
|
||||||
|
"WHERE h.user_id = ? AND s.date IS NULL "
|
||||||
|
"GROUP BY h.date ORDER BY h.date DESC",
|
||||||
|
[user_id],
|
||||||
|
)
|
||||||
|
dates = [str(r["date"])[:10] for r in rows]
|
||||||
|
return dates[:limit] if limit else dates
|
||||||
|
|
||||||
|
|
||||||
|
def start_backfill(user_id, limit=None):
|
||||||
|
"""Fill in everything the per-day sync leaves out, in the background.
|
||||||
|
|
||||||
|
Two long jobs share one runner because they share a cause — an account
|
||||||
|
whose history predates these features — and because the user should press
|
||||||
|
one button, not two. Each activity costs several Garmin calls and each day
|
||||||
|
of curves costs five, so this runs for minutes; the UI polls.
|
||||||
"""
|
"""
|
||||||
state = _detail_progress.get(user_id)
|
state = _backfill_progress.get(user_id)
|
||||||
if state and state.get("running"):
|
if state and state.get("running"):
|
||||||
return state
|
return state
|
||||||
|
|
||||||
_detail_progress[user_id] = {"running": True, "done": 0, "total": 0, "error": None}
|
_set_backfill(user_id, running=True, stage="启动中", done=0, total=0, error=None)
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
client = _connect({}, user_id=user_id)
|
client = _connect({}, user_id=user_id)
|
||||||
|
|
||||||
def progress(done, total):
|
_set_backfill(user_id, stage="运动详情", done=0, total=0)
|
||||||
_detail_progress[user_id] = {
|
sync_activity_details(
|
||||||
"running": True, "done": done, "total": total, "error": None,
|
client, user_id, limit,
|
||||||
}
|
on_progress=lambda d, n: _set_backfill(
|
||||||
|
user_id, stage="运动详情", done=d, total=n),
|
||||||
|
)
|
||||||
|
|
||||||
stored = sync_activity_details(client, user_id, limit, on_progress=progress)
|
dates = days_missing_series(user_id, limit)
|
||||||
_detail_progress[user_id] = {
|
_set_backfill(user_id, stage="每日曲线", done=0, total=len(dates))
|
||||||
"running": False, "done": stored,
|
for i, date in enumerate(dates):
|
||||||
"total": _detail_progress[user_id].get("total", stored), "error": None,
|
try:
|
||||||
}
|
extras.sync_daily_series(client, user_id, date)
|
||||||
|
except Exception: # noqa: BLE001 - one day must not stop the rest
|
||||||
|
pass
|
||||||
|
_set_backfill(user_id, stage="每日曲线", done=i + 1,
|
||||||
|
total=len(dates))
|
||||||
|
|
||||||
|
_set_backfill(user_id, running=False, stage="完成", error=None)
|
||||||
except Exception as e: # noqa: BLE001 - reported through the status endpoint
|
except Exception as e: # noqa: BLE001 - reported through the status endpoint
|
||||||
_detail_progress[user_id] = {
|
_set_backfill(user_id, running=False, stage=None, error=describe(e))
|
||||||
"running": False, "done": 0, "total": 0, "error": describe(e),
|
|
||||||
}
|
|
||||||
|
|
||||||
threading.Thread(target=run, daemon=True, name=f"detail-sync-{user_id}").start()
|
threading.Thread(target=run, daemon=True, name=f"backfill-{user_id}").start()
|
||||||
return _detail_progress[user_id]
|
return backfill_status(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Up to this many days, a sync also pulls each day's within-day curves inline.
|
||||||
|
# Beyond it the curves are left to the background backfill: five extra calls
|
||||||
|
# per day would turn a year's sync into an hour.
|
||||||
|
SERIES_INLINE_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -749,6 +785,7 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
date_str = (today - datetime.timedelta(days=i)).isoformat()
|
date_str = (today - datetime.timedelta(days=i)).isoformat()
|
||||||
try:
|
try:
|
||||||
record = _extract_daily(client, date_str)
|
record = _extract_daily(client, date_str)
|
||||||
|
record.update(extras.daily_extras(client, date_str))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
day_errors.append(f"{date_str}: {describe(e)}")
|
day_errors.append(f"{date_str}: {describe(e)}")
|
||||||
continue
|
continue
|
||||||
@@ -757,6 +794,14 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
if any(record[k] is not None for k in record if k != "date"):
|
if any(record[k] is not None for k in record if k != "date"):
|
||||||
health.upsert_health_daily(user_id, record)
|
health.upsert_health_daily(user_id, record)
|
||||||
days_synced += 1
|
days_synced += 1
|
||||||
|
# Within-day curves for short syncs only. A year-long backfill
|
||||||
|
# would add five calls per day on top of everything else; those
|
||||||
|
# days are filled by start_backfill instead.
|
||||||
|
if days <= SERIES_INLINE_DAYS:
|
||||||
|
try:
|
||||||
|
extras.sync_daily_series(client, user_id, date_str)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
day_errors.append(f"{date_str} series: {describe(e)}")
|
||||||
|
|
||||||
# Reported every few days rather than every day: the write is cheap
|
# Reported every few days rather than every day: the write is cheap
|
||||||
# but not free, and the UI polls on a 2s cadence anyway.
|
# but not free, and the UI polls on a 2s cadence anyway.
|
||||||
@@ -783,6 +828,26 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
day_errors.append(f"activity_details: {describe(e)}")
|
day_errors.append(f"activity_details: {describe(e)}")
|
||||||
|
|
||||||
|
# Everything else Garmin holds: body composition, blood pressure, race
|
||||||
|
# predictions, challenges and devices. Account-wide, so once per sync.
|
||||||
|
extra_counts = {}
|
||||||
|
for name, call in (
|
||||||
|
("bodyComposition",
|
||||||
|
lambda: extras.sync_body_composition(client, user_id, start_date,
|
||||||
|
today.isoformat())),
|
||||||
|
("bloodPressure",
|
||||||
|
lambda: extras.sync_blood_pressure(client, user_id, start_date,
|
||||||
|
today.isoformat())),
|
||||||
|
("racePredictions",
|
||||||
|
lambda: extras.sync_race_predictions(client, user_id)),
|
||||||
|
("challenges", lambda: extras.sync_challenges(client, user_id)),
|
||||||
|
("devices", lambda: extras.sync_devices(client, user_id)),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
extra_counts[name] = call()
|
||||||
|
except Exception as e: # noqa: BLE001 - one section must not fail the sync
|
||||||
|
day_errors.append(f"{name}: {describe(e)}")
|
||||||
|
|
||||||
# Badges and personal records are account-wide rather than per-day, so
|
# Badges and personal records are account-wide rather than per-day, so
|
||||||
# they are fetched once per sync rather than inside the day loop.
|
# they are fetched once per sync rather than inside the day loop.
|
||||||
badges_synced = 0
|
badges_synced = 0
|
||||||
|
|||||||
449
backend/services/garmin_extras.py
Normal file
449
backend/services/garmin_extras.py
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
"""
|
||||||
|
The rest of what Garmin holds.
|
||||||
|
|
||||||
|
The original sync covered daily totals, activities, badges and personal
|
||||||
|
records — 16 of the library's 57 endpoints. Everything here is data the
|
||||||
|
account actually has that was simply never being stored: body composition,
|
||||||
|
hill score, race predictions, hydration, the within-day sample series, and
|
||||||
|
challenges and devices.
|
||||||
|
|
||||||
|
All of it lands in the local database during a sync, so no screen ever has to
|
||||||
|
reach Garmin to draw itself.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from db import execute, query_one, query_all
|
||||||
|
from config import DB_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
# --- small helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _num(*values):
|
||||||
|
for v in values:
|
||||||
|
if v is None or v == "":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _int(*values):
|
||||||
|
n = _num(*values)
|
||||||
|
return int(n) if n is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(fn, default=None):
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except Exception: # noqa: BLE001 - a missing feature must not fail a sync
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _day(value):
|
||||||
|
"""Garmin dates arrive as ISO strings, epoch millis, or already-dates."""
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime.date):
|
||||||
|
return value.isoformat()
|
||||||
|
text = str(value)
|
||||||
|
if text.isdigit():
|
||||||
|
seconds = int(text) / (1000 if len(text) > 10 else 1)
|
||||||
|
return datetime.datetime.utcfromtimestamp(seconds).date().isoformat()
|
||||||
|
return text[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp(value):
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
text = str(value)
|
||||||
|
if text.isdigit():
|
||||||
|
seconds = int(text) / (1000 if len(text) > 10 else 1)
|
||||||
|
return datetime.datetime.utcfromtimestamp(seconds).isoformat(timespec="seconds")
|
||||||
|
return text.replace("T", " ")[:19]
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert(table, key_cols, cols, values):
|
||||||
|
placeholders = ", ".join(["?"] * len(cols))
|
||||||
|
updatable = [c for c in cols if c not in key_cols]
|
||||||
|
if DB_TYPE == "mariadb":
|
||||||
|
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
|
||||||
|
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||||
|
f"ON DUPLICATE KEY UPDATE {updates}")
|
||||||
|
else:
|
||||||
|
conflict = ", ".join(key_cols)
|
||||||
|
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
|
||||||
|
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||||
|
f"ON CONFLICT({conflict}) DO UPDATE SET {updates}")
|
||||||
|
execute(sql, values)
|
||||||
|
|
||||||
|
|
||||||
|
# --- body composition --------------------------------------------------------
|
||||||
|
|
||||||
|
def sync_body_composition(client, user_id, start, end):
|
||||||
|
"""Weight and everything a connected scale reports with it."""
|
||||||
|
data = _safe(lambda: client.get_body_composition(start, end), {}) or {}
|
||||||
|
rows = data.get("dateWeightList") or []
|
||||||
|
|
||||||
|
stored = 0
|
||||||
|
for row in rows:
|
||||||
|
date = _day(row.get("calendarDate") or row.get("date"))
|
||||||
|
if not date:
|
||||||
|
continue
|
||||||
|
# Garmin stores weight in grams.
|
||||||
|
grams = _num(row.get("weight"))
|
||||||
|
_upsert(
|
||||||
|
"body_composition", ("user_id", "date"),
|
||||||
|
["id", "user_id", "date", "weight_kg", "bmi", "body_fat_pct",
|
||||||
|
"body_water_pct", "bone_mass_kg", "muscle_mass_kg",
|
||||||
|
"physique_rating", "visceral_fat", "metabolic_age", "source"],
|
||||||
|
[f"{user_id}-{date}", user_id, date,
|
||||||
|
grams / 1000 if grams else None,
|
||||||
|
_num(row.get("bmi")),
|
||||||
|
_num(row.get("bodyFat")),
|
||||||
|
_num(row.get("bodyWater")),
|
||||||
|
(_num(row.get("boneMass")) or 0) / 1000 or None,
|
||||||
|
(_num(row.get("muscleMass")) or 0) / 1000 or None,
|
||||||
|
_num(row.get("physiqueRating")),
|
||||||
|
_num(row.get("visceralFat")),
|
||||||
|
_num(row.get("metabolicAge")),
|
||||||
|
row.get("sourceType")],
|
||||||
|
)
|
||||||
|
stored += 1
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def get_body_composition(user_id, start=None, end=None):
|
||||||
|
sql = "WHERE user_id = ?"
|
||||||
|
params = [user_id]
|
||||||
|
if start:
|
||||||
|
sql += " AND date >= ?"
|
||||||
|
params.append(start)
|
||||||
|
if end:
|
||||||
|
sql += " AND date <= ?"
|
||||||
|
params.append(end)
|
||||||
|
rows = query_all(
|
||||||
|
f"SELECT * FROM body_composition {sql} ORDER BY date ASC", params
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"date": str(r["date"])[:10],
|
||||||
|
"weightKg": r["weight_kg"],
|
||||||
|
"bmi": r["bmi"],
|
||||||
|
"bodyFatPct": r["body_fat_pct"],
|
||||||
|
"bodyWaterPct": r["body_water_pct"],
|
||||||
|
"boneMassKg": r["bone_mass_kg"],
|
||||||
|
"muscleMassKg": r["muscle_mass_kg"],
|
||||||
|
"physiqueRating": r["physique_rating"],
|
||||||
|
"visceralFat": r["visceral_fat"],
|
||||||
|
"metabolicAge": r["metabolic_age"],
|
||||||
|
} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- blood pressure ----------------------------------------------------------
|
||||||
|
|
||||||
|
def sync_blood_pressure(client, user_id, start, end):
|
||||||
|
data = _safe(lambda: client.get_blood_pressure(start, end), {}) or {}
|
||||||
|
stored = 0
|
||||||
|
for summary in data.get("measurementSummaries") or []:
|
||||||
|
for m in summary.get("measurements") or []:
|
||||||
|
when = _stamp(m.get("measurementTimestampLocal")
|
||||||
|
or m.get("measurementTimestampGMT"))
|
||||||
|
if not when:
|
||||||
|
continue
|
||||||
|
_upsert(
|
||||||
|
"blood_pressure", ("user_id", "measured_at"),
|
||||||
|
["id", "user_id", "measured_at", "systolic", "diastolic",
|
||||||
|
"pulse", "note"],
|
||||||
|
[f"{user_id}-{when}", user_id, when,
|
||||||
|
_int(m.get("systolic")), _int(m.get("diastolic")),
|
||||||
|
_int(m.get("pulse")), m.get("notes")],
|
||||||
|
)
|
||||||
|
stored += 1
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def get_blood_pressure(user_id):
|
||||||
|
rows = query_all(
|
||||||
|
"SELECT * FROM blood_pressure WHERE user_id = ? ORDER BY measured_at DESC",
|
||||||
|
[user_id],
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"measuredAt": str(r["measured_at"]),
|
||||||
|
"systolic": r["systolic"],
|
||||||
|
"diastolic": r["diastolic"],
|
||||||
|
"pulse": r["pulse"],
|
||||||
|
"note": r["note"],
|
||||||
|
} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- race predictions --------------------------------------------------------
|
||||||
|
|
||||||
|
def sync_race_predictions(client, user_id, start=None, end=None):
|
||||||
|
data = _safe(lambda: client.get_race_predictions(start, end), None)
|
||||||
|
rows = data if isinstance(data, list) else [data] if data else []
|
||||||
|
|
||||||
|
stored = 0
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
date = _day(row.get("calendarDate") or row.get("fromCalendarDate"))
|
||||||
|
if not date:
|
||||||
|
continue
|
||||||
|
_upsert(
|
||||||
|
"race_predictions", ("user_id", "date"),
|
||||||
|
["id", "user_id", "date", "time_5k", "time_10k", "time_half",
|
||||||
|
"time_marathon"],
|
||||||
|
[f"{user_id}-{date}", user_id, date,
|
||||||
|
_int(row.get("time5K")), _int(row.get("time10K")),
|
||||||
|
_int(row.get("timeHalfMarathon")), _int(row.get("timeMarathon"))],
|
||||||
|
)
|
||||||
|
stored += 1
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def get_race_predictions(user_id, limit=90):
|
||||||
|
rows = query_all(
|
||||||
|
"SELECT * FROM race_predictions WHERE user_id = ? ORDER BY date DESC",
|
||||||
|
[user_id],
|
||||||
|
)[:limit]
|
||||||
|
return [{
|
||||||
|
"date": str(r["date"])[:10],
|
||||||
|
"time5k": r["time_5k"],
|
||||||
|
"time10k": r["time_10k"],
|
||||||
|
"timeHalf": r["time_half"],
|
||||||
|
"timeMarathon": r["time_marathon"],
|
||||||
|
} for r in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
|
# --- within-day series -------------------------------------------------------
|
||||||
|
|
||||||
|
# Each entry: the API call, and how to pull the [timestamp, value] pairs out of
|
||||||
|
# whatever shape that particular endpoint returns. They are all different.
|
||||||
|
def _hr_series(data):
|
||||||
|
return [[_stamp(t), v] for t, v in (data.get("heartRateValues") or [])
|
||||||
|
if v is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _stress_series(data):
|
||||||
|
return [[_stamp(t), v] for t, v in (data.get("stressValuesArray") or [])
|
||||||
|
if v is not None and v >= 0]
|
||||||
|
|
||||||
|
|
||||||
|
def _battery_series(data):
|
||||||
|
out = []
|
||||||
|
for entry in data if isinstance(data, list) else [data]:
|
||||||
|
for point in (entry or {}).get("bodyBatteryValuesArray") or []:
|
||||||
|
# [timestamp, status, level, version]
|
||||||
|
if len(point) >= 3 and point[2] is not None:
|
||||||
|
out.append([_stamp(point[0]), point[2]])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _respiration_series(data):
|
||||||
|
return [[_stamp(t), v] for t, v in (data.get("respirationValuesArray") or [])
|
||||||
|
if v is not None and v > 0]
|
||||||
|
|
||||||
|
|
||||||
|
def _spo2_series(data):
|
||||||
|
return [[_stamp(t), v] for t, v in (data.get("spO2HourlyAverages") or [])
|
||||||
|
if v is not None]
|
||||||
|
|
||||||
|
|
||||||
|
SERIES_KINDS = {
|
||||||
|
"heartRate": (lambda c, d: c.get_heart_rates(d), _hr_series),
|
||||||
|
"stress": (lambda c, d: c.get_all_day_stress(d), _stress_series),
|
||||||
|
"bodyBattery": (lambda c, d: c.get_body_battery(d, d), _battery_series),
|
||||||
|
"respiration": (lambda c, d: c.get_respiration_data(d), _respiration_series),
|
||||||
|
"spo2": (lambda c, d: c.get_spo2_data(d), _spo2_series),
|
||||||
|
}
|
||||||
|
|
||||||
|
# A day of heart rate is ~500 samples at 2-minute resolution. More than this
|
||||||
|
# cannot be told apart on a phone chart and only inflates the row.
|
||||||
|
SERIES_MAX_POINTS = 240
|
||||||
|
|
||||||
|
|
||||||
|
def _thin(points, limit=SERIES_MAX_POINTS):
|
||||||
|
if len(points) <= limit:
|
||||||
|
return points
|
||||||
|
step = (len(points) - 1) / (limit - 1)
|
||||||
|
return [points[int(round(i * step))] for i in range(limit)]
|
||||||
|
|
||||||
|
|
||||||
|
def sync_daily_series(client, user_id, date, kinds=None):
|
||||||
|
"""Store the within-day curves for one day."""
|
||||||
|
stored = 0
|
||||||
|
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
|
||||||
|
for kind, (call, extract) in SERIES_KINDS.items():
|
||||||
|
if kinds and kind not in kinds:
|
||||||
|
continue
|
||||||
|
raw = _safe(lambda: call(client, date))
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
points = _safe(lambda: _thin(extract(raw)), []) or []
|
||||||
|
if not points:
|
||||||
|
continue
|
||||||
|
_upsert(
|
||||||
|
"daily_series", ("user_id", "date", "kind"),
|
||||||
|
["id", "user_id", "date", "kind", "payload", "fetched_at"],
|
||||||
|
[f"{user_id}-{date}-{kind}", user_id, date, kind,
|
||||||
|
json.dumps(points, default=str), now],
|
||||||
|
)
|
||||||
|
stored += 1
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def get_daily_series(user_id, date):
|
||||||
|
rows = query_all(
|
||||||
|
"SELECT kind, payload FROM daily_series WHERE user_id = ? AND date = ?",
|
||||||
|
[user_id, date],
|
||||||
|
)
|
||||||
|
out = {}
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
out[r["kind"]] = json.loads(r["payload"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- challenges --------------------------------------------------------------
|
||||||
|
|
||||||
|
def sync_challenges(client, user_id):
|
||||||
|
"""Badge challenges and ad-hoc challenges.
|
||||||
|
|
||||||
|
Distinct from badges: a badge is earned once and sits in a list, while a
|
||||||
|
challenge has a period, a target and a standing.
|
||||||
|
"""
|
||||||
|
execute("DELETE FROM challenges WHERE user_id = ?", [user_id])
|
||||||
|
|
||||||
|
stored = 0
|
||||||
|
sources = [
|
||||||
|
("badge", lambda: client.get_badge_challenges(1, 100)),
|
||||||
|
("adhoc", lambda: client.get_adhoc_challenges(1, 100)),
|
||||||
|
("available", lambda: client.get_available_badge_challenges(1, 100)),
|
||||||
|
("inprogress", lambda: client.get_inprogress_virtual_challenges(1, 100)),
|
||||||
|
]
|
||||||
|
|
||||||
|
for kind, call in sources:
|
||||||
|
rows = _safe(call, []) or []
|
||||||
|
if isinstance(rows, dict):
|
||||||
|
rows = rows.get("challenges") or rows.get("badgeChallenges") or []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
uuid = row.get("uuid") or row.get("challengeUuid") or row.get("badgeId")
|
||||||
|
name = (row.get("badgeChallengeName") or row.get("adHocChallengeName")
|
||||||
|
or row.get("challengeName") or row.get("badgeName"))
|
||||||
|
execute(
|
||||||
|
"INSERT INTO challenges (id, user_id, challenge_uuid, kind, name, "
|
||||||
|
"status, start_date, end_date, payload) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
[f"{user_id}-{kind}-{uuid}-{stored}", user_id, str(uuid or ""),
|
||||||
|
kind, name,
|
||||||
|
str(row.get("badgeChallengeStatusId")
|
||||||
|
or row.get("socialChallengeStatusId") or ""),
|
||||||
|
_day(row.get("startDate")), _day(row.get("endDate")),
|
||||||
|
json.dumps(row, default=str)],
|
||||||
|
)
|
||||||
|
stored += 1
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def get_challenges(user_id):
|
||||||
|
rows = query_all(
|
||||||
|
"SELECT * FROM challenges WHERE user_id = ? ORDER BY start_date DESC",
|
||||||
|
[user_id],
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(r["payload"]) if r["payload"] else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
payload = {}
|
||||||
|
out.append({
|
||||||
|
"uuid": r["challenge_uuid"],
|
||||||
|
"kind": r["kind"],
|
||||||
|
"name": r["name"],
|
||||||
|
"status": r["status"],
|
||||||
|
"startDate": str(r["start_date"])[:10] if r["start_date"] else None,
|
||||||
|
"endDate": str(r["end_date"])[:10] if r["end_date"] else None,
|
||||||
|
"payload": payload,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- devices -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def sync_devices(client, user_id):
|
||||||
|
devices = _safe(lambda: client.get_devices(), []) or []
|
||||||
|
if isinstance(devices, dict):
|
||||||
|
devices = [devices]
|
||||||
|
|
||||||
|
execute("DELETE FROM devices WHERE user_id = ?", [user_id])
|
||||||
|
last_used = _safe(lambda: client.get_device_last_used(), {}) or {}
|
||||||
|
|
||||||
|
stored = 0
|
||||||
|
for d in devices:
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
continue
|
||||||
|
device_id = str(d.get("deviceId") or d.get("unitId") or stored)
|
||||||
|
execute(
|
||||||
|
"INSERT INTO devices (id, user_id, device_id, name, model, serial, "
|
||||||
|
"software_version, last_used_at, payload) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
[f"{user_id}-{device_id}", user_id, device_id,
|
||||||
|
d.get("displayName") or d.get("productDisplayName"),
|
||||||
|
d.get("productDisplayName") or d.get("partNumber"),
|
||||||
|
str(d.get("serialNumber") or ""),
|
||||||
|
str(d.get("softwareVersion") or ""),
|
||||||
|
_stamp(last_used.get("lastUsedDeviceUploadTime"))
|
||||||
|
if str(last_used.get("userDeviceId") or "") == device_id else None,
|
||||||
|
json.dumps(d, default=str)],
|
||||||
|
)
|
||||||
|
stored += 1
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def get_devices(user_id):
|
||||||
|
rows = query_all("SELECT * FROM devices WHERE user_id = ?", [user_id])
|
||||||
|
return [{
|
||||||
|
"deviceId": r["device_id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"model": r["model"],
|
||||||
|
"serial": r["serial"],
|
||||||
|
"softwareVersion": r["software_version"],
|
||||||
|
"lastUsedAt": str(r["last_used_at"]) if r["last_used_at"] else None,
|
||||||
|
} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-day extras folded into health_data ----------------------------------
|
||||||
|
|
||||||
|
def daily_extras(client, date):
|
||||||
|
"""Hill score, hydration and weight for one day.
|
||||||
|
|
||||||
|
Returned as columns to merge into the day's health_data row rather than
|
||||||
|
stored separately — they are daily scalars like every other metric there.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
|
||||||
|
hydration = _safe(lambda: client.get_hydration_data(date), {}) or {}
|
||||||
|
out["hydrationMl"] = _int(hydration.get("valueInML"))
|
||||||
|
out["hydrationGoalMl"] = _int(hydration.get("goalInML"))
|
||||||
|
out["sweatLossMl"] = _int(hydration.get("sweatLossInML"))
|
||||||
|
|
||||||
|
hill = _safe(lambda: client.get_hill_score(date, date), {}) or {}
|
||||||
|
scores = hill.get("hillScoreDTOList") or []
|
||||||
|
if scores:
|
||||||
|
out["hillScore"] = _int(scores[-1].get("overallScore"))
|
||||||
|
else:
|
||||||
|
out["hillScore"] = _int(hill.get("periodAvgScore"))
|
||||||
|
|
||||||
|
weigh = _safe(lambda: client.get_daily_weigh_ins(date), {}) or {}
|
||||||
|
summaries = weigh.get("dateWeightList") or []
|
||||||
|
if summaries:
|
||||||
|
grams = _num(summaries[-1].get("weight"))
|
||||||
|
out["weightKg"] = grams / 1000 if grams else None
|
||||||
|
out["bmi"] = _num(summaries[-1].get("bmi"))
|
||||||
|
out["bodyFatPct"] = _num(summaries[-1].get("bodyFat"))
|
||||||
|
|
||||||
|
return {k: v for k, v in out.items() if v is not None}
|
||||||
@@ -169,6 +169,13 @@ HEALTH_COLUMNS = {
|
|||||||
"training_readiness": "trainingReadiness",
|
"training_readiness": "trainingReadiness",
|
||||||
"vo2max": "vo2max",
|
"vo2max": "vo2max",
|
||||||
"endurance_score": "enduranceScore",
|
"endurance_score": "enduranceScore",
|
||||||
|
"hill_score": "hillScore",
|
||||||
|
"hydration_ml": "hydrationMl",
|
||||||
|
"hydration_goal_ml": "hydrationGoalMl",
|
||||||
|
"sweat_loss_ml": "sweatLossMl",
|
||||||
|
"weight_kg": "weightKg",
|
||||||
|
"body_fat_pct": "bodyFatPct",
|
||||||
|
"bmi": "bmi",
|
||||||
"blood_pressure_systolic": "bloodPressureSystolic",
|
"blood_pressure_systolic": "bloodPressureSystolic",
|
||||||
"blood_pressure_diastolic": "bloodPressureDiastolic",
|
"blood_pressure_diastolic": "bloodPressureDiastolic",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,30 @@ export const METRICS: Record<string, MetricDef> = {
|
|||||||
id: 'enduranceScore', label: '耐力分', pick: (d) => d.enduranceScore,
|
id: 'enduranceScore', label: '耐力分', pick: (d) => d.enduranceScore,
|
||||||
about: 'Garmin 由长期训练负荷与 VO₂max 推算的耐力水平,变化很慢。',
|
about: 'Garmin 由长期训练负荷与 VO₂max 推算的耐力水平,变化很慢。',
|
||||||
},
|
},
|
||||||
|
hillScore: {
|
||||||
|
id: 'hillScore', label: '爬坡分', pick: (d) => d.hillScore,
|
||||||
|
about: 'Garmin 根据爬坡时的输出功率与耐力评估的爬坡能力,只在有爬升的活动后更新。',
|
||||||
|
},
|
||||||
|
hydration: {
|
||||||
|
id: 'hydration', label: '饮水量', unit: 'ml', cumulative: true,
|
||||||
|
pick: (d) => d.hydrationMl,
|
||||||
|
about: '当天记录的饮水量,需要在 Garmin Connect 或手表上手动记录。',
|
||||||
|
},
|
||||||
|
sweatLoss: {
|
||||||
|
id: 'sweatLoss', label: '出汗量', unit: 'ml', cumulative: true,
|
||||||
|
pick: (d) => d.sweatLossMl,
|
||||||
|
about: '运动中的预估出汗量,由时长、强度与温度推算。',
|
||||||
|
},
|
||||||
|
weight: {
|
||||||
|
id: 'weight', label: '体重', unit: 'kg', decimals: 1,
|
||||||
|
pick: (d) => d.weightKg, route: '/body/',
|
||||||
|
about: '体脂秤或手动记录的体重。',
|
||||||
|
},
|
||||||
|
bodyFat: {
|
||||||
|
id: 'bodyFat', label: '体脂率', unit: '%', decimals: 1,
|
||||||
|
pick: (d) => d.bodyFatPct, route: '/body/',
|
||||||
|
about: '体脂秤用生物电阻抗估算,绝对值误差较大,看趋势更有意义。',
|
||||||
|
},
|
||||||
vo2max: {
|
vo2max: {
|
||||||
id: 'vo2max', label: 'VO₂max', unit: 'ml/kg/min', pick: (d) => d.vo2max,
|
id: 'vo2max', label: 'VO₂max', unit: 'ml/kg/min', pick: (d) => d.vo2max,
|
||||||
about: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。',
|
about: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。',
|
||||||
|
|||||||
190
client/src/pages/BodyPage.tsx
Normal file
190
client/src/pages/BodyPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'framework7-react';
|
||||||
|
import {
|
||||||
|
apiClient, BloodPressureReading, BodyCompositionDay, errorMessage,
|
||||||
|
} from '../services/api';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Chart from '../components/charts/Chart';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
|
import './MetricDetail.css';
|
||||||
|
|
||||||
|
const RANGES = [90, 180, 365, 730];
|
||||||
|
|
||||||
|
/** WHO adult BMI classes. Shown as words, never as a colour alone. */
|
||||||
|
function bmiClass(bmi: number | null) {
|
||||||
|
if (bmi == null) return null;
|
||||||
|
if (bmi < 18.5) return { label: '偏瘦', tone: 'warning' };
|
||||||
|
if (bmi < 25) return { label: '正常', tone: 'good' };
|
||||||
|
if (bmi < 30) return { label: '超重', tone: 'warning' };
|
||||||
|
return { label: '肥胖', tone: 'serious' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function BodyPage() {
|
||||||
|
const [rows, setRows] = useState<BodyCompositionDay[]>([]);
|
||||||
|
const [pressure, setPressure] = useState<BloodPressureReading[]>([]);
|
||||||
|
const [range, setRange] = useState(365);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
apiClient
|
||||||
|
.getBodyComposition(daysAgo(range - 1), todayIso())
|
||||||
|
.then((r) => { if (!cancelled) setRows(r); })
|
||||||
|
.catch((err) => { if (!cancelled) setError(errorMessage(err, '加载失败')); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.getBloodPressure().then(setPressure).catch(() => setPressure([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const latest = useMemo(
|
||||||
|
() => [...rows].reverse().find((r) => r.weightKg != null) ?? null,
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartRows = rows.map((r) => ({
|
||||||
|
date: r.date.slice(5),
|
||||||
|
weightKg: r.weightKg,
|
||||||
|
bodyFatPct: r.bodyFatPct,
|
||||||
|
muscleMassKg: r.muscleMassKg,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const verdict = bmiClass(latest?.bmi ?? null);
|
||||||
|
|
||||||
|
if (loading && rows.length === 0) {
|
||||||
|
return <Screen title="身体成分" backLink><Skeleton count={4} /></Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="身体成分" backLink>
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
{rows.length === 0 && !error ? (
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>还没有体重记录。用体脂秤同步到 Garmin Connect,或在 Connect 里手动记录后再同步。</p>
|
||||||
|
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="md-hero">
|
||||||
|
<div className="md-value">
|
||||||
|
{latest?.weightKg != null ? latest.weightKg.toFixed(1) : '—'}
|
||||||
|
<span className="md-unit">kg</span>
|
||||||
|
</div>
|
||||||
|
{verdict && (
|
||||||
|
<div className="md-verdict">
|
||||||
|
<span className={`md-tone tone-${verdict.tone}`}>{verdict.label}</span>
|
||||||
|
<span className="md-target">BMI {latest?.bmi?.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="md-when">
|
||||||
|
{latest ? `最近记录 ${latest.date}` : '暂无数据'}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="segmented-row">
|
||||||
|
<span className="segmented-label">范围</span>
|
||||||
|
<div className="segmented">
|
||||||
|
{RANGES.map((d) => (
|
||||||
|
<button key={d} className={d === range ? 'on' : ''}
|
||||||
|
onClick={() => setRange(d)}>
|
||||||
|
{d >= 365 ? `${d / 365} 年` : `${d} 天`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{latest && (
|
||||||
|
<div className="md-stats">
|
||||||
|
{([
|
||||||
|
['体脂率', latest.bodyFatPct, '%'],
|
||||||
|
['肌肉量', latest.muscleMassKg, 'kg'],
|
||||||
|
['体水分', latest.bodyWaterPct, '%'],
|
||||||
|
['骨量', latest.boneMassKg, 'kg'],
|
||||||
|
['内脏脂肪', latest.visceralFat, ''],
|
||||||
|
['代谢年龄', latest.metabolicAge, '岁'],
|
||||||
|
] as Array<[string, number | null, string]>)
|
||||||
|
.filter(([, v]) => v != null)
|
||||||
|
.map(([label, value, unit]) => (
|
||||||
|
<div className="md-stat" key={label}>
|
||||||
|
<span className="md-stat-label">{label}</span>
|
||||||
|
<span className="md-stat-value">
|
||||||
|
{value!.toFixed(1)}{unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<Chart
|
||||||
|
title="体重"
|
||||||
|
unit="kg"
|
||||||
|
data={chartRows}
|
||||||
|
type="line"
|
||||||
|
height={210}
|
||||||
|
series={[{ key: 'weightKg', label: '体重', slot: 1, unit: 'kg', decimals: 1 }]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{chartRows.some((r) => r.bodyFatPct != null) && (
|
||||||
|
<section className="sec">
|
||||||
|
<Chart
|
||||||
|
title="体脂率与肌肉量"
|
||||||
|
data={chartRows}
|
||||||
|
type="line"
|
||||||
|
height={200}
|
||||||
|
series={[
|
||||||
|
{ key: 'bodyFatPct', label: '体脂率', slot: 2, unit: '%', decimals: 1 },
|
||||||
|
{ key: 'muscleMassKg', label: '肌肉量', slot: 3, unit: 'kg', decimals: 1 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">血压</h3>
|
||||||
|
{pressure.length === 0 ? (
|
||||||
|
<p className="screen-note">
|
||||||
|
没有血压记录。Garmin 的血压数据只能在 Connect 里手动录入,
|
||||||
|
或由兼容的血压计上传。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">时间</th><th scope="col">收缩压</th>
|
||||||
|
<th scope="col">舒张压</th><th scope="col">脉搏</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pressure.map((r) => (
|
||||||
|
<tr key={r.measuredAt}>
|
||||||
|
<th scope="row">{r.measuredAt.slice(0, 16)}</th>
|
||||||
|
<td className="num">{r.systolic ?? '—'}</td>
|
||||||
|
<td className="num">{r.diastolic ?? '—'}</td>
|
||||||
|
<td className="num">{r.pulse ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="screen-disclaimer">
|
||||||
|
体脂率等身体成分由体脂秤的生物电阻抗估算,绝对值误差较大,看趋势比看单次更有意义。
|
||||||
|
</p>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BodyPage;
|
||||||
114
client/src/pages/Challenges.css
Normal file
114
client/src/pages/Challenges.css
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
.chal-list { display: grid; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.chal {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chal-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chal-kind {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chal-dates {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chal-bar {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--surface-0);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chal-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.5s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chal-pct {
|
||||||
|
margin-top: 0.22rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Race predictions ---------------------------------------------------------- */
|
||||||
|
.race-list {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-1);
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.race-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-pace { font-size: 0.72rem; color: var(--text-muted); font-weight: 400; }
|
||||||
|
|
||||||
|
.race-time {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 660;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Devices ------------------------------------------------------------------- */
|
||||||
|
.dev-list { display: grid; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.dev {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-name { font-size: 0.9rem; font-weight: 620; color: var(--text-primary); }
|
||||||
|
.dev-meta { margin-top: 0.3rem; font-size: 0.74rem; color: var(--text-muted); line-height: 1.7; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chal-fill { transition: none; }
|
||||||
|
}
|
||||||
103
client/src/pages/ChallengesPage.tsx
Normal file
103
client/src/pages/ChallengesPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'framework7-react';
|
||||||
|
import { apiClient, Challenge, errorMessage } from '../services/api';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import './Challenges.css';
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
badge: '徽章挑战',
|
||||||
|
adhoc: '好友挑战',
|
||||||
|
available: '可参加',
|
||||||
|
inprogress: '进行中',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Percentage complete, if the payload carries a target and a total. */
|
||||||
|
function progressOf(c: Challenge): number | null {
|
||||||
|
const p = c.payload || {};
|
||||||
|
const target = p.badgeTargetValue ?? p.targetValue ?? p.challengeTargetValue;
|
||||||
|
const current = p.userRankValue ?? p.badgeProgressValue ?? p.currentValue;
|
||||||
|
if (!target || current == null) return null;
|
||||||
|
return Math.min(100, Math.round((Number(current) / Number(target)) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChallengesPage() {
|
||||||
|
const [rows, setRows] = useState<Challenge[]>([]);
|
||||||
|
const [kind, setKind] = useState<string>('all');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient
|
||||||
|
.getChallenges()
|
||||||
|
.then(setRows)
|
||||||
|
.catch((err) => setError(errorMessage(err, '加载失败')))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const kinds = useMemo(
|
||||||
|
() => ['all', ...Array.from(new Set(rows.map((r) => r.kind)))],
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
const shown = kind === 'all' ? rows : rows.filter((r) => r.kind === kind);
|
||||||
|
|
||||||
|
if (loading) return <Screen title="挑战赛" backLink><Skeleton count={5} /></Screen>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="挑战赛" backLink subtitle={`共 ${rows.length} 项`}>
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>还没有挑战赛数据。</p>
|
||||||
|
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{kinds.length > 2 && (
|
||||||
|
<div className="metric-tabs">
|
||||||
|
{kinds.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
className={`metric-tab ${k === kind ? 'active' : ''}`}
|
||||||
|
onClick={() => setKind(k)}
|
||||||
|
>
|
||||||
|
{k === 'all' ? '全部' : KIND_LABEL[k] ?? k}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="chal-list">
|
||||||
|
{shown.map((c, i) => {
|
||||||
|
const pct = progressOf(c);
|
||||||
|
return (
|
||||||
|
<div className="chal" key={`${c.uuid}-${i}`}>
|
||||||
|
<div className="chal-head">
|
||||||
|
<span className="chal-name">{c.name || '未命名挑战'}</span>
|
||||||
|
<span className="chal-kind">{KIND_LABEL[c.kind] ?? c.kind}</span>
|
||||||
|
</div>
|
||||||
|
{(c.startDate || c.endDate) && (
|
||||||
|
<div className="chal-dates">
|
||||||
|
{c.startDate} {c.endDate ? `→ ${c.endDate}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pct != null && (
|
||||||
|
<>
|
||||||
|
<div className="chal-bar">
|
||||||
|
<div className="chal-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="chal-pct">{pct}%</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChallengesPage;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
import {
|
||||||
|
apiClient, Activity, DailySeries, errorMessage, HealthDay,
|
||||||
|
} from '../services/api';
|
||||||
|
import Chart from '../components/charts/Chart';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import './Daily.css';
|
import './Daily.css';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
@@ -117,17 +120,22 @@ function DailyPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [onlyRecorded, setOnlyRecorded] = useState(true);
|
const [onlyRecorded, setOnlyRecorded] = useState(true);
|
||||||
|
const [series, setSeries] = useState<DailySeries>({});
|
||||||
|
|
||||||
const load = useCallback(async (target: string) => {
|
const load = useCallback(async (target: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [summary, acts] = await Promise.all([
|
const [summary, acts, curves] = await Promise.all([
|
||||||
apiClient.getHealthSummary(target, target),
|
apiClient.getHealthSummary(target, target),
|
||||||
apiClient.getActivities(target, target),
|
apiClient.getActivities(target, target),
|
||||||
|
// Curves are optional: a day synced before they were stored simply
|
||||||
|
// has none, and the rest of the screen must still render.
|
||||||
|
apiClient.getDailySeries(target).catch(() => ({} as DailySeries)),
|
||||||
]);
|
]);
|
||||||
setDay(summary[0] ?? null);
|
setDay(summary[0] ?? null);
|
||||||
setActivities(acts);
|
setActivities(acts);
|
||||||
|
setSeries(curves);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(errorMessage(err, '加载失败'));
|
setError(errorMessage(err, '加载失败'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -163,6 +171,22 @@ function DailyPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* The stored curves are [timestamp, value] pairs; recharts wants rows, and
|
||||||
|
the axis reads better as clock time than as a full timestamp. */
|
||||||
|
const curveRows = (points: Array<[string, number]> | undefined) =>
|
||||||
|
(points ?? []).map(([at, value]) => ({
|
||||||
|
date: String(at).slice(11, 16),
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CURVES: Array<[string, string, string, 1 | 2 | 3 | 4 | 5]> = [
|
||||||
|
['heartRate', '心率', 'bpm', 1],
|
||||||
|
['stress', '压力', '', 2],
|
||||||
|
['bodyBattery', '身体电量', '', 3],
|
||||||
|
['respiration', '呼吸频率', '次/分', 4],
|
||||||
|
['spo2', '血氧', '%', 5],
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="每日数据" backLink>
|
<Screen title="每日数据" backLink>
|
||||||
|
|
||||||
@@ -245,6 +269,29 @@ function DailyPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{CURVES.some(([key]) => (series[key] ?? []).length > 0) && (
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">全天曲线</h3>
|
||||||
|
<div className="chart-grid">
|
||||||
|
{CURVES.filter(([key]) => (series[key] ?? []).length > 0).map(
|
||||||
|
([key, label, unit, slot]) => (
|
||||||
|
<Chart
|
||||||
|
key={key}
|
||||||
|
title={label}
|
||||||
|
unit={unit || undefined}
|
||||||
|
data={curveRows(series[key])}
|
||||||
|
type="area"
|
||||||
|
height={180}
|
||||||
|
series={[{
|
||||||
|
key: 'value', label, slot, unit: unit || undefined,
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="sec">
|
<section className="sec">
|
||||||
<h3 className="sec-title">
|
<h3 className="sec-title">
|
||||||
运动记录
|
运动记录
|
||||||
|
|||||||
51
client/src/pages/DevicesPage.tsx
Normal file
51
client/src/pages/DevicesPage.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'framework7-react';
|
||||||
|
import { apiClient, Device, errorMessage } from '../services/api';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import './Challenges.css';
|
||||||
|
|
||||||
|
function DevicesPage() {
|
||||||
|
const [rows, setRows] = useState<Device[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient
|
||||||
|
.getDevices()
|
||||||
|
.then(setRows)
|
||||||
|
.catch((err) => setError(errorMessage(err, '加载失败')))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <Screen title="设备" backLink><Skeleton count={3} /></Screen>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="设备" backLink>
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>还没有设备信息。</p>
|
||||||
|
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dev-list">
|
||||||
|
{rows.map((d) => (
|
||||||
|
<div className="dev" key={d.deviceId}>
|
||||||
|
<div className="dev-name">{d.name || d.model || '未知设备'}</div>
|
||||||
|
<div className="dev-meta">
|
||||||
|
{d.model && <>型号 {d.model}<br /></>}
|
||||||
|
{d.softwareVersion && <>固件 {d.softwareVersion}<br /></>}
|
||||||
|
{d.serial && <>序列号 {d.serial}<br /></>}
|
||||||
|
{d.lastUsedAt && <>最后同步 {d.lastUsedAt.slice(0, 16)}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DevicesPage;
|
||||||
@@ -175,6 +175,19 @@ function ExercisePage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<div className="trend-tools">
|
||||||
|
<Link href="/challenges/" className="picker-open picker-open-link">
|
||||||
|
<span className="picker-open-label">挑战赛</span>
|
||||||
|
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/race/" className="picker-open picker-open-link">
|
||||||
|
<span className="picker-open-label">成绩预测</span>
|
||||||
|
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="metric-tabs">
|
<div className="metric-tabs">
|
||||||
{([
|
{([
|
||||||
['activities', `记录 (${activities.length})`],
|
['activities', `记录 (${activities.length})`],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Skeleton from '../components/Skeleton';
|
|||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
import { daysAgo, today as todayIso } from '../lib/day';
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
import './Health.css';
|
import './Health.css';
|
||||||
|
import './Settings.css';
|
||||||
|
|
||||||
const WINDOW_DAYS = 30;
|
const WINDOW_DAYS = 30;
|
||||||
|
|
||||||
@@ -18,6 +19,14 @@ const SECTIONS: Array<{ title: string; items: string[] }> = [
|
|||||||
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
|
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
|
||||||
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
|
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
|
||||||
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
|
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
|
||||||
|
{ title: '身体成分', items: ['weight', 'bodyFat', 'hydration', 'hillScore'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Screens that are not a single metric, so they get their own entries. */
|
||||||
|
const LINKS: Array<[string, string, string]> = [
|
||||||
|
['/body/', '身体成分与血压', '体重、体脂、肌肉量、血压记录'],
|
||||||
|
['/race/', '成绩预测', '5 公里到全马的预测完赛时间'],
|
||||||
|
['/challenges/', '挑战赛', '徽章挑战与好友挑战'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function BodyAge({ data }: { data: FitnessAge | null }) {
|
function BodyAge({ data }: { data: FitnessAge | null }) {
|
||||||
@@ -161,6 +170,21 @@ function HealthPage() {
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">更多</h3>
|
||||||
|
<div className="set-rows">
|
||||||
|
{LINKS.map(([href, label, sub]) => (
|
||||||
|
<Link href={href} className="set-row" key={href}>
|
||||||
|
<span className="set-label">
|
||||||
|
{label}
|
||||||
|
<span className="set-sub">{sub}</span>
|
||||||
|
</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p className="screen-disclaimer">
|
<p className="screen-disclaimer">
|
||||||
参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
|
参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
117
client/src/pages/RacePage.tsx
Normal file
117
client/src/pages/RacePage.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'framework7-react';
|
||||||
|
import { apiClient, errorMessage, RacePrediction } from '../services/api';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Chart from '../components/charts/Chart';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import './MetricDetail.css';
|
||||||
|
|
||||||
|
const DISTANCES: Array<[keyof RacePrediction, string, number]> = [
|
||||||
|
['time5k', '5 公里', 1],
|
||||||
|
['time10k', '10 公里', 2],
|
||||||
|
['timeHalf', '半程马拉松', 3],
|
||||||
|
['timeMarathon', '全程马拉松', 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
const hms = (seconds?: number | null) => {
|
||||||
|
if (!seconds) return '—';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.round(seconds % 60);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pace per kilometre for a predicted finish. */
|
||||||
|
const pace = (seconds: number | null | undefined, km: number) => {
|
||||||
|
if (!seconds) return '—';
|
||||||
|
const perKm = seconds / km;
|
||||||
|
return `${Math.floor(perKm / 60)}:${String(Math.round(perKm % 60)).padStart(2, '0')} /km`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KM: Record<string, number> = {
|
||||||
|
time5k: 5, time10k: 10, timeHalf: 21.0975, timeMarathon: 42.195,
|
||||||
|
};
|
||||||
|
|
||||||
|
function RacePage() {
|
||||||
|
const [rows, setRows] = useState<RacePrediction[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient
|
||||||
|
.getRacePredictions()
|
||||||
|
.then(setRows)
|
||||||
|
.catch((err) => setError(errorMessage(err, '加载失败')))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <Screen title="成绩预测" backLink><Skeleton count={3} /></Screen>;
|
||||||
|
|
||||||
|
const latest = rows[rows.length - 1];
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
return (
|
||||||
|
<Screen title="成绩预测" backLink>
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>还没有成绩预测。Garmin 需要几次户外跑步才会给出预测。</p>
|
||||||
|
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minutes rather than seconds on the axis: a marathon in seconds is a
|
||||||
|
// five-digit number that tells the reader nothing at a glance.
|
||||||
|
const chartRows = rows.map((r) => ({
|
||||||
|
date: r.date.slice(5),
|
||||||
|
time5k: r.time5k ? +(r.time5k / 60).toFixed(1) : null,
|
||||||
|
time10k: r.time10k ? +(r.time10k / 60).toFixed(1) : null,
|
||||||
|
timeHalf: r.timeHalf ? +(r.timeHalf / 60).toFixed(1) : null,
|
||||||
|
timeMarathon: r.timeMarathon ? +(r.timeMarathon / 60).toFixed(1) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="成绩预测" backLink subtitle={latest.date}>
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="race-list">
|
||||||
|
{DISTANCES.map(([key, label]) => (
|
||||||
|
<div className="race-row" key={key}>
|
||||||
|
<div className="race-name">
|
||||||
|
{label}
|
||||||
|
<span className="race-pace">
|
||||||
|
{pace(latest[key] as number | null, KM[key as string])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="race-time">{hms(latest[key] as number | null)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length > 1 && (
|
||||||
|
<section className="sec">
|
||||||
|
<Chart
|
||||||
|
title="预测成绩变化"
|
||||||
|
unit="分钟"
|
||||||
|
data={chartRows}
|
||||||
|
type="line"
|
||||||
|
height={220}
|
||||||
|
series={DISTANCES.map(([key, label, slot]) => ({
|
||||||
|
key: key as string, label, slot: slot as 1 | 2 | 3 | 4,
|
||||||
|
unit: '分钟', decimals: 1,
|
||||||
|
}))}
|
||||||
|
footer="向下走表示预测成绩在变快。"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="screen-disclaimer">
|
||||||
|
预测由 Garmin 根据 VO₂max 与近期训练负荷推算,假设你按对应距离完成了针对性训练,
|
||||||
|
实际成绩受配速策略、天气与赛道影响。
|
||||||
|
</p>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RacePage;
|
||||||
@@ -261,6 +261,16 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">设备</h3>
|
||||||
|
<div className="set-rows">
|
||||||
|
<Link href="/devices/" className="set-row">
|
||||||
|
<span className="set-label">已配对设备</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{FEATURES.ai && models.length > 0 && (
|
{FEATURES.ai && models.length > 0 && (
|
||||||
<section className="sec">
|
<section className="sec">
|
||||||
<h3 className="sec-title">AI 模型</h3>
|
<h3 className="sec-title">AI 模型</h3>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import MetricDetailPage from './pages/MetricDetailPage';
|
|||||||
import ActivityDetailPage from './pages/ActivityDetailPage';
|
import ActivityDetailPage from './pages/ActivityDetailPage';
|
||||||
import BodyAgePage from './pages/BodyAgePage';
|
import BodyAgePage from './pages/BodyAgePage';
|
||||||
import RatingBasisPage from './pages/RatingBasisPage';
|
import RatingBasisPage from './pages/RatingBasisPage';
|
||||||
|
import BodyPage from './pages/BodyPage';
|
||||||
|
import RacePage from './pages/RacePage';
|
||||||
|
import ChallengesPage from './pages/ChallengesPage';
|
||||||
|
import DevicesPage from './pages/DevicesPage';
|
||||||
import ExercisePage from './pages/ExercisePage';
|
import ExercisePage from './pages/ExercisePage';
|
||||||
import SleepPage from './pages/SleepPage';
|
import SleepPage from './pages/SleepPage';
|
||||||
import SyncPage from './pages/SyncPage';
|
import SyncPage from './pages/SyncPage';
|
||||||
@@ -34,6 +38,10 @@ const SCREENS: Router.RouteParameters[] = [
|
|||||||
{ path: '/activity/:id/', component: ActivityDetailPage },
|
{ path: '/activity/:id/', component: ActivityDetailPage },
|
||||||
{ path: '/body-age/', component: BodyAgePage },
|
{ path: '/body-age/', component: BodyAgePage },
|
||||||
{ path: '/rating-basis/', component: RatingBasisPage },
|
{ path: '/rating-basis/', component: RatingBasisPage },
|
||||||
|
{ path: '/body/', component: BodyPage },
|
||||||
|
{ path: '/race/', component: RacePage },
|
||||||
|
{ path: '/challenges/', component: ChallengesPage },
|
||||||
|
{ path: '/devices/', component: DevicesPage },
|
||||||
{ path: '/sync/', component: SyncPage },
|
{ path: '/sync/', component: SyncPage },
|
||||||
{ path: '/settings/', component: SettingsPage },
|
{ path: '/settings/', component: SettingsPage },
|
||||||
{ path: '/login/', component: LoginPage },
|
{ path: '/login/', component: LoginPage },
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ export interface HealthDay {
|
|||||||
sleepStressAvg: number | null;
|
sleepStressAvg: number | null;
|
||||||
trainingReadiness: number | null;
|
trainingReadiness: number | null;
|
||||||
vo2max: number | null;
|
vo2max: number | null;
|
||||||
|
hillScore: number | null;
|
||||||
|
hydrationMl: number | null;
|
||||||
|
hydrationGoalMl: number | null;
|
||||||
|
sweatLossMl: number | null;
|
||||||
|
weightKg: number | null;
|
||||||
|
bodyFatPct: number | null;
|
||||||
|
bmi: number | null;
|
||||||
enduranceScore: number | null;
|
enduranceScore: number | null;
|
||||||
sleep: SleepDetail | null;
|
sleep: SleepDetail | null;
|
||||||
}
|
}
|
||||||
@@ -211,11 +218,65 @@ export interface FitnessAge {
|
|||||||
|
|
||||||
export interface DetailSyncStatus {
|
export interface DetailSyncStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
/** Which part of the backfill is running: 运动详情 / 每日曲线. */
|
||||||
|
stage?: string | null;
|
||||||
done: number;
|
done: number;
|
||||||
total: number;
|
total: number;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BodyCompositionDay {
|
||||||
|
date: string;
|
||||||
|
weightKg: number | null;
|
||||||
|
bmi: number | null;
|
||||||
|
bodyFatPct: number | null;
|
||||||
|
bodyWaterPct: number | null;
|
||||||
|
boneMassKg: number | null;
|
||||||
|
muscleMassKg: number | null;
|
||||||
|
physiqueRating: number | null;
|
||||||
|
visceralFat: number | null;
|
||||||
|
metabolicAge: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BloodPressureReading {
|
||||||
|
measuredAt: string;
|
||||||
|
systolic: number | null;
|
||||||
|
diastolic: number | null;
|
||||||
|
pulse: number | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Predicted finishing times, in seconds. */
|
||||||
|
export interface RacePrediction {
|
||||||
|
date: string;
|
||||||
|
time5k: number | null;
|
||||||
|
time10k: number | null;
|
||||||
|
timeHalf: number | null;
|
||||||
|
timeMarathon: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [timestamp, value] pairs, thinned to at most 240 points per day. */
|
||||||
|
export type DailySeries = Record<string, Array<[string, number]>>;
|
||||||
|
|
||||||
|
export interface Challenge {
|
||||||
|
uuid: string;
|
||||||
|
kind: string;
|
||||||
|
name: string | null;
|
||||||
|
status: string | null;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
payload: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
deviceId: string;
|
||||||
|
name: string | null;
|
||||||
|
model: string | null;
|
||||||
|
serial: string | null;
|
||||||
|
softwareVersion: string | null;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AutoSyncStatus {
|
export interface AutoSyncStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
intervalSeconds: number;
|
intervalSeconds: number;
|
||||||
@@ -511,6 +572,44 @@ class ApiClient {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBodyComposition(startDate?: string, endDate?: string) {
|
||||||
|
const { data } = await this.client.get<BodyCompositionDay[]>(
|
||||||
|
'/health/body-composition', this.range(startDate, endDate)
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBloodPressure() {
|
||||||
|
const { data } = await this.client.get<BloodPressureReading[]>(
|
||||||
|
'/health/blood-pressure'
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRacePredictions() {
|
||||||
|
const { data } = await this.client.get<RacePrediction[]>(
|
||||||
|
'/health/race-predictions'
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDailySeries(date: string) {
|
||||||
|
const { data } = await this.client.get<DailySeries>(
|
||||||
|
'/health/series', { params: { date } }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChallenges() {
|
||||||
|
const { data } = await this.client.get<Challenge[]>('/health/challenges');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDevices() {
|
||||||
|
const { data } = await this.client.get<Device[]>('/health/devices');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async getBadges() {
|
async getBadges() {
|
||||||
const { data } = await this.client.get<Badge[]>('/health/badges');
|
const { data } = await this.client.get<Badge[]>('/health/badges');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -57,6 +57,31 @@
|
|||||||
| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表四个 Tab,含心率区间条与时间/距离横轴切换 | ✅ |
|
| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表四个 Tab,含心率区间条与时间/距离横轴切换 | ✅ |
|
||||||
| 4.9 | 健康页增加身体年龄 | 健康页卡片 + `/body-age/` 展示每一步推算过程与出处 | ✅ |
|
| 4.9 | 健康页增加身体年龄 | 健康页卡片 + `/body-age/` 展示每一步推算过程与出处 | ✅ |
|
||||||
|
|
||||||
|
## 四之二、补齐未同步的数据(2026-08-24)
|
||||||
|
|
||||||
|
审计:garminconnect 0.2.8 共 57 个 `get_*`,原先只用了 16 个。只读探测后确认
|
||||||
|
账号里真有数据、却从未入库的部分如下,全部已加入同步模块并配了界面。
|
||||||
|
|
||||||
|
| # | 数据 | 接口 | 存放 | 界面 | 状态 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 4.10 | 体重与身体成分 | `get_body_composition` | `body_composition` 表 | `/body/` 身体成分 | ✅ |
|
||||||
|
| 4.11 | 血压 | `get_blood_pressure` | `blood_pressure` 表 | `/body/` 内表格 | ✅ 接口通,账号暂无数据 |
|
||||||
|
| 4.12 | 跑步成绩预测 | `get_race_predictions` | `race_predictions` 表 | `/race/` 成绩预测 | ✅ |
|
||||||
|
| 4.13 | 爬坡分 | `get_hill_score` | `health_data.hill_score` | 指标详情 | ✅ |
|
||||||
|
| 4.14 | 饮水与出汗 | `get_hydration_data` | `health_data` 三列 | 指标详情 | ✅ |
|
||||||
|
| 4.15 | 全天曲线(心率/压力/身体电量/呼吸/血氧) | 五个日内接口 | `daily_series` 表 | 每日页「全天曲线」 | ✅ |
|
||||||
|
| 4.16 | 挑战赛 | `get_badge_challenges` 等四个 | `challenges` 表 | `/challenges/` | ✅ |
|
||||||
|
| 4.17 | 设备 | `get_devices` | `devices` 表 | 设置 → 已配对设备 | ✅ |
|
||||||
|
|
||||||
|
**探测为空、未做界面**:`get_max_metrics`(VO₂max 已从训练状态取到)、
|
||||||
|
`get_goals`、`get_inprogress_virtual_challenges`。
|
||||||
|
**判定为重复**:`get_stats`/`get_steps_data`/`get_floors`/`get_stress_data` 等
|
||||||
|
日聚合接口,数据已在 `health_data`;`get_activities`(分页);
|
||||||
|
`get_device_settings`/`get_gear_defaults` 等配置类接口。
|
||||||
|
|
||||||
|
同步开销:日内曲线每天五个请求,因此 14 天以内的同步顺带拉取,更长的历史
|
||||||
|
交给「补齐详细数据」后台任务,否则一年的同步会多出约 1800 个请求。
|
||||||
|
|
||||||
## 五、设置
|
## 五、设置
|
||||||
|
|
||||||
| # | 需求 | 理解 | 状态 |
|
| # | 需求 | 理解 | 状态 |
|
||||||
|
|||||||
Reference in New Issue
Block a user