[阶段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:
311
client/src/pages/Daily.tsx
Normal file
311
client/src/pages/Daily.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
||||
import './Daily.css';
|
||||
import './Pages.css';
|
||||
|
||||
/** Every stored metric, grouped the way the device groups them. */
|
||||
interface Field {
|
||||
key: keyof HealthDay | 'sleepDeep' | 'sleepLight' | 'sleepRem' | 'sleepAwake';
|
||||
label: string;
|
||||
unit?: string;
|
||||
/** Convert the raw stored value for display. */
|
||||
transform?: (v: number) => number;
|
||||
decimals?: number;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const SECONDS_TO_HOURS = (v: number) => v / 3600;
|
||||
const SECONDS_TO_MINUTES = (v: number) => v / 60;
|
||||
|
||||
const GROUPS: Array<{ title: string; fields: Field[] }> = [
|
||||
{
|
||||
title: '活动',
|
||||
fields: [
|
||||
{ key: 'steps', label: '步数', unit: '步' },
|
||||
{ key: 'stepGoal', label: '步数目标', unit: '步' },
|
||||
{ key: 'distanceMeters', label: '距离', unit: 'km', transform: (v) => v / 1000, decimals: 2 },
|
||||
{ key: 'floorsAscended', label: '爬楼上行', unit: '层' },
|
||||
{ key: 'floorsDescended', label: '爬楼下行', unit: '层' },
|
||||
{ key: 'intensityMinutes', label: '强度分钟', unit: '分钟', hint: '中等及以上强度' },
|
||||
{ key: 'activeSeconds', label: '活动时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 },
|
||||
{ key: 'sedentarySeconds', label: '久坐时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '能量',
|
||||
fields: [
|
||||
{ key: 'caloriesBurned', label: '总消耗', unit: 'kcal' },
|
||||
{ key: 'activeCalories', label: '活动消耗', unit: 'kcal' },
|
||||
{ key: 'bmrCalories', label: '基础代谢', unit: 'kcal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '心率',
|
||||
fields: [
|
||||
{ key: 'heartRate', label: '静息心率', unit: 'bpm' },
|
||||
{ key: 'heartRateMin', label: '最低心率', unit: 'bpm' },
|
||||
{ key: 'heartRateMax', label: '最高心率', unit: 'bpm' },
|
||||
{ key: 'heartRateVariability', label: '心率变异性', unit: 'ms', decimals: 1, hint: 'HRV,反映恢复情况' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '压力与身体电量',
|
||||
fields: [
|
||||
{ key: 'stress', label: '平均压力' },
|
||||
{ key: 'stressMax', label: '最高压力' },
|
||||
{ key: 'bodyBatteryHigh', label: '身体电量最高' },
|
||||
{ key: 'bodyBatteryLow', label: '身体电量最低' },
|
||||
{ key: 'bodyBatteryCharged', label: '当日充能' },
|
||||
{ key: 'bodyBatteryDrained', label: '当日消耗' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '睡眠',
|
||||
fields: [
|
||||
{ key: 'sleepDuration', label: '总时长', unit: '小时', decimals: 1 },
|
||||
{ key: 'sleepQuality', label: '睡眠评分', unit: '/100' },
|
||||
{ key: 'sleepDeep', label: '深睡', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||||
{ key: 'sleepLight', label: '浅睡', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||||
{ key: 'sleepRem', label: 'REM', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||||
{ key: 'sleepAwake', label: '夜间清醒', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||||
{ key: 'sleepSpo2Avg', label: '睡眠血氧', unit: '%', decimals: 1 },
|
||||
{ key: 'sleepRespirationAvg', label: '睡眠呼吸', unit: '次/分', decimals: 1 },
|
||||
{ key: 'sleepStressAvg', label: '睡眠压力', decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '血氧与呼吸',
|
||||
fields: [
|
||||
{ key: 'spo2Avg', label: '平均血氧', unit: '%', decimals: 1 },
|
||||
{ key: 'spo2Min', label: '最低血氧', unit: '%' },
|
||||
{ key: 'respirationAvg', label: '平均呼吸', unit: '次/分', decimals: 1 },
|
||||
{ key: 'respirationMin', label: '最低呼吸', unit: '次/分', decimals: 1 },
|
||||
{ key: 'respirationMax', label: '最高呼吸', unit: '次/分', decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '训练',
|
||||
fields: [
|
||||
{ key: 'trainingReadiness', label: '训练准备度', unit: '/100' },
|
||||
{ key: 'vo2max', label: 'VO2max', decimals: 1 },
|
||||
{ key: 'enduranceScore', label: '耐力分' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ACTIVITY_LABEL: Record<string, string> = {
|
||||
running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步',
|
||||
swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练',
|
||||
indoor_cycling: '室内骑行', treadmill_running: '跑步机',
|
||||
};
|
||||
|
||||
function valueOf(day: HealthDay, key: Field['key']): number | null {
|
||||
if (key === 'sleepDeep') return day.sleep?.deepSeconds ?? null;
|
||||
if (key === 'sleepLight') return day.sleep?.lightSeconds ?? null;
|
||||
if (key === 'sleepRem') return day.sleep?.remSeconds ?? null;
|
||||
if (key === 'sleepAwake') return day.sleep?.awakeSeconds ?? null;
|
||||
const v = (day as any)[key];
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
function Daily() {
|
||||
const [date, setDate] = useState(() => iso(new Date()));
|
||||
const [day, setDay] = useState<HealthDay | null>(null);
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [onlyRecorded, setOnlyRecorded] = useState(true);
|
||||
|
||||
const load = useCallback(async (target: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [summary, acts] = await Promise.all([
|
||||
apiClient.getHealthSummary(target, target),
|
||||
apiClient.getActivities(target, target),
|
||||
]);
|
||||
setDay(summary[0] ?? null);
|
||||
setActivities(acts);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(date); }, [date, load]);
|
||||
|
||||
const shift = (delta: number) => {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + delta);
|
||||
if (d > new Date()) return;
|
||||
setDate(iso(d));
|
||||
};
|
||||
|
||||
const recorded = useMemo(() => {
|
||||
if (!day) return 0;
|
||||
return GROUPS.reduce(
|
||||
(n, g) => n + g.fields.filter((f) => valueOf(day, f.key) != null).length, 0
|
||||
);
|
||||
}, [day]);
|
||||
|
||||
const totalFields = GROUPS.reduce((n, g) => n + g.fields.length, 0);
|
||||
const isToday = date === iso(new Date());
|
||||
|
||||
const fmt = (f: Field, raw: number) => {
|
||||
const v = f.transform ? f.transform(raw) : raw;
|
||||
const decimals = f.decimals ?? 0;
|
||||
return v.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>每日数据</h2>
|
||||
<p className="subtitle">
|
||||
{day
|
||||
? `已记录 ${recorded} / ${totalFields} 项指标`
|
||||
: '该日无数据'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="day-nav">
|
||||
<button className="btn btn-plain" onClick={() => shift(-1)} aria-label="前一天">
|
||||
‹ 前一天
|
||||
</button>
|
||||
<input
|
||||
type="date"
|
||||
className="day-input"
|
||||
value={date}
|
||||
max={iso(new Date())}
|
||||
onChange={(e) => e.target.value && setDate(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-plain"
|
||||
onClick={() => shift(1)}
|
||||
disabled={isToday}
|
||||
aria-label="后一天"
|
||||
>
|
||||
后一天 ›
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{loading && <div className="page-loading">加载中…</div>}
|
||||
|
||||
{!loading && !error && !day && (
|
||||
<div className="empty-state">
|
||||
<p>{date} 没有数据。可能当天未佩戴设备,或尚未同步到这一天。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && day && (
|
||||
<>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyRecorded}
|
||||
onChange={(e) => setOnlyRecorded(e.target.checked)}
|
||||
/>
|
||||
只显示有数据的指标
|
||||
</label>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
const fields = onlyRecorded
|
||||
? g.fields.filter((f) => valueOf(day, f.key) != null)
|
||||
: g.fields;
|
||||
if (fields.length === 0) return null;
|
||||
return (
|
||||
<section className="section" key={g.title}>
|
||||
<h3 className="section-title">
|
||||
{g.title}
|
||||
<span className="section-count">{fields.length} 项</span>
|
||||
</h3>
|
||||
<dl className="metric-list">
|
||||
{fields.map((f) => {
|
||||
const raw = valueOf(day, f.key);
|
||||
return (
|
||||
<div className="metric-row" key={String(f.key)}>
|
||||
<dt>
|
||||
{f.label}
|
||||
{f.hint && <span className="metric-hint">{f.hint}</span>}
|
||||
</dt>
|
||||
<dd>
|
||||
{raw == null ? (
|
||||
<span className="metric-empty">未记录</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="metric-value">{fmt(f, raw)}</span>
|
||||
{f.unit && <span className="metric-unit">{f.unit}</span>}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<section className="section">
|
||||
<h3 className="section-title">
|
||||
运动记录
|
||||
<span className="section-count">{activities.length} 条</span>
|
||||
</h3>
|
||||
{activities.length === 0 ? (
|
||||
<p className="placeholder">当天没有运动记录。</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">开始</th>
|
||||
<th scope="col">类型</th>
|
||||
<th scope="col">时长</th>
|
||||
<th scope="col">距离</th>
|
||||
<th scope="col">消耗</th>
|
||||
<th scope="col">平均心率</th>
|
||||
<th scope="col">最高心率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activities.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<th scope="row">{a.start_time?.slice(11, 16)}</th>
|
||||
<td>
|
||||
{ACTIVITY_LABEL[a.activity_type] ??
|
||||
a.activity_type?.replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="num">
|
||||
{a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'}
|
||||
</td>
|
||||
<td className="num">
|
||||
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
|
||||
</td>
|
||||
<td className="num">
|
||||
{a.calories != null ? Math.round(a.calories) : '—'}
|
||||
</td>
|
||||
<td className="num">{a.heart_rate_average ?? '—'}</td>
|
||||
<td className="num">{a.heart_rate_max ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Daily;
|
||||
Reference in New Issue
Block a user