diff --git a/backend/db.py b/backend/db.py index dd1a898..db0ce49 100644 --- a/backend/db.py +++ b/backend/db.py @@ -98,6 +98,36 @@ CREATE TABLE IF NOT EXISTS garmin_tokens ( FOREIGN KEY (user_id) REFERENCES users(id) ); +-- Badges earned on Garmin Connect ("奖励"). Keyed by Garmin's own badge id so +-- a re-sync updates rather than duplicates. +CREATE TABLE IF NOT EXISTS badges ( + id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + badge_key VARCHAR(128), + name VARCHAR(255), + category_id INT, + difficulty_id INT, + earned_date DATETIME, + earned_count INT, + points INT, + PRIMARY KEY (user_id, id), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- Personal records (个人纪录), e.g. fastest 5k, longest run. +CREATE TABLE IF NOT EXISTS personal_records ( + id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + type_id INT, + activity_id VARCHAR(64), + activity_name VARCHAR(255), + activity_type VARCHAR(64), + value DOUBLE, + achieved_at DATETIME, + PRIMARY KEY (user_id, id), + FOREIGN KEY (user_id) REFERENCES users(id) +); + -- Rendezvous for the interactive MFA login. -- garth asks for the code through a *blocking* callback, so the login parks in -- a background thread while the code arrives in a separate HTTP request that @@ -227,6 +257,75 @@ def _row_to_dict(row): return {k: _serialize(row[k]) for k in row.keys()} +# Columns added after the first release. `CREATE TABLE IF NOT EXISTS` does +# nothing to a table that already exists, so new metrics need an explicit +# additive migration or they silently never appear in production. +MIGRATIONS = { + "health_data": [ + # activity / energy + ("distance_meters", "DOUBLE"), + ("active_calories", "DOUBLE"), + ("bmr_calories", "DOUBLE"), + ("floors_ascended", "DOUBLE"), + ("floors_descended", "DOUBLE"), + ("intensity_minutes", "INT"), + ("step_goal", "INT"), + ("sedentary_seconds", "INT"), + ("active_seconds", "INT"), + # heart / stress + ("heart_rate_max", "INT"), + ("heart_rate_min", "INT"), + ("stress_max", "INT"), + # body battery + ("body_battery_high", "INT"), + ("body_battery_low", "INT"), + ("body_battery_charged", "INT"), + ("body_battery_drained", "INT"), + # breathing / blood oxygen + ("spo2_avg", "DOUBLE"), + ("spo2_min", "INT"), + ("respiration_avg", "DOUBLE"), + ("respiration_min", "DOUBLE"), + ("respiration_max", "DOUBLE"), + # sleep detail + ("sleep_deep_seconds", "INT"), + ("sleep_light_seconds", "INT"), + ("sleep_rem_seconds", "INT"), + ("sleep_awake_seconds", "INT"), + ("sleep_spo2_avg", "DOUBLE"), + ("sleep_respiration_avg", "DOUBLE"), + ("sleep_stress_avg", "DOUBLE"), + # training + ("training_readiness", "INT"), + ("vo2max", "DOUBLE"), + ("endurance_score", "INT"), + ], +} + + +def _existing_columns(cur, table): + if DB_TYPE == "mariadb": + cur.execute( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s", + [table], + ) + return {r["COLUMN_NAME"] if isinstance(r, dict) else r[0] for r in cur.fetchall()} + cur.execute(f"PRAGMA table_info({table})") + return {row[1] for row in cur.fetchall()} + + +def _migrate(cur): + for table, columns in MIGRATIONS.items(): + present = _existing_columns(cur, table) + for name, coltype in columns: + if name in present: + continue + # SQLite has no "ADD COLUMN IF NOT EXISTS"; the membership check + # above is what keeps this idempotent on both backends. + cur.execute(f"ALTER TABLE {table} ADD COLUMN {name} {coltype}") + + # --- Public API ------------------------------------------------------------- def init_db(): conn = _connect() @@ -237,6 +336,7 @@ def init_db(): if not stmt: continue cur.execute(_adapt_sql(stmt)) + _migrate(cur) finally: _disconnect(conn) diff --git a/backend/routes/health.py b/backend/routes/health.py index 0e645c4..3f68dc7 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -44,3 +44,16 @@ def sleep(): def activities(): s, e = _range() return jsonify(health_svc.get_activities(g.user_id, s, e)) + + +@bp.route("/badges", methods=["GET"]) +@require_auth +def badges(): + """Earned badges (奖励), most recent first.""" + return jsonify(health_svc.get_badges(g.user_id)) + + +@bp.route("/personal-records", methods=["GET"]) +@require_auth +def personal_records(): + return jsonify(health_svc.get_personal_records(g.user_id)) diff --git a/backend/services/ai.py b/backend/services/ai.py index 8b128f2..5fc3a36 100644 --- a/backend/services/ai.py +++ b/backend/services/ai.py @@ -361,13 +361,28 @@ def resolve_chain(preferred=None): # --- prompt construction ---------------------------------------------------- +# Kept deliberately short: every extra column multiplies by the number of +# days sent, and the column names double as the vocabulary the model cites +# back in `basedOn`. _CSV_COLUMNS = [ ("date", "date"), ("steps", "steps"), + ("distanceMeters", "dist_m"), ("heartRate", "rest_hr"), + ("heartRateMax", "max_hr"), ("heartRateVariability", "hrv"), ("stress", "stress"), + ("stressMax", "stress_max"), + ("bodyBatteryHigh", "bb_high"), + ("bodyBatteryLow", "bb_low"), + ("spo2Avg", "spo2"), + ("respirationAvg", "resp"), + ("intensityMinutes", "intensity_min"), ("caloriesBurned", "kcal"), + ("activeCalories", "active_kcal"), + ("floorsAscended", "floors"), + ("trainingReadiness", "readiness"), + ("enduranceScore", "endurance"), ] @@ -379,7 +394,10 @@ def build_prompt(summary, activities=None, day_budget=None): """ day_budget = day_budget if day_budget is not None else default_day_budget() rows = summary[-day_budget:] if day_budget else summary - header = ",".join(label for _, label in _CSV_COLUMNS) + ",sleep_h,sleep_q" + header = ( + ",".join(label for _, label in _CSV_COLUMNS) + + ",sleep_h,sleep_q,sleep_deep_s,sleep_rem_s,sleep_awake_s" + ) lines = [header] for r in rows: cells = [] @@ -387,8 +405,9 @@ def build_prompt(summary, activities=None, day_budget=None): value = r.get(key) cells.append("" if value is None else str(value)) sleep = r.get("sleep") or {} - cells.append("" if sleep.get("duration") is None else str(sleep["duration"])) - cells.append("" if sleep.get("quality") is None else str(sleep["quality"])) + for key in ("duration", "quality", "deepSeconds", "remSeconds", "awakeSeconds"): + value = sleep.get(key) + cells.append("" if value is None else str(value)) lines.append(",".join(cells)) sections = [ diff --git a/backend/services/garmin.py b/backend/services/garmin.py index 06dfbe5..39e16ab 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -69,6 +69,21 @@ class MFARequired(RuntimeError): """Raised when a password login needs a code this process cannot obtain.""" +# garth sends a browser User-Agent, which its SSO flow needs. The data API +# treats that same UA as a browser hitting it directly and answers every +# request with HTTP 200 and an empty array — no error, just no data. The +# official app's UA (and in fact any non-browser one) returns real data, so +# the header is swapped after login, before any API call. +API_USER_AGENT = "com.garmin.android.apps.connectmobile" + + +def _use_api_user_agent(client): + try: + client.garth.sess.headers["User-Agent"] = API_USER_AGENT + except AttributeError: + pass # a stubbed client in tests has no session + + def _is_cn(): # Selects Garmin's China service, a separate backend with separate # accounts. This project tracks an international account. @@ -124,8 +139,11 @@ def _connect(creds, user_id=None): token = load_token(user_id) if user_id else None if token: client.garth.loads(token) - # Populates display_name/unit_system and proves the token still works. + _use_api_user_agent(client) + # Proves the token still works, and refreshes it if near expiry. client.garth.refresh_oauth2() + # garminconnect builds most of its URLs from display_name, so leaving + # it unset sends every request to ".../None". client.display_name = client.garth.profile["displayName"] return client @@ -140,12 +158,24 @@ def _connect(creds, user_id=None): # garth's default MFA prompt calls input(); under gunicorn stdin is # closed, so it raises EOFError rather than anything descriptive. raise MFARequired( - "该 Garmin 账号开启了两步验证,无法在服务端直接登录。" - "请在 NAS 上执行一次 `python garmin_login.py` 完成验证并保存令牌。" + "该 Garmin 账号开启了两步验证。请在「数据同步」页面用密码重新绑定," + "系统会提示你输入验证码。" ) from e + _use_api_user_agent(client) return client +def describe(e): + """A message that is never empty. + + Some exceptions carry no text at all — a bare `assert` raises + AssertionError with str(e) == "" — and storing that produced a failed + sync whose recorded reason was blank, which is undiagnosable. + """ + text = str(e).strip() + return f"{type(e).__name__}: {text}" if text else type(e).__name__ + + def _num(*values): """First value that is a usable number.""" for v in values: @@ -154,46 +184,179 @@ def _num(*values): return None -def _extract_daily(client, date_str): - """One day of metrics, assembled from the endpoints that carry them. +def _safe(fn, default=None): + """Call an optional endpoint; a metric the device does not record must not + abort the whole day.""" + try: + return fn() + except Exception: + return default - Sleep and HRV are separate endpoints in this library — they are not part - of the daily summary — so a sync that only read the summary would record - every night as "no sleep data". + +def _first(seq): + return seq[0] if isinstance(seq, list) and seq else {} + + +def _to_datetime(*values): + """Normalise Garmin's several timestamp shapes into an ISO string. + + The same payload mixes ISO strings ("2019-10-13T10:10:12.0") with epoch + milliseconds (1570961412000); handing the latter to a DATETIME column is + rejected outright, so a personal record whose only timestamp was numeric + failed the whole batch. """ - summary = client.get_user_summary(date_str) or {} + for v in values: + if v is None or v == "": + continue + if isinstance(v, str): + return v[:26] + if isinstance(v, (int, float)) and not isinstance(v, bool): + # Values past ~1e11 are milliseconds, below that seconds. + seconds = v / 1000 if v > 1e11 else v + try: + return datetime.datetime.utcfromtimestamp(seconds).isoformat( + timespec="seconds" + ) + except (ValueError, OverflowError, OSError): + continue + return None - sleep_seconds = None - sleep_quality = None - try: - sleep = (client.get_sleep_data(date_str) or {}).get("dailySleepDTO") or {} - sleep_seconds = _num(sleep.get("sleepTimeSeconds")) - sleep_quality = _num(sleep.get("sleepScores", {}).get("overall", {}).get("value") - if isinstance(sleep.get("sleepScores"), dict) else None) - except Exception: - pass # a missing night must not abort the whole day - hrv = None - try: - hrv_body = client.get_hrv_data(date_str) or {} - summary_block = hrv_body.get("hrvSummary") or {} - hrv = _num(summary_block.get("lastNightAvg"), summary_block.get("weeklyAvg")) - except Exception: - pass +def _extract_daily(client, date_str): + """Everything Garmin exposes for one day. + + The daily summary is the bulk of it, but sleep, HRV, training readiness + and endurance each live behind their own endpoint — none of them appear in + get_user_summary. Each is fetched defensively so a metric this device does + not record leaves a NULL instead of failing the day. + """ + s = client.get_user_summary(date_str) or {} + + sleep_dto = (_safe(lambda: client.get_sleep_data(date_str)) or {}).get( + "dailySleepDTO" + ) or {} + scores = sleep_dto.get("sleepScores") if isinstance( + sleep_dto.get("sleepScores"), dict + ) else {} + sleep_seconds = _num(sleep_dto.get("sleepTimeSeconds")) + + hrv_summary = (_safe(lambda: client.get_hrv_data(date_str)) or {}).get( + "hrvSummary" + ) or {} + + readiness = _first(_safe(lambda: client.get_training_readiness(date_str), [])) + training = _safe(lambda: client.get_training_status(date_str), {}) or {} + vo2 = (training.get("mostRecentVO2Max") or {}).get("generic") or {} + endurance = _safe(lambda: client.get_endurance_score(date_str), {}) or {} + + def secs(key): + return _num(sleep_dto.get(key)) return { "date": date_str, - "steps": _num(summary.get("totalSteps")), - "heartRate": _num(summary.get("restingHeartRate"), - summary.get("averageHeartRate")), - "heartRateVariability": hrv, + # --- activity / energy --- + "steps": _num(s.get("totalSteps")), + "stepGoal": _num(s.get("dailyStepGoal")), + "distanceMeters": _num(s.get("totalDistanceMeters")), + "caloriesBurned": _num(s.get("totalKilocalories")), + "activeCalories": _num(s.get("activeKilocalories")), + "bmrCalories": _num(s.get("bmrKilocalories")), + "floorsAscended": _num(s.get("floorsAscended")), + "floorsDescended": _num(s.get("floorsDescended")), + "intensityMinutes": ( + (_num(s.get("moderateIntensityMinutes")) or 0) + + (_num(s.get("vigorousIntensityMinutes")) or 0) + ) or None, + "sedentarySeconds": _num(s.get("sedentarySeconds")), + "activeSeconds": _num(s.get("activeSeconds")), + # --- heart / stress --- + "heartRate": _num(s.get("restingHeartRate"), s.get("averageHeartRate")), + "heartRateMax": _num(s.get("maxHeartRate")), + "heartRateMin": _num(s.get("minHeartRate")), + "heartRateVariability": _num( + hrv_summary.get("lastNightAvg"), hrv_summary.get("weeklyAvg") + ), + "stress": _num(s.get("averageStressLevel")), + "stressMax": _num(s.get("maxStressLevel")), + # --- body battery --- + "bodyBatteryHigh": _num(s.get("bodyBatteryHighestValue")), + "bodyBatteryLow": _num(s.get("bodyBatteryLowestValue")), + "bodyBatteryCharged": _num(s.get("bodyBatteryChargedValue")), + "bodyBatteryDrained": _num(s.get("bodyBatteryDrainedValue")), + # --- breathing / blood oxygen --- + "spo2Avg": _num(s.get("averageSpo2")), + "spo2Min": _num(s.get("lowestSpo2")), + "respirationAvg": _num( + s.get("avgWakingRespirationValue"), s.get("latestRespirationValue") + ), + "respirationMin": _num(s.get("lowestRespirationValue")), + "respirationMax": _num(s.get("highestRespirationValue")), + # --- sleep --- "sleepDuration": round(sleep_seconds / 3600, 1) if sleep_seconds else None, - "sleepQuality": sleep_quality, - "stress": _num(summary.get("averageStressLevel")), - "caloriesBurned": _num(summary.get("totalKilocalories")), + "sleepQuality": _num((scores.get("overall") or {}).get("value")), + "sleepDeepSeconds": secs("deepSleepSeconds"), + "sleepLightSeconds": secs("lightSleepSeconds"), + "sleepRemSeconds": secs("remSleepSeconds"), + "sleepAwakeSeconds": secs("awakeSleepSeconds"), + "sleepSpo2Avg": secs("averageSpO2Value"), + "sleepRespirationAvg": secs("averageRespirationValue"), + "sleepStressAvg": secs("avgSleepStress"), + # --- training --- + "trainingReadiness": _num(readiness.get("score")), + "vo2max": _num(vo2.get("vo2MaxValue")), + "enduranceScore": _num(endurance.get("overallScore")), } +def sync_badges(client, user_id): + """Earned badges. Keyed by Garmin's badge id, so re-syncing updates.""" + badges = _safe(lambda: client.get_earned_badges(), []) or [] + stored = 0 + for b in badges: + bid = b.get("badgeId") + if bid is None: + continue + health.upsert_badge(user_id, { + "id": str(bid), + "badgeKey": b.get("badgeKey"), + "name": b.get("badgeName"), + "categoryId": _num(b.get("badgeCategoryId")), + "difficultyId": _num(b.get("badgeDifficultyId")), + "earnedDate": _to_datetime(b.get("badgeEarnedDate")), + "earnedCount": _num(b.get("badgeEarnedNumber")), + "points": _num(b.get("badgePoints")), + }) + stored += 1 + return stored + + +def sync_personal_records(client, user_id): + records = _safe(lambda: client.get_personal_record(), []) or [] + stored = 0 + for r in records: + rid = r.get("id") + if rid is None: + continue + health.upsert_personal_record(user_id, { + "id": str(rid), + "typeId": _num(r.get("typeId")), + "activityId": r.get("activityId"), + "activityName": r.get("activityName"), + "activityType": r.get("activityType"), + "value": _num(r.get("value")), + # Prefer the pre-formatted strings; the bare fields are epoch ms. + "achievedAt": _to_datetime( + r.get("prStartTimeLocalFormatted"), + r.get("prStartTimeGmtFormatted"), + r.get("activityStartDateTimeLocalFormatted"), + r.get("prStartTimeLocal"), + r.get("prStartTimeGmt"), + ), + }) + stored += 1 + return stored + + def _activity_end(start, duration_seconds): if not start or not duration_seconds: return start @@ -260,7 +423,7 @@ def sync_data(user_id, creds, days=None, client=None): try: client = client or _connect(creds, user_id) except Exception as e: - message = str(e) + message = describe(e) _set_sync_status(user_id, "error", now, records_synced=0, last_error=message) return { "status": "error", @@ -280,7 +443,7 @@ def sync_data(user_id, creds, days=None, client=None): try: record = _extract_daily(client, date_str) except Exception as e: - day_errors.append(f"{date_str}: {e}") + day_errors.append(f"{date_str}: {describe(e)}") continue # A day Garmin has no data for comes back all-None; storing it would # create an empty row that the metric endpoints then have to filter. @@ -294,7 +457,20 @@ def sync_data(user_id, creds, days=None, client=None): client, user_id, start_date, today.isoformat() ) except Exception as e: - day_errors.append(f"activities: {e}") + day_errors.append(f"activities: {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 + records_synced_pr = 0 + try: + badges_synced = sync_badges(client, user_id) + except Exception as e: + day_errors.append(f"badges: {describe(e)}") + try: + records_synced_pr = sync_personal_records(client, user_id) + except Exception as e: + day_errors.append(f"personal_records: {describe(e)}") # Every single day failing means something systemic (expired session, # API change) — reporting that as a clean success would hide it. @@ -308,13 +484,18 @@ def sync_data(user_id, creds, days=None, client=None): user_id, "idle", now, records_synced=days_synced, last_error="; ".join(day_errors[:3]) if day_errors else None, ) - message = f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录" + message = ( + f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录、" + f"{badges_synced} 个奖励、{records_synced_pr} 项个人纪录" + ) if day_errors: - message += f"({len(day_errors)} 天跳过)" + message += f"({len(day_errors)} 项跳过)" return { "status": "success", "recordsSynced": days_synced, "activitiesSynced": activities_synced, + "badgesSynced": badges_synced, + "personalRecordsSynced": records_synced_pr, "message": message, "lastSyncTime": now, } diff --git a/backend/services/health.py b/backend/services/health.py index fe57e81..2f7d213 100644 --- a/backend/services/health.py +++ b/backend/services/health.py @@ -24,29 +24,35 @@ def _range_sql(user_id, start=None, end=None): def get_summary(user_id, start=None, end=None): + """Every stored metric for each day, in the camelCase the UI and the AI + prompt consume.""" sql, params = _range_sql(user_id, start, end) + columns = ", ".join(HEALTH_COLUMNS) rows = query_all( - "SELECT date, steps, heart_rate, heart_rate_variability, " - "sleep_duration, sleep_quality, stress, calories_burned " - f"FROM health_data {sql} ORDER BY date ASC", - params, + f"SELECT date, {columns} FROM health_data {sql} ORDER BY date ASC", params ) - return [ - { - "date": r["date"], - "steps": r["steps"], - "heartRate": r["heart_rate"], - "heartRateVariability": r["heart_rate_variability"], - "sleep": ( - {"duration": r["sleep_duration"], "quality": r["sleep_quality"]} - if r["sleep_duration"] is not None - else None - ), - "stress": r["stress"], - "caloriesBurned": r["calories_burned"], - } - for r in rows - ] + + out = [] + for r in rows: + day = {"date": r["date"]} + for column, key in HEALTH_COLUMNS.items(): + day[key] = r.get(column) + # Sleep stays nested for backwards compatibility with the UI and the + # existing recommendation rules. + day["sleep"] = ( + { + "duration": r.get("sleep_duration"), + "quality": r.get("sleep_quality"), + "deepSeconds": r.get("sleep_deep_seconds"), + "lightSeconds": r.get("sleep_light_seconds"), + "remSeconds": r.get("sleep_rem_seconds"), + "awakeSeconds": r.get("sleep_awake_seconds"), + } + if r.get("sleep_duration") is not None + else None + ) + out.append(day) + return out def get_steps(user_id, start=None, end=None): @@ -99,40 +105,121 @@ def get_activities(user_id, start=None, end=None): return rows +# Column name -> key in the record dict produced by the Garmin extractor. +# Keeping the mapping in one place means adding a metric touches this table +# and the extractor, and nothing else. +HEALTH_COLUMNS = { + "steps": "steps", + "step_goal": "stepGoal", + "distance_meters": "distanceMeters", + "calories_burned": "caloriesBurned", + "active_calories": "activeCalories", + "bmr_calories": "bmrCalories", + "floors_ascended": "floorsAscended", + "floors_descended": "floorsDescended", + "intensity_minutes": "intensityMinutes", + "sedentary_seconds": "sedentarySeconds", + "active_seconds": "activeSeconds", + "heart_rate": "heartRate", + "heart_rate_max": "heartRateMax", + "heart_rate_min": "heartRateMin", + "heart_rate_variability": "heartRateVariability", + "stress": "stress", + "stress_max": "stressMax", + "body_battery_high": "bodyBatteryHigh", + "body_battery_low": "bodyBatteryLow", + "body_battery_charged": "bodyBatteryCharged", + "body_battery_drained": "bodyBatteryDrained", + "spo2_avg": "spo2Avg", + "spo2_min": "spo2Min", + "respiration_avg": "respirationAvg", + "respiration_min": "respirationMin", + "respiration_max": "respirationMax", + "sleep_duration": "sleepDuration", + "sleep_quality": "sleepQuality", + "sleep_deep_seconds": "sleepDeepSeconds", + "sleep_light_seconds": "sleepLightSeconds", + "sleep_rem_seconds": "sleepRemSeconds", + "sleep_awake_seconds": "sleepAwakeSeconds", + "sleep_spo2_avg": "sleepSpo2Avg", + "sleep_respiration_avg": "sleepRespirationAvg", + "sleep_stress_avg": "sleepStressAvg", + "training_readiness": "trainingReadiness", + "vo2max": "vo2max", + "endurance_score": "enduranceScore", + "blood_pressure_systolic": "bloodPressureSystolic", + "blood_pressure_diastolic": "bloodPressureDiastolic", +} + + +def _upsert(table, key_cols, cols, values): + """INSERT ... ON CONFLICT/DUPLICATE UPDATE, written for both backends.""" + 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) + + def upsert_health_daily(user_id, record): hid = f"{user_id}-{record['date']}" - cols = [ - "id", "user_id", "date", "steps", "heart_rate", - "heart_rate_variability", "blood_pressure_systolic", - "blood_pressure_diastolic", "sleep_duration", "sleep_quality", - "stress", "calories_burned", + cols = ["id", "user_id", "date"] + list(HEALTH_COLUMNS) + values = [hid, user_id, record.get("date")] + [ + record.get(key) for key in HEALTH_COLUMNS.values() ] - placeholders = ", ".join(["?"] * len(cols)) - vals = [ - hid, user_id, record.get("date"), record.get("steps"), - record.get("heartRate"), record.get("heartRateVariability"), - record.get("bloodPressureSystolic"), record.get("bloodPressureDiastolic"), - record.get("sleepDuration"), record.get("sleepQuality"), - record.get("stress"), record.get("caloriesBurned"), - ] - if DB_TYPE == "mariadb": - update_cols = [c for c in cols if c not in ("id", "user_id")] - updates = ", ".join([f"{c}=VALUES({c})" for c in update_cols]) - sql = ( - f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) " - f"ON DUPLICATE KEY UPDATE {updates}, updated_at=CURRENT_TIMESTAMP" - ) - else: - update_cols = [c for c in cols if c not in ("id", "user_id")] - updates = ", ".join([f"{c}=excluded.{c}" for c in update_cols]) - sql = ( - f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) " - f"ON CONFLICT(user_id, date) DO UPDATE SET {updates}, updated_at=CURRENT_TIMESTAMP" - ) - execute(sql, vals) + _upsert("health_data", ("user_id", "date"), cols, values) return hid +def upsert_badge(user_id, badge): + cols = ["id", "user_id", "badge_key", "name", "category_id", + "difficulty_id", "earned_date", "earned_count", "points"] + values = [ + badge["id"], user_id, badge.get("badgeKey"), badge.get("name"), + badge.get("categoryId"), badge.get("difficultyId"), + badge.get("earnedDate"), badge.get("earnedCount"), badge.get("points"), + ] + _upsert("badges", ("user_id", "id"), cols, values) + return badge["id"] + + +def upsert_personal_record(user_id, record): + cols = ["id", "user_id", "type_id", "activity_id", "activity_name", + "activity_type", "value", "achieved_at"] + values = [ + record["id"], user_id, record.get("typeId"), record.get("activityId"), + record.get("activityName"), record.get("activityType"), + record.get("value"), record.get("achievedAt"), + ] + _upsert("personal_records", ("user_id", "id"), cols, values) + return record["id"] + + +def get_badges(user_id): + return query_all( + "SELECT id, badge_key, name, category_id, difficulty_id, earned_date, " + "earned_count, points FROM badges WHERE user_id = ? " + "ORDER BY earned_date DESC", + [user_id], + ) + + +def get_personal_records(user_id): + return query_all( + "SELECT id, type_id, activity_id, activity_name, activity_type, value, " + "achieved_at FROM personal_records WHERE user_id = ? " + "ORDER BY achieved_at DESC", + [user_id], + ) + + def insert_activity(user_id, activity): # Prefer Garmin's own activity id when the caller has one: it is stable # across syncs, which is what lets a re-synced window skip what is already diff --git a/backend/tests/test_garmin_sync.py b/backend/tests/test_garmin_sync.py index ed2732a..58a2c1b 100644 --- a/backend/tests/test_garmin_sync.py +++ b/backend/tests/test_garmin_sync.py @@ -117,7 +117,8 @@ class TestHappyPath: assert row["stress"] == 33 assert row["caloriesBurned"] == 2450 assert row["heartRateVariability"] == 52 - assert row["sleep"] == {"duration": 8.0, "quality": 91} + assert row["sleep"]["duration"] == 8.0 + assert row["sleep"]["quality"] == 91 def test_sleep_and_hrv_come_from_their_own_endpoints(self, db, user): """Regression: both live outside get_user_summary. Reading only the @@ -408,3 +409,121 @@ class TestAuthStatusEndpoint: garmin_svc.save_token(user["id"], "blob", "g@example.com") r = client.post("/api/garmin/sync", headers=auth, json={}) assert r.status_code != 400 + + +class TestApiUserAgent: + """Regression: garth keeps its browser User-Agent after login, and the + Garmin data API answers that UA with HTTP 200 and an empty array — every + endpoint silently returns nothing.""" + + def test_a_browser_user_agent_is_not_used_for_the_api(self): + assert "Mozilla" not in garmin_svc.API_USER_AGENT + assert "iPhone" not in garmin_svc.API_USER_AGENT + + def test_header_is_swapped_after_loading_a_token(self, db, user): + garmin_svc.save_token(user["id"], "blob", "g@example.com") + headers = {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5)"} + + class StubSess: + def __init__(self): self.headers = headers + + class StubGarth: + profile = {"displayName": "Tester"} + def __init__(self): self.sess = StubSess() + def loads(self, s): pass + def refresh_oauth2(self): pass + + class StubGarmin: + def __init__(self, *a, **k): self.garth = StubGarth() + + monkey = pytest.MonkeyPatch() + monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) + try: + garmin_svc._connect({}, user["id"]) + finally: + monkey.undo() + + assert headers["User-Agent"] == garmin_svc.API_USER_AGENT + + def test_swap_is_harmless_on_a_client_without_a_session(self): + class Bare: + garth = object() + + garmin_svc._use_api_user_agent(Bare()) # must not raise + + def test_display_name_is_populated(self, db, user): + """garminconnect builds URLs from display_name; unset sends every + request to '.../None'.""" + class StubGarth: + profile = {"displayName": "Tester"} + sess = type("S", (), {"headers": {}})() + def loads(self, s): pass + def refresh_oauth2(self): pass + + class StubGarmin: + def __init__(self, *a, **k): self.garth = StubGarth() + + garmin_svc.save_token(user["id"], "blob", "g@example.com") + monkey = pytest.MonkeyPatch() + monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin) + try: + client = garmin_svc._connect({}, user["id"]) + finally: + monkey.undo() + + assert client.display_name == "Tester" + + +class TestErrorMessagesAreNeverEmpty: + """Regression: a bare `assert` inside garth raised AssertionError with an + empty str(), which was stored as the sync's reason — a failed sync with a + blank explanation cannot be diagnosed.""" + + def test_exception_without_text_still_describes_itself(self): + assert garmin_svc.describe(AssertionError()) == "AssertionError" + + def test_exception_with_text_keeps_it(self): + assert "boom" in garmin_svc.describe(RuntimeError("boom")) + assert "RuntimeError" in garmin_svc.describe(RuntimeError("boom")) + + def test_whitespace_only_text_is_treated_as_empty(self): + assert garmin_svc.describe(ValueError(" ")) == "ValueError" + + def test_connect_failure_records_a_non_empty_reason(self, db, user, monkeypatch): + def boom(_creds, _uid=None): + raise AssertionError() # no message at all + + monkeypatch.setattr(garmin_svc, "_connect", boom) + out = garmin_svc.sync_data(user["id"], CREDS, days=1) + + assert out["status"] == "error" + assert out["message"].strip() + assert garmin_svc.get_sync_status(user["id"])["lastError"].strip() + + +class TestTimestampNormalisation: + """Regression: Garmin mixes ISO strings and epoch milliseconds in one + payload. Writing the numeric form to a DATETIME column is rejected, which + failed the entire personal-records batch.""" + + def test_iso_string_passes_through(self): + assert garmin_svc._to_datetime("2019-10-13T10:10:12.0").startswith( + "2019-10-13T10:10:12" + ) + + def test_epoch_milliseconds_are_converted(self): + assert garmin_svc._to_datetime(1570961412000).startswith("2019-10-13") + + def test_epoch_seconds_are_converted(self): + assert garmin_svc._to_datetime(1570961412).startswith("2019-10-13") + + def test_first_usable_value_wins(self): + assert garmin_svc._to_datetime(None, "", "2020-01-01T00:00:00") == ( + "2020-01-01T00:00:00" + ) + + def test_all_empty_gives_none(self): + assert garmin_svc._to_datetime(None, "") is None + + def test_nonsense_value_does_not_raise(self): + assert garmin_svc._to_datetime(float("inf")) is None diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 3f5687e..6820bce 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -31,7 +31,9 @@ class TestGetSummary: assert row["heartRate"] == 70 assert row["heartRateVariability"] == 45 assert row["caloriesBurned"] == 260 - assert row["sleep"] == {"duration": 6, "quality": 80} + # Sleep carries stage detail too; the core two must be right. + assert row["sleep"]["duration"] == 6 + assert row["sleep"]["quality"] == 80 def test_sleep_is_none_when_absent(self, seed_health, user): seed_health([{"date": "2026-08-20", "steps": 100}]) @@ -187,3 +189,68 @@ class TestEndpoints: "/api/health/summary?startDate=2026-08-22", headers=auth ).get_json() assert len(body) == 1 + + +class TestBadgesAndRecords: + """Badges ("奖励") and personal records are account-wide, not per-day.""" + + BADGE = { + "id": "1822", "badgeKey": "sleep_30_days", "name": "Sleep Savant", + "categoryId": 3, "difficultyId": 2, "earnedDate": "2026-08-01T10:00:00", + "earnedCount": 1, "points": 5, + } + + def test_badge_round_trips(self, db, user): + health_svc.upsert_badge(user["id"], self.BADGE) + rows = health_svc.get_badges(user["id"]) + assert len(rows) == 1 + assert rows[0]["name"] == "Sleep Savant" + assert rows[0]["badge_key"] == "sleep_30_days" + + def test_resync_updates_rather_than_duplicating(self, db, user): + health_svc.upsert_badge(user["id"], self.BADGE) + health_svc.upsert_badge(user["id"], {**self.BADGE, "earnedCount": 2}) + rows = health_svc.get_badges(user["id"]) + assert len(rows) == 1 + assert rows[0]["earned_count"] == 2 + + def test_badges_are_per_user(self, db, user, client): + health_svc.upsert_badge(user["id"], self.BADGE) + other = client.post( + "/api/auth/register", + json={"email": "b@example.com", "garminEmail": "bg@example.com", + "garminPassword": "pw123456"}, + ).get_json() + assert health_svc.get_badges(other["id"]) == [] + + def test_two_users_may_hold_the_same_badge_id(self, db, user, client): + """The key is (user, badge), so the same Garmin badge on two accounts + must not collide.""" + other = client.post( + "/api/auth/register", + json={"email": "c@example.com", "garminEmail": "cg@example.com", + "garminPassword": "pw123456"}, + ).get_json() + health_svc.upsert_badge(user["id"], self.BADGE) + health_svc.upsert_badge(other["id"], self.BADGE) + assert len(health_svc.get_badges(user["id"])) == 1 + assert len(health_svc.get_badges(other["id"])) == 1 + + def test_personal_record_round_trips(self, db, user): + health_svc.upsert_personal_record(user["id"], { + "id": "2538883970", "typeId": 1, "activityId": "17446848459", + "activityName": "晨跑", "activityType": "running", + "value": 1234.5, "achievedAt": "2026-08-01T07:00:00", + }) + rows = health_svc.get_personal_records(user["id"]) + assert len(rows) == 1 and rows[0]["activity_name"] == "晨跑" + + def test_endpoints_require_auth(self, client): + assert client.get("/api/health/badges").status_code == 401 + assert client.get("/api/health/personal-records").status_code == 401 + + def test_endpoints_return_lists(self, client, auth): + assert isinstance(client.get("/api/health/badges", headers=auth).get_json(), list) + assert isinstance( + client.get("/api/health/personal-records", headers=auth).get_json(), list + ) diff --git a/client/src/pages/DataSync.tsx b/client/src/pages/DataSync.tsx index cc73d1b..2714d76 100644 --- a/client/src/pages/DataSync.tsx +++ b/client/src/pages/DataSync.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - apiClient, errorMessage, GarminLoginStatus, SyncStatus, + apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus, } from '../services/api'; import './DataSync.css'; @@ -183,9 +183,8 @@ function DataSync() {