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:
ericwyuan
2026-08-24 04:16:33 +08:00
parent 34940cc387
commit f1319a6171
19 changed files with 1520 additions and 29 deletions

View File

@@ -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"),
], ],
} }

View File

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

View File

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

View File

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

View 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}

View File

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

View File

@@ -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: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。',

View 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;

View 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; }
}

View 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;

View File

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

View 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;

View File

@@ -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})`],

View File

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

View 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 VOmax
</p>
</Screen>
);
}
export default RacePage;

View File

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

View File

@@ -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 },

View File

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

View File

@@ -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 个请求。
## 五、设置 ## 五、设置
| # | 需求 | 理解 | 状态 | | # | 需求 | 理解 | 状态 |