[阶段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

126
client/src/lib/aggregate.ts Normal file
View File

@@ -0,0 +1,126 @@
import { HealthDay } from '../services/api';
export type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
export const GRANULARITIES: Array<{ id: Granularity; label: string; days: number }> = [
{ id: 'day', label: '每天', days: 1 },
{ id: 'week', label: '每 7 天', days: 7 },
{ id: 'month', label: '每月', days: 30 },
{ id: 'quarter', label: '每季度', days: 91 },
{ id: 'year', label: '每年', days: 365 },
];
/**
* Which metrics are counts that accumulate over a day, and which are rates or
* levels that only make sense as an average.
*
* The distinction matters once days are bucketed: summing a week of step
* counts is meaningful, summing a week of resting heart rates is nonsense.
* Both are still reported *per day* so buckets of different length stay
* comparable — a 30-day month and a 31-day month should not differ by 3%
* purely because of the calendar.
*/
const CUMULATIVE = new Set([
'steps', 'distanceMeters', 'caloriesBurned', 'activeCalories', 'bmrCalories',
'floorsAscended', 'floorsDescended', 'intensityMinutes',
'sedentarySeconds', 'activeSeconds',
]);
export function isCumulative(key: string) {
return CUMULATIVE.has(key);
}
function startOfBucket(date: Date, g: Granularity, anchor: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
if (g === 'day') return d;
if (g === 'month') return new Date(d.getFullYear(), d.getMonth(), 1);
if (g === 'quarter') {
return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
}
if (g === 'year') return new Date(d.getFullYear(), 0, 1);
// Weeks are counted back from the newest day rather than from a calendar
// Monday, so "每 7 天" always means the last 7 days, the 7 before that, and
// so on — a half-empty leading bucket would read as a slump that is really
// just where the window happened to start.
const diffDays = Math.floor((anchor.getTime() - d.getTime()) / 86400000);
const bucket = new Date(anchor);
bucket.setHours(0, 0, 0, 0);
bucket.setDate(bucket.getDate() - Math.floor(diffDays / 7) * 7 - 6);
return bucket;
}
function labelFor(start: Date, g: Granularity): string {
const mm = String(start.getMonth() + 1).padStart(2, '0');
const dd = String(start.getDate()).padStart(2, '0');
if (g === 'day') return `${mm}-${dd}`;
if (g === 'week') return `${mm}-${dd}`;
if (g === 'month') return `${start.getFullYear()}-${mm}`;
if (g === 'quarter') return `${start.getFullYear()} Q${Math.floor(start.getMonth() / 3) + 1}`;
return String(start.getFullYear());
}
export interface Bucket {
key: string;
label: string;
start: string;
end: string;
/** Days in the bucket that carried at least one value. */
days: number;
values: Record<string, number | null>;
}
/**
* Group days into buckets and reduce each metric to a per-day figure.
*
* Every metric — cumulative or not — comes out as a daily average, so the
* y-axis keeps the same unit and meaning whichever granularity is selected.
*/
export function aggregate(
days: HealthDay[],
granularity: Granularity,
keys: string[]
): Bucket[] {
if (days.length === 0) return [];
const anchor = new Date(days[days.length - 1].date);
anchor.setHours(0, 0, 0, 0);
const groups = new Map<string, { start: Date; rows: HealthDay[] }>();
for (const row of days) {
const start = startOfBucket(new Date(row.date), granularity, anchor);
const key = start.toISOString().slice(0, 10);
if (!groups.has(key)) groups.set(key, { start, rows: [] });
groups.get(key)!.rows.push(row);
}
return [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, { start, rows }]) => {
const values: Record<string, number | null> = {};
for (const metric of keys) {
const present = rows
.map((r) => (r as any)[metric])
.filter((v): v is number => typeof v === 'number');
values[metric] = present.length
? present.reduce((a, b) => a + b, 0) / present.length
: null;
}
return {
key,
label: labelFor(start, granularity),
start: rows[0].date,
end: rows[rows.length - 1].date,
days: rows.length,
values,
};
});
}
/** Pick the coarsest granularity that keeps a window readable. */
export function suggestGranularity(dayCount: number): Granularity {
if (dayCount <= 31) return 'day';
if (dayCount <= 120) return 'week';
if (dayCount <= 400) return 'month';
return 'quarter';
}