[阶段8] 新增每日数据模块;趋势改为全指标并列 + 周期聚合

每日数据(新页面 /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>
This commit is contained in:
ericwyuan
2026-08-23 22:33:59 +08:00
parent 9e5e77755e
commit bfda1cd017
9 changed files with 939 additions and 115 deletions

View File

@@ -5,6 +5,7 @@ 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
@@ -95,14 +96,35 @@ def get_sleep(user_id, start=None, end=None):
def get_activities(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
"""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,
)
return rows
# Column name -> key in the record dict produced by the Garmin extractor.

View File

@@ -254,3 +254,52 @@ class TestBadgesAndRecords:
assert isinstance(
client.get("/api/health/personal-records", headers=auth).get_json(), list
)
class TestActivityDateRange:
"""Regression: activities were filtered with the daily-metrics range
clause, which references a `date` column the activities table does not
have. It only failed once a caller actually passed a range."""
def seed(self, user):
for stamp in ("2026-08-20T07:00:00", "2026-08-21T18:30:00",
"2026-08-22T09:15:00"):
health_svc.insert_activity(user["id"], {
"activityType": "running",
"startTime": stamp,
"endTime": stamp,
"duration": 1800,
})
def test_single_day_range_does_not_raise(self, db, user):
self.seed(user)
rows = health_svc.get_activities(user["id"], "2026-08-21", "2026-08-21")
assert len(rows) == 1
assert rows[0]["start_time"].startswith("2026-08-21")
def test_range_bounds_are_inclusive_of_the_whole_day(self, db, user):
"""An activity at 18:30 must fall inside its own day."""
self.seed(user)
rows = health_svc.get_activities(user["id"], "2026-08-20", "2026-08-22")
assert len(rows) == 3
def test_start_only(self, db, user):
self.seed(user)
assert len(health_svc.get_activities(user["id"], "2026-08-21")) == 2
def test_end_only(self, db, user):
self.seed(user)
assert len(health_svc.get_activities(user["id"], None, "2026-08-21")) == 2
def test_no_range_returns_everything(self, db, user):
self.seed(user)
assert len(health_svc.get_activities(user["id"])) == 3
def test_endpoint_with_a_date_range(self, client, auth, user, db):
self.seed(user)
r = client.get(
"/api/health/activities?startDate=2026-08-21&endDate=2026-08-21",
headers=auth,
)
assert r.status_code == 200
assert len(r.get_json()) == 1