diff --git a/backend/db.py b/backend/db.py index 53c8b17..ee09df5 100644 --- a/backend/db.py +++ b/backend/db.py @@ -188,6 +188,97 @@ CREATE TABLE IF NOT EXISTS activity_details ( 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 -- 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. @@ -349,6 +440,15 @@ MIGRATIONS = { ("training_readiness", "INT"), ("vo2max", "DOUBLE"), ("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"), ], } diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index 713ef62..859aad0 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -171,7 +171,7 @@ def activity_detail(activity_id): @bp.route("/sync-details", methods=["POST"]) @require_auth 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): return jsonify({"error": "尚未绑定 Garmin 账号"}), 400 @@ -181,10 +181,10 @@ def sync_details(): except (TypeError, ValueError): 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"]) @require_auth def sync_details_status(): - return jsonify(garmin_svc.detail_sync_status(g.user_id)) + return jsonify(garmin_svc.backfill_status(g.user_id)) diff --git a/backend/routes/health.py b/backend/routes/health.py index 697474c..4058e18 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -7,6 +7,7 @@ from auth import require_auth from services import health as health_svc from services import settings as settings_svc from services import fitness_age +from services import garmin_extras as extras bp = Blueprint("health", __name__) @@ -88,3 +89,47 @@ def badges(): @require_auth def personal_records(): 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)) diff --git a/backend/services/garmin.py b/backend/services/garmin.py index 5c62a3c..153b26f 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -27,6 +27,7 @@ import threading from db import execute, query_one, query_all from config import DB_TYPE from services import health +from services import garmin_extras as extras # How many days back a sync reaches. 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 -_detail_progress = {} +_backfill_progress = {} -def detail_sync_status(user_id): - """Progress of the detail backfill for this account.""" - return _detail_progress.get(user_id) or {"running": False, "done": 0, "total": 0} +def backfill_status(user_id): + """Progress of the historical backfill for this account.""" + 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): - """Backfill activity details in the background. +def _set_backfill(user_id, **fields): + 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"): 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(): try: client = _connect({}, user_id=user_id) - def progress(done, total): - _detail_progress[user_id] = { - "running": True, "done": done, "total": total, "error": None, - } + _set_backfill(user_id, stage="运动详情", done=0, total=0) + sync_activity_details( + 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) - _detail_progress[user_id] = { - "running": False, "done": stored, - "total": _detail_progress[user_id].get("total", stored), "error": None, - } + dates = days_missing_series(user_id, limit) + _set_backfill(user_id, stage="每日曲线", done=0, total=len(dates)) + for i, date in enumerate(dates): + 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 - _detail_progress[user_id] = { - "running": False, "done": 0, "total": 0, "error": describe(e), - } + _set_backfill(user_id, running=False, stage=None, error=describe(e)) - threading.Thread(target=run, daemon=True, name=f"detail-sync-{user_id}").start() - return _detail_progress[user_id] + threading.Thread(target=run, daemon=True, name=f"backfill-{user_id}").start() + 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 @@ -749,6 +785,7 @@ def sync_data(user_id, creds, days=None, client=None): date_str = (today - datetime.timedelta(days=i)).isoformat() try: record = _extract_daily(client, date_str) + record.update(extras.daily_extras(client, date_str)) except Exception as e: day_errors.append(f"{date_str}: {describe(e)}") 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"): health.upsert_health_daily(user_id, record) 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 # 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: 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 # they are fetched once per sync rather than inside the day loop. badges_synced = 0 diff --git a/backend/services/garmin_extras.py b/backend/services/garmin_extras.py new file mode 100644 index 0000000..59a1a30 --- /dev/null +++ b/backend/services/garmin_extras.py @@ -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} diff --git a/backend/services/health.py b/backend/services/health.py index d47fd7b..997ee48 100644 --- a/backend/services/health.py +++ b/backend/services/health.py @@ -169,6 +169,13 @@ HEALTH_COLUMNS = { "training_readiness": "trainingReadiness", "vo2max": "vo2max", "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_diastolic": "bloodPressureDiastolic", } diff --git a/client/src/lib/metrics.ts b/client/src/lib/metrics.ts index 498ba38..9f84699 100644 --- a/client/src/lib/metrics.ts +++ b/client/src/lib/metrics.ts @@ -129,6 +129,30 @@ export const METRICS: Record = { id: 'enduranceScore', label: '耐力分', pick: (d) => d.enduranceScore, 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: { id: 'vo2max', label: 'VO₂max', unit: 'ml/kg/min', pick: (d) => d.vo2max, about: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。', diff --git a/client/src/pages/BodyPage.tsx b/client/src/pages/BodyPage.tsx new file mode 100644 index 0000000..e8e076f --- /dev/null +++ b/client/src/pages/BodyPage.tsx @@ -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([]); + const [pressure, setPressure] = useState([]); + 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 ; + } + + return ( + + {error &&
{error}
} + + {rows.length === 0 && !error ? ( +
+

还没有体重记录。用体脂秤同步到 Garmin Connect,或在 Connect 里手动记录后再同步。

+ 去同步 +
+ ) : ( + <> +
+
+ {latest?.weightKg != null ? latest.weightKg.toFixed(1) : '—'} + kg +
+ {verdict && ( +
+ {verdict.label} + BMI {latest?.bmi?.toFixed(1)} +
+ )} +
+ {latest ? `最近记录 ${latest.date}` : '暂无数据'} +
+
+ +
+ 范围 +
+ {RANGES.map((d) => ( + + ))} +
+
+ + {latest && ( +
+ {([ + ['体脂率', 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]) => ( +
+ {label} + + {value!.toFixed(1)}{unit} + +
+ ))} +
+ )} + +
+ +
+ + {chartRows.some((r) => r.bodyFatPct != null) && ( +
+ +
+ )} + + )} + +
+

血压

+ {pressure.length === 0 ? ( +

+ 没有血压记录。Garmin 的血压数据只能在 Connect 里手动录入, + 或由兼容的血压计上传。 +

+ ) : ( +
+ + + + + + + + + {pressure.map((r) => ( + + + + + + + ))} + +
时间收缩压舒张压脉搏
{r.measuredAt.slice(0, 16)}{r.systolic ?? '—'}{r.diastolic ?? '—'}{r.pulse ?? '—'}
+
+ )} +
+ +

+ 体脂率等身体成分由体脂秤的生物电阻抗估算,绝对值误差较大,看趋势比看单次更有意义。 +

+
+ ); +} + +export default BodyPage; diff --git a/client/src/pages/Challenges.css b/client/src/pages/Challenges.css new file mode 100644 index 0000000..64c7e4b --- /dev/null +++ b/client/src/pages/Challenges.css @@ -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; } +} diff --git a/client/src/pages/ChallengesPage.tsx b/client/src/pages/ChallengesPage.tsx new file mode 100644 index 0000000..db58a6e --- /dev/null +++ b/client/src/pages/ChallengesPage.tsx @@ -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 = { + 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([]); + const [kind, setKind] = useState('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 ; + + return ( + + {error &&
{error}
} + + {rows.length === 0 ? ( +
+

还没有挑战赛数据。

+ 去同步 +
+ ) : ( + <> + {kinds.length > 2 && ( +
+ {kinds.map((k) => ( + + ))} +
+ )} + +
+ {shown.map((c, i) => { + const pct = progressOf(c); + return ( +
+
+ {c.name || '未命名挑战'} + {KIND_LABEL[c.kind] ?? c.kind} +
+ {(c.startDate || c.endDate) && ( +
+ {c.startDate} {c.endDate ? `→ ${c.endDate}` : ''} +
+ )} + {pct != null && ( + <> +
+
+
+
{pct}%
+ + )} +
+ ); + })} +
+ + )} + + ); +} + +export default ChallengesPage; diff --git a/client/src/pages/DailyPage.tsx b/client/src/pages/DailyPage.tsx index 3ed4feb..4cd42b9 100644 --- a/client/src/pages/DailyPage.tsx +++ b/client/src/pages/DailyPage.tsx @@ -1,5 +1,8 @@ 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 './Daily.css'; import Screen from '../components/Screen'; @@ -117,17 +120,22 @@ function DailyPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [onlyRecorded, setOnlyRecorded] = useState(true); + const [series, setSeries] = useState({}); const load = useCallback(async (target: string) => { setLoading(true); setError(''); try { - const [summary, acts] = await Promise.all([ + const [summary, acts, curves] = await Promise.all([ apiClient.getHealthSummary(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); setActivities(acts); + setSeries(curves); } catch (err: any) { setError(errorMessage(err, '加载失败')); } 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 ( @@ -245,6 +269,29 @@ function DailyPage() { ); })} + {CURVES.some(([key]) => (series[key] ?? []).length > 0) && ( +
+

全天曲线

+
+ {CURVES.filter(([key]) => (series[key] ?? []).length > 0).map( + ([key, label, unit, slot]) => ( + + ) + )} +
+
+ )} +

运动记录 diff --git a/client/src/pages/DevicesPage.tsx b/client/src/pages/DevicesPage.tsx new file mode 100644 index 0000000..c6fdb5b --- /dev/null +++ b/client/src/pages/DevicesPage.tsx @@ -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([]); + 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 ; + + return ( + + {error &&
{error}
} + + {rows.length === 0 ? ( +
+

还没有设备信息。

+ 去同步 +
+ ) : ( +
+ {rows.map((d) => ( +
+
{d.name || d.model || '未知设备'}
+
+ {d.model && <>型号 {d.model}
} + {d.softwareVersion && <>固件 {d.softwareVersion}
} + {d.serial && <>序列号 {d.serial}
} + {d.lastUsedAt && <>最后同步 {d.lastUsedAt.slice(0, 16)}} +
+
+ ))} +
+ )} +
+ ); +} + +export default DevicesPage; diff --git a/client/src/pages/ExercisePage.tsx b/client/src/pages/ExercisePage.tsx index 2a74be0..30c5ed5 100644 --- a/client/src/pages/ExercisePage.tsx +++ b/client/src/pages/ExercisePage.tsx @@ -175,6 +175,19 @@ function ExercisePage() { />

+
+
+ + 挑战赛 + + + + 成绩预测 + + +
+
+
{([ ['activities', `记录 (${activities.length})`], diff --git a/client/src/pages/HealthPage.tsx b/client/src/pages/HealthPage.tsx index 1555b45..1b6653e 100644 --- a/client/src/pages/HealthPage.tsx +++ b/client/src/pages/HealthPage.tsx @@ -7,6 +7,7 @@ import Skeleton from '../components/Skeleton'; import Screen from '../components/Screen'; import { daysAgo, today as todayIso } from '../lib/day'; import './Health.css'; +import './Settings.css'; const WINDOW_DAYS = 30; @@ -18,6 +19,14 @@ const SECTIONS: Array<{ title: string; items: string[] }> = [ { title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] }, { title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] }, { 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 }) { @@ -161,6 +170,21 @@ function HealthPage() { ))} +
+

更多

+
+ {LINKS.map(([href, label, sub]) => ( + + + {label} + {sub} + + + + ))} +
+
+

参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。

diff --git a/client/src/pages/RacePage.tsx b/client/src/pages/RacePage.tsx new file mode 100644 index 0000000..1212cfd --- /dev/null +++ b/client/src/pages/RacePage.tsx @@ -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 = { + time5k: 5, time10k: 10, timeHalf: 21.0975, timeMarathon: 42.195, +}; + +function RacePage() { + const [rows, setRows] = useState([]); + 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 ; + + const latest = rows[rows.length - 1]; + + if (!latest) { + return ( + +
+

还没有成绩预测。Garmin 需要几次户外跑步才会给出预测。

+ 去同步 +
+
+ ); + } + + // 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 ( + + {error &&
{error}
} + +
+ {DISTANCES.map(([key, label]) => ( +
+
+ {label} + + {pace(latest[key] as number | null, KM[key as string])} + +
+
{hms(latest[key] as number | null)}
+
+ ))} +
+ + {rows.length > 1 && ( +
+ ({ + key: key as string, label, slot: slot as 1 | 2 | 3 | 4, + unit: '分钟', decimals: 1, + }))} + footer="向下走表示预测成绩在变快。" + /> +
+ )} + +

+ 预测由 Garmin 根据 VO₂max 与近期训练负荷推算,假设你按对应距离完成了针对性训练, + 实际成绩受配速策略、天气与赛道影响。 +

+
+ ); +} + +export default RacePage; diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 17faf88..3bc20fb 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -261,6 +261,16 @@ function SettingsPage() {
+
+

设备

+
+ + 已配对设备 + + +
+
+ {FEATURES.ai && models.length > 0 && (

AI 模型

diff --git a/client/src/routes.ts b/client/src/routes.ts index f25020f..e8b1a9f 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -8,6 +8,10 @@ import MetricDetailPage from './pages/MetricDetailPage'; import ActivityDetailPage from './pages/ActivityDetailPage'; import BodyAgePage from './pages/BodyAgePage'; 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 SleepPage from './pages/SleepPage'; import SyncPage from './pages/SyncPage'; @@ -34,6 +38,10 @@ const SCREENS: Router.RouteParameters[] = [ { path: '/activity/:id/', component: ActivityDetailPage }, { path: '/body-age/', component: BodyAgePage }, { 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: '/settings/', component: SettingsPage }, { path: '/login/', component: LoginPage }, diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 0993ea8..b357804 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -58,6 +58,13 @@ export interface HealthDay { sleepStressAvg: number | null; trainingReadiness: 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; sleep: SleepDetail | null; } @@ -211,11 +218,65 @@ export interface FitnessAge { export interface DetailSyncStatus { running: boolean; + /** Which part of the backfill is running: 运动详情 / 每日曲线. */ + stage?: string | null; done: number; total: number; 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>; + +export interface Challenge { + uuid: string; + kind: string; + name: string | null; + status: string | null; + startDate: string | null; + endDate: string | null; + payload: Record; +} + +export interface Device { + deviceId: string; + name: string | null; + model: string | null; + serial: string | null; + softwareVersion: string | null; + lastUsedAt: string | null; +} + export interface AutoSyncStatus { enabled: boolean; intervalSeconds: number; @@ -511,6 +572,44 @@ class ApiClient { return data; } + async getBodyComposition(startDate?: string, endDate?: string) { + const { data } = await this.client.get( + '/health/body-composition', this.range(startDate, endDate) + ); + return data; + } + + async getBloodPressure() { + const { data } = await this.client.get( + '/health/blood-pressure' + ); + return data; + } + + async getRacePredictions() { + const { data } = await this.client.get( + '/health/race-predictions' + ); + return data; + } + + async getDailySeries(date: string) { + const { data } = await this.client.get( + '/health/series', { params: { date } } + ); + return data; + } + + async getChallenges() { + const { data } = await this.client.get('/health/challenges'); + return data; + } + + async getDevices() { + const { data } = await this.client.get('/health/devices'); + return data; + } + async getBadges() { const { data } = await this.client.get('/health/badges'); return data; diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 419ea5a..7defe34 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -57,6 +57,31 @@ | 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表四个 Tab,含心率区间条与时间/距离横轴切换 | ✅ | | 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 个请求。 + ## 五、设置 | # | 需求 | 理解 | 状态 |