""" Health data service: read endpoints + upsert helpers used by the Garmin sync. Mirrors the original Node HealthService, including the camelCase JSON mapping. Upserts use backend-specific SQL because SQLite does not support `ON DUPLICATE KEY UPDATE` (it uses `ON CONFLICT ... DO UPDATE`). """ import datetime import uuid from db import execute, query_one, query_all from config import DB_TYPE def _range_sql(user_id, start=None, end=None): params = [user_id] sql = "WHERE user_id = ?" if start: sql += " AND date >= ?" params.append(start) if end: sql += " AND date <= ?" params.append(end) return sql, params 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( f"SELECT date, {columns} FROM health_data {sql} ORDER BY date ASC", params ) 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): sql, params = _range_sql(user_id, start, end) rows = query_all( f"SELECT date, steps FROM health_data {sql} AND steps IS NOT NULL ORDER BY date ASC", params, ) return [{"date": r["date"], "steps": r["steps"]} for r in rows] def get_heart_rate(user_id, start=None, end=None): sql, params = _range_sql(user_id, start, end) rows = query_all( f"SELECT date, heart_rate, heart_rate_variability FROM health_data {sql} " "AND heart_rate IS NOT NULL ORDER BY date ASC", params, ) return [ { "date": r["date"], "heartRate": r["heart_rate"], "heartRateVariability": r["heart_rate_variability"], } for r in rows ] def get_sleep(user_id, start=None, end=None): sql, params = _range_sql(user_id, start, end) rows = query_all( f"SELECT date, sleep_duration, sleep_quality FROM health_data {sql} " "AND sleep_duration IS NOT NULL ORDER BY date ASC", params, ) return [ {"date": r["date"], "duration": r["sleep_duration"], "quality": r["sleep_quality"]} for r in rows ] def get_activities(user_id, start=None, end=None): """Activities in a date range. Filters on start_time, not `date`: the activities table has no `date` column, so reusing the daily-metrics range clause raised "Unknown column 'date'". It went unnoticed while the only caller asked for every activity, which produced no date predicate at all. """ params = [user_id] sql = "WHERE user_id = ?" if start: sql += " AND start_time >= ?" params.append(start) if end: # Exclusive upper bound at the next midnight rather than "end # 23:59:59": SQLite compares these as strings, and the stored # separator may be 'T' (0x54) or a space (0x20), so a same-day # 18:30 timestamp sorts *after* an end bound written with a space # and would be dropped from its own day. sql += " AND start_time < ?" params.append( (datetime.date.fromisoformat(end) + datetime.timedelta(days=1)).isoformat() ) return query_all( "SELECT id, activity_type, start_time, end_time, duration, distance, " "calories, heart_rate_average, heart_rate_max " f"FROM activities {sql} ORDER BY start_time DESC", params, ) # 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"] + list(HEALTH_COLUMNS) values = [hid, user_id, record.get("date")] + [ record.get(key) for key in HEALTH_COLUMNS.values() ] _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 # stored instead of inserting it again. aid = str(activity.get("id") or uuid.uuid4()) cols = [ "id", "user_id", "activity_type", "start_time", "end_time", "duration", "distance", "calories", "heart_rate_average", "heart_rate_max", ] placeholders = ", ".join(["?"] * len(cols)) vals = [ aid, user_id, activity.get("activityType"), activity.get("startTime"), activity.get("endTime"), activity.get("duration"), activity.get("distance"), activity.get("calories"), activity.get("heartRateAverage"), activity.get("heartRateMax"), ] execute( f"INSERT INTO activities ({', '.join(cols)}) VALUES ({placeholders})", vals, ) return aid