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:
@@ -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
|
||||
|
||||
449
backend/services/garmin_extras.py
Normal file
449
backend/services/garmin_extras.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
The rest of what Garmin holds.
|
||||
|
||||
The original sync covered daily totals, activities, badges and personal
|
||||
records — 16 of the library's 57 endpoints. Everything here is data the
|
||||
account actually has that was simply never being stored: body composition,
|
||||
hill score, race predictions, hydration, the within-day sample series, and
|
||||
challenges and devices.
|
||||
|
||||
All of it lands in the local database during a sync, so no screen ever has to
|
||||
reach Garmin to draw itself.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
|
||||
from db import execute, query_one, query_all
|
||||
from config import DB_TYPE
|
||||
|
||||
|
||||
# --- small helpers -----------------------------------------------------------
|
||||
|
||||
def _num(*values):
|
||||
for v in values:
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _int(*values):
|
||||
n = _num(*values)
|
||||
return int(n) if n is not None else None
|
||||
|
||||
|
||||
def _safe(fn, default=None):
|
||||
try:
|
||||
return fn()
|
||||
except Exception: # noqa: BLE001 - a missing feature must not fail a sync
|
||||
return default
|
||||
|
||||
|
||||
def _day(value):
|
||||
"""Garmin dates arrive as ISO strings, epoch millis, or already-dates."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, datetime.date):
|
||||
return value.isoformat()
|
||||
text = str(value)
|
||||
if text.isdigit():
|
||||
seconds = int(text) / (1000 if len(text) > 10 else 1)
|
||||
return datetime.datetime.utcfromtimestamp(seconds).date().isoformat()
|
||||
return text[:10]
|
||||
|
||||
|
||||
def _stamp(value):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
text = str(value)
|
||||
if text.isdigit():
|
||||
seconds = int(text) / (1000 if len(text) > 10 else 1)
|
||||
return datetime.datetime.utcfromtimestamp(seconds).isoformat(timespec="seconds")
|
||||
return text.replace("T", " ")[:19]
|
||||
|
||||
|
||||
def _upsert(table, key_cols, cols, values):
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
updatable = [c for c in cols if c not in key_cols]
|
||||
if DB_TYPE == "mariadb":
|
||||
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
|
||||
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON DUPLICATE KEY UPDATE {updates}")
|
||||
else:
|
||||
conflict = ", ".join(key_cols)
|
||||
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
|
||||
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT({conflict}) DO UPDATE SET {updates}")
|
||||
execute(sql, values)
|
||||
|
||||
|
||||
# --- body composition --------------------------------------------------------
|
||||
|
||||
def sync_body_composition(client, user_id, start, end):
|
||||
"""Weight and everything a connected scale reports with it."""
|
||||
data = _safe(lambda: client.get_body_composition(start, end), {}) or {}
|
||||
rows = data.get("dateWeightList") or []
|
||||
|
||||
stored = 0
|
||||
for row in rows:
|
||||
date = _day(row.get("calendarDate") or row.get("date"))
|
||||
if not date:
|
||||
continue
|
||||
# Garmin stores weight in grams.
|
||||
grams = _num(row.get("weight"))
|
||||
_upsert(
|
||||
"body_composition", ("user_id", "date"),
|
||||
["id", "user_id", "date", "weight_kg", "bmi", "body_fat_pct",
|
||||
"body_water_pct", "bone_mass_kg", "muscle_mass_kg",
|
||||
"physique_rating", "visceral_fat", "metabolic_age", "source"],
|
||||
[f"{user_id}-{date}", user_id, date,
|
||||
grams / 1000 if grams else None,
|
||||
_num(row.get("bmi")),
|
||||
_num(row.get("bodyFat")),
|
||||
_num(row.get("bodyWater")),
|
||||
(_num(row.get("boneMass")) or 0) / 1000 or None,
|
||||
(_num(row.get("muscleMass")) or 0) / 1000 or None,
|
||||
_num(row.get("physiqueRating")),
|
||||
_num(row.get("visceralFat")),
|
||||
_num(row.get("metabolicAge")),
|
||||
row.get("sourceType")],
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def get_body_composition(user_id, start=None, end=None):
|
||||
sql = "WHERE user_id = ?"
|
||||
params = [user_id]
|
||||
if start:
|
||||
sql += " AND date >= ?"
|
||||
params.append(start)
|
||||
if end:
|
||||
sql += " AND date <= ?"
|
||||
params.append(end)
|
||||
rows = query_all(
|
||||
f"SELECT * FROM body_composition {sql} ORDER BY date ASC", params
|
||||
)
|
||||
return [{
|
||||
"date": str(r["date"])[:10],
|
||||
"weightKg": r["weight_kg"],
|
||||
"bmi": r["bmi"],
|
||||
"bodyFatPct": r["body_fat_pct"],
|
||||
"bodyWaterPct": r["body_water_pct"],
|
||||
"boneMassKg": r["bone_mass_kg"],
|
||||
"muscleMassKg": r["muscle_mass_kg"],
|
||||
"physiqueRating": r["physique_rating"],
|
||||
"visceralFat": r["visceral_fat"],
|
||||
"metabolicAge": r["metabolic_age"],
|
||||
} for r in rows]
|
||||
|
||||
|
||||
# --- blood pressure ----------------------------------------------------------
|
||||
|
||||
def sync_blood_pressure(client, user_id, start, end):
|
||||
data = _safe(lambda: client.get_blood_pressure(start, end), {}) or {}
|
||||
stored = 0
|
||||
for summary in data.get("measurementSummaries") or []:
|
||||
for m in summary.get("measurements") or []:
|
||||
when = _stamp(m.get("measurementTimestampLocal")
|
||||
or m.get("measurementTimestampGMT"))
|
||||
if not when:
|
||||
continue
|
||||
_upsert(
|
||||
"blood_pressure", ("user_id", "measured_at"),
|
||||
["id", "user_id", "measured_at", "systolic", "diastolic",
|
||||
"pulse", "note"],
|
||||
[f"{user_id}-{when}", user_id, when,
|
||||
_int(m.get("systolic")), _int(m.get("diastolic")),
|
||||
_int(m.get("pulse")), m.get("notes")],
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def get_blood_pressure(user_id):
|
||||
rows = query_all(
|
||||
"SELECT * FROM blood_pressure WHERE user_id = ? ORDER BY measured_at DESC",
|
||||
[user_id],
|
||||
)
|
||||
return [{
|
||||
"measuredAt": str(r["measured_at"]),
|
||||
"systolic": r["systolic"],
|
||||
"diastolic": r["diastolic"],
|
||||
"pulse": r["pulse"],
|
||||
"note": r["note"],
|
||||
} for r in rows]
|
||||
|
||||
|
||||
# --- race predictions --------------------------------------------------------
|
||||
|
||||
def sync_race_predictions(client, user_id, start=None, end=None):
|
||||
data = _safe(lambda: client.get_race_predictions(start, end), None)
|
||||
rows = data if isinstance(data, list) else [data] if data else []
|
||||
|
||||
stored = 0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
date = _day(row.get("calendarDate") or row.get("fromCalendarDate"))
|
||||
if not date:
|
||||
continue
|
||||
_upsert(
|
||||
"race_predictions", ("user_id", "date"),
|
||||
["id", "user_id", "date", "time_5k", "time_10k", "time_half",
|
||||
"time_marathon"],
|
||||
[f"{user_id}-{date}", user_id, date,
|
||||
_int(row.get("time5K")), _int(row.get("time10K")),
|
||||
_int(row.get("timeHalfMarathon")), _int(row.get("timeMarathon"))],
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def get_race_predictions(user_id, limit=90):
|
||||
rows = query_all(
|
||||
"SELECT * FROM race_predictions WHERE user_id = ? ORDER BY date DESC",
|
||||
[user_id],
|
||||
)[:limit]
|
||||
return [{
|
||||
"date": str(r["date"])[:10],
|
||||
"time5k": r["time_5k"],
|
||||
"time10k": r["time_10k"],
|
||||
"timeHalf": r["time_half"],
|
||||
"timeMarathon": r["time_marathon"],
|
||||
} for r in reversed(rows)]
|
||||
|
||||
|
||||
# --- within-day series -------------------------------------------------------
|
||||
|
||||
# Each entry: the API call, and how to pull the [timestamp, value] pairs out of
|
||||
# whatever shape that particular endpoint returns. They are all different.
|
||||
def _hr_series(data):
|
||||
return [[_stamp(t), v] for t, v in (data.get("heartRateValues") or [])
|
||||
if v is not None]
|
||||
|
||||
|
||||
def _stress_series(data):
|
||||
return [[_stamp(t), v] for t, v in (data.get("stressValuesArray") or [])
|
||||
if v is not None and v >= 0]
|
||||
|
||||
|
||||
def _battery_series(data):
|
||||
out = []
|
||||
for entry in data if isinstance(data, list) else [data]:
|
||||
for point in (entry or {}).get("bodyBatteryValuesArray") or []:
|
||||
# [timestamp, status, level, version]
|
||||
if len(point) >= 3 and point[2] is not None:
|
||||
out.append([_stamp(point[0]), point[2]])
|
||||
return out
|
||||
|
||||
|
||||
def _respiration_series(data):
|
||||
return [[_stamp(t), v] for t, v in (data.get("respirationValuesArray") or [])
|
||||
if v is not None and v > 0]
|
||||
|
||||
|
||||
def _spo2_series(data):
|
||||
return [[_stamp(t), v] for t, v in (data.get("spO2HourlyAverages") or [])
|
||||
if v is not None]
|
||||
|
||||
|
||||
SERIES_KINDS = {
|
||||
"heartRate": (lambda c, d: c.get_heart_rates(d), _hr_series),
|
||||
"stress": (lambda c, d: c.get_all_day_stress(d), _stress_series),
|
||||
"bodyBattery": (lambda c, d: c.get_body_battery(d, d), _battery_series),
|
||||
"respiration": (lambda c, d: c.get_respiration_data(d), _respiration_series),
|
||||
"spo2": (lambda c, d: c.get_spo2_data(d), _spo2_series),
|
||||
}
|
||||
|
||||
# A day of heart rate is ~500 samples at 2-minute resolution. More than this
|
||||
# cannot be told apart on a phone chart and only inflates the row.
|
||||
SERIES_MAX_POINTS = 240
|
||||
|
||||
|
||||
def _thin(points, limit=SERIES_MAX_POINTS):
|
||||
if len(points) <= limit:
|
||||
return points
|
||||
step = (len(points) - 1) / (limit - 1)
|
||||
return [points[int(round(i * step))] for i in range(limit)]
|
||||
|
||||
|
||||
def sync_daily_series(client, user_id, date, kinds=None):
|
||||
"""Store the within-day curves for one day."""
|
||||
stored = 0
|
||||
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
|
||||
for kind, (call, extract) in SERIES_KINDS.items():
|
||||
if kinds and kind not in kinds:
|
||||
continue
|
||||
raw = _safe(lambda: call(client, date))
|
||||
if raw is None:
|
||||
continue
|
||||
points = _safe(lambda: _thin(extract(raw)), []) or []
|
||||
if not points:
|
||||
continue
|
||||
_upsert(
|
||||
"daily_series", ("user_id", "date", "kind"),
|
||||
["id", "user_id", "date", "kind", "payload", "fetched_at"],
|
||||
[f"{user_id}-{date}-{kind}", user_id, date, kind,
|
||||
json.dumps(points, default=str), now],
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def get_daily_series(user_id, date):
|
||||
rows = query_all(
|
||||
"SELECT kind, payload FROM daily_series WHERE user_id = ? AND date = ?",
|
||||
[user_id, date],
|
||||
)
|
||||
out = {}
|
||||
for r in rows:
|
||||
try:
|
||||
out[r["kind"]] = json.loads(r["payload"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
# --- challenges --------------------------------------------------------------
|
||||
|
||||
def sync_challenges(client, user_id):
|
||||
"""Badge challenges and ad-hoc challenges.
|
||||
|
||||
Distinct from badges: a badge is earned once and sits in a list, while a
|
||||
challenge has a period, a target and a standing.
|
||||
"""
|
||||
execute("DELETE FROM challenges WHERE user_id = ?", [user_id])
|
||||
|
||||
stored = 0
|
||||
sources = [
|
||||
("badge", lambda: client.get_badge_challenges(1, 100)),
|
||||
("adhoc", lambda: client.get_adhoc_challenges(1, 100)),
|
||||
("available", lambda: client.get_available_badge_challenges(1, 100)),
|
||||
("inprogress", lambda: client.get_inprogress_virtual_challenges(1, 100)),
|
||||
]
|
||||
|
||||
for kind, call in sources:
|
||||
rows = _safe(call, []) or []
|
||||
if isinstance(rows, dict):
|
||||
rows = rows.get("challenges") or rows.get("badgeChallenges") or []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
uuid = row.get("uuid") or row.get("challengeUuid") or row.get("badgeId")
|
||||
name = (row.get("badgeChallengeName") or row.get("adHocChallengeName")
|
||||
or row.get("challengeName") or row.get("badgeName"))
|
||||
execute(
|
||||
"INSERT INTO challenges (id, user_id, challenge_uuid, kind, name, "
|
||||
"status, start_date, end_date, payload) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
[f"{user_id}-{kind}-{uuid}-{stored}", user_id, str(uuid or ""),
|
||||
kind, name,
|
||||
str(row.get("badgeChallengeStatusId")
|
||||
or row.get("socialChallengeStatusId") or ""),
|
||||
_day(row.get("startDate")), _day(row.get("endDate")),
|
||||
json.dumps(row, default=str)],
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def get_challenges(user_id):
|
||||
rows = query_all(
|
||||
"SELECT * FROM challenges WHERE user_id = ? ORDER BY start_date DESC",
|
||||
[user_id],
|
||||
)
|
||||
out = []
|
||||
for r in rows:
|
||||
try:
|
||||
payload = json.loads(r["payload"]) if r["payload"] else {}
|
||||
except (ValueError, TypeError):
|
||||
payload = {}
|
||||
out.append({
|
||||
"uuid": r["challenge_uuid"],
|
||||
"kind": r["kind"],
|
||||
"name": r["name"],
|
||||
"status": r["status"],
|
||||
"startDate": str(r["start_date"])[:10] if r["start_date"] else None,
|
||||
"endDate": str(r["end_date"])[:10] if r["end_date"] else None,
|
||||
"payload": payload,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# --- devices -----------------------------------------------------------------
|
||||
|
||||
def sync_devices(client, user_id):
|
||||
devices = _safe(lambda: client.get_devices(), []) or []
|
||||
if isinstance(devices, dict):
|
||||
devices = [devices]
|
||||
|
||||
execute("DELETE FROM devices WHERE user_id = ?", [user_id])
|
||||
last_used = _safe(lambda: client.get_device_last_used(), {}) or {}
|
||||
|
||||
stored = 0
|
||||
for d in devices:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
device_id = str(d.get("deviceId") or d.get("unitId") or stored)
|
||||
execute(
|
||||
"INSERT INTO devices (id, user_id, device_id, name, model, serial, "
|
||||
"software_version, last_used_at, payload) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
[f"{user_id}-{device_id}", user_id, device_id,
|
||||
d.get("displayName") or d.get("productDisplayName"),
|
||||
d.get("productDisplayName") or d.get("partNumber"),
|
||||
str(d.get("serialNumber") or ""),
|
||||
str(d.get("softwareVersion") or ""),
|
||||
_stamp(last_used.get("lastUsedDeviceUploadTime"))
|
||||
if str(last_used.get("userDeviceId") or "") == device_id else None,
|
||||
json.dumps(d, default=str)],
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def get_devices(user_id):
|
||||
rows = query_all("SELECT * FROM devices WHERE user_id = ?", [user_id])
|
||||
return [{
|
||||
"deviceId": r["device_id"],
|
||||
"name": r["name"],
|
||||
"model": r["model"],
|
||||
"serial": r["serial"],
|
||||
"softwareVersion": r["software_version"],
|
||||
"lastUsedAt": str(r["last_used_at"]) if r["last_used_at"] else None,
|
||||
} for r in rows]
|
||||
|
||||
|
||||
# --- per-day extras folded into health_data ----------------------------------
|
||||
|
||||
def daily_extras(client, date):
|
||||
"""Hill score, hydration and weight for one day.
|
||||
|
||||
Returned as columns to merge into the day's health_data row rather than
|
||||
stored separately — they are daily scalars like every other metric there.
|
||||
"""
|
||||
out = {}
|
||||
|
||||
hydration = _safe(lambda: client.get_hydration_data(date), {}) or {}
|
||||
out["hydrationMl"] = _int(hydration.get("valueInML"))
|
||||
out["hydrationGoalMl"] = _int(hydration.get("goalInML"))
|
||||
out["sweatLossMl"] = _int(hydration.get("sweatLossInML"))
|
||||
|
||||
hill = _safe(lambda: client.get_hill_score(date, date), {}) or {}
|
||||
scores = hill.get("hillScoreDTOList") or []
|
||||
if scores:
|
||||
out["hillScore"] = _int(scores[-1].get("overallScore"))
|
||||
else:
|
||||
out["hillScore"] = _int(hill.get("periodAvgScore"))
|
||||
|
||||
weigh = _safe(lambda: client.get_daily_weigh_ins(date), {}) or {}
|
||||
summaries = weigh.get("dateWeightList") or []
|
||||
if summaries:
|
||||
grams = _num(summaries[-1].get("weight"))
|
||||
out["weightKg"] = grams / 1000 if grams else None
|
||||
out["bmi"] = _num(summaries[-1].get("bmi"))
|
||||
out["bodyFatPct"] = _num(summaries[-1].get("bodyFat"))
|
||||
|
||||
return {k: v for k, v in out.items() if v is not None}
|
||||
@@ -169,6 +169,13 @@ HEALTH_COLUMNS = {
|
||||
"training_readiness": "trainingReadiness",
|
||||
"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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user