[阶段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:
126
client/src/lib/aggregate.ts
Normal file
126
client/src/lib/aggregate.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user