每日数据(新页面 /daily): - 按活动/能量/心率/压力/睡眠/血氧呼吸/训练七组,列出全部 40 项指标 - 日期选择器 + 前后一天翻页;"只显示有数据的指标"开关 - 附当天的运动记录明细 - 标题处显示当天记录到多少项,缺数据一目了然 趋势(重写): - 15 组指标全部并列展示,不再一次只能看一个 - 标签可逐个隐藏/显示,选择存入 localStorage(每次刷新都重置的 选择算不上偏好);提供全选/全不选 - 两级筛选:范围(一月/一季/半年/一年/两年)× 周期(每天/每 7 天/ 每月/每季度)。周期选项按范围过滤,避免出现"近一月按季度聚合" - 所有图表共用同一份聚合结果,一行筛选器统摄全部图表 聚合口径(lib/aggregate.ts): - 无论累计型还是速率型指标,一律折算为"周期内日均",这样 30 天的 月份和 31 天的月份不会仅因日历差 3% - 周按最新一天往回切,而不是按自然周一 —— 否则开头会出现一个半空 的桶,看起来像低谷,其实只是窗口起点 - 累计型指标的统计标签写作"日均"而非"平均",读者不必猜口径 fix(viz): 聚合后柱形/面积图掩盖了变化 - 柱形与面积都以延展量编码大小,必须从 0 起;而一年的月均步数都在 9,590~12,701 之间,画出来几乎一样高,恰恰看不见要看的变化 - 聚合视图改用折线:折线编码位置而非延展量,非零轴是正当的。 改后 y 轴自动落在 9350~12750,走势清晰可读 fix(health): 运动记录按日期筛选时 500 - get_activities 复用了按 date 列过滤的子句,但 activities 表只有 start_time,报 "Unknown column 'date'"。此前唯一的调用方不传 日期,所以一直没暴露,每日数据页一传就炸 - 上界改用次日零点的开区间:SQLite 按字符串比较,而存储的分隔符 可能是 'T'(0x54) 也可能是空格(0x20),写成 "end 23:59:59" 会让 当天 18:30 的记录排在上界之后而被排除在自己那天之外 tests (+6, 共 305): 运动记录的单日范围、上下界闭合、仅起点/仅终点、 不传范围返回全部、端点级验证 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
266 lines
9.4 KiB
Python
266 lines
9.4 KiB
Python
"""
|
|
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
|