[阶段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:
@@ -5,6 +5,7 @@ Mirrors the original Node HealthService, including the camelCase JSON mapping.
|
|||||||
Upserts use backend-specific SQL because SQLite does not support
|
Upserts use backend-specific SQL because SQLite does not support
|
||||||
`ON DUPLICATE KEY UPDATE` (it uses `ON CONFLICT ... DO UPDATE`).
|
`ON DUPLICATE KEY UPDATE` (it uses `ON CONFLICT ... DO UPDATE`).
|
||||||
"""
|
"""
|
||||||
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from db import execute, query_one, query_all
|
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):
|
def get_activities(user_id, start=None, end=None):
|
||||||
sql, params = _range_sql(user_id, start, end)
|
"""Activities in a date range.
|
||||||
rows = query_all(
|
|
||||||
|
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, "
|
"SELECT id, activity_type, start_time, end_time, duration, distance, "
|
||||||
"calories, heart_rate_average, heart_rate_max "
|
"calories, heart_rate_average, heart_rate_max "
|
||||||
f"FROM activities {sql} ORDER BY start_time DESC",
|
f"FROM activities {sql} ORDER BY start_time DESC",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
# Column name -> key in the record dict produced by the Garmin extractor.
|
# Column name -> key in the record dict produced by the Garmin extractor.
|
||||||
|
|||||||
@@ -254,3 +254,52 @@ class TestBadgesAndRecords:
|
|||||||
assert isinstance(
|
assert isinstance(
|
||||||
client.get("/api/health/personal-records", headers=auth).get_json(), list
|
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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Recommendations from './pages/Recommendations';
|
|||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import Sleep from './pages/Sleep';
|
import Sleep from './pages/Sleep';
|
||||||
import Achievements from './pages/Achievements';
|
import Achievements from './pages/Achievements';
|
||||||
|
import Daily from './pages/Daily';
|
||||||
import { FEATURES } from './features';
|
import { FEATURES } from './features';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -25,6 +26,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/sync" element={<DataSync />} />
|
<Route path="/sync" element={<DataSync />} />
|
||||||
|
<Route path="/daily" element={<Daily />} />
|
||||||
<Route path="/trends" element={<Trends />} />
|
<Route path="/trends" element={<Trends />} />
|
||||||
<Route path="/sleep" element={<Sleep />} />
|
<Route path="/sleep" element={<Sleep />} />
|
||||||
<Route path="/achievements" element={<Achievements />} />
|
<Route path="/achievements" element={<Achievements />} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface LayoutProps {
|
|||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ path: '/', label: '今日' },
|
{ path: '/', label: '今日' },
|
||||||
|
{ path: '/daily', label: '每日数据' },
|
||||||
{ path: '/trends', label: '趋势' },
|
{ path: '/trends', label: '趋势' },
|
||||||
{ path: '/sleep', label: '睡眠' },
|
{ path: '/sleep', label: '睡眠' },
|
||||||
{ path: '/achievements', label: '成就' },
|
{ path: '/achievements', label: '成就' },
|
||||||
|
|||||||
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';
|
||||||
|
}
|
||||||
125
client/src/pages/Daily.css
Normal file
125
client/src/pages/Daily.css
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
.day-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-input {
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-list {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row dt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row dd {
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 620;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-unit {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls stack: range above granularity, each labelled — two unlabelled
|
||||||
|
pill rows would read as one confusing filter. */
|
||||||
|
.control-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-label {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 2.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.metric-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.day-nav {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.day-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -349,3 +349,109 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Metric picker ------------------------------------------------------------ */
|
||||||
|
.metric-picker {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.off {
|
||||||
|
background: var(--surface-0);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-mark {
|
||||||
|
font-size: 0.72em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.9rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-stats b {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 620;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-label {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 2.4em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||||
import Chart, { Series } from '../components/charts/Chart';
|
import Chart, { Series } from '../components/charts/Chart';
|
||||||
import StatTile from '../components/charts/StatTile';
|
import {
|
||||||
|
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
||||||
|
} from '../lib/aggregate';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
const RANGES = [7, 14, 30, 90, 365];
|
const RANGES = [
|
||||||
|
{ days: 30, label: '近一月' },
|
||||||
|
{ days: 91, label: '近一季' },
|
||||||
|
{ days: 182, label: '近半年' },
|
||||||
|
{ days: 365, label: '近一年' },
|
||||||
|
{ days: 730, label: '近两年' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HIDDEN_KEY = 'ghl_hidden_metrics';
|
||||||
|
|
||||||
/** Each group is one chart. Metrics only share a chart when they share a
|
/** Each group is one chart. Metrics only share a chart when they share a
|
||||||
* scale and a unit — a chart never carries two y-scales. */
|
* scale and a unit — a chart never carries two y-scales. */
|
||||||
@@ -13,9 +23,8 @@ interface MetricGroup {
|
|||||||
label: string;
|
label: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
type: 'line' | 'bar' | 'area';
|
type: 'line' | 'bar' | 'area';
|
||||||
/** Which HealthDay fields to plot, in palette-slot order. */
|
|
||||||
series: Series[];
|
series: Series[];
|
||||||
/** Optional transform, e.g. seconds to hours. */
|
/** Optional transform, e.g. metres to kilometres. */
|
||||||
scale?: Record<string, number>;
|
scale?: Record<string, number>;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
@@ -86,7 +95,7 @@ const GROUPS: MetricGroup[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
|
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
|
||||||
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层', decimals: 0 }],
|
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
|
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
|
||||||
@@ -110,36 +119,50 @@ const GROUPS: MetricGroup[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function stats(values: Array<number | null | undefined>) {
|
function summarise(values: Array<number | null | undefined>) {
|
||||||
const present = values.filter((v): v is number => v != null);
|
const present = values.filter((v): v is number => v != null);
|
||||||
if (!present.length) return null;
|
if (!present.length) return null;
|
||||||
const sorted = [...present].sort((a, b) => a - b);
|
const sorted = [...present].sort((a, b) => a - b);
|
||||||
const mean = present.reduce((a, b) => a + b, 0) / present.length;
|
const mean = present.reduce((a, b) => a + b, 0) / present.length;
|
||||||
const mid = Math.floor(present.length / 2);
|
const mid = Math.floor(present.length / 2);
|
||||||
const firstHalf = present.slice(0, mid);
|
|
||||||
const secondHalf = present.slice(mid);
|
|
||||||
const delta =
|
const delta =
|
||||||
firstHalf.length && secondHalf.length
|
present.length > 1
|
||||||
? secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length -
|
? present.slice(mid).reduce((a, b) => a + b, 0) / (present.length - mid) -
|
||||||
firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
|
present.slice(0, mid).reduce((a, b) => a + b, 0) / Math.max(mid, 1)
|
||||||
: 0;
|
: 0;
|
||||||
return {
|
return { mean, min: sorted[0], max: sorted[sorted.length - 1], delta };
|
||||||
count: present.length,
|
|
||||||
mean,
|
|
||||||
min: sorted[0],
|
|
||||||
max: sorted[sorted.length - 1],
|
|
||||||
median: sorted[mid],
|
|
||||||
delta,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fmt = (v: number) => {
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
|
||||||
|
return v.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function Trends() {
|
function Trends() {
|
||||||
const [days, setDays] = useState<HealthDay[]>([]);
|
const [days, setDays] = useState<HealthDay[]>([]);
|
||||||
const [range, setRange] = useState(30);
|
const [range, setRange] = useState(365);
|
||||||
const [active, setActive] = useState(GROUPS[0].id);
|
const [granularity, setGranularity] = useState<Granularity | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Which charts are hidden. Persisted: a selection that resets on every
|
||||||
|
// reload is not really a preference.
|
||||||
|
const [hidden, setHidden] = useState<Set<string>>(() => {
|
||||||
|
try {
|
||||||
|
return new Set<string>(JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]'));
|
||||||
|
} catch {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden]));
|
||||||
|
}, [hidden]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -162,116 +185,175 @@ function Trends() {
|
|||||||
load();
|
load();
|
||||||
}, [range]);
|
}, [range]);
|
||||||
|
|
||||||
const group = GROUPS.find((g) => g.id === active) ?? GROUPS[0];
|
const effective = granularity ?? suggestGranularity(days.length);
|
||||||
|
const visible = GROUPS.filter((g) => !hidden.has(g.id));
|
||||||
|
|
||||||
/* Bars stop working long before a year fits: 365 of them across a typical
|
// Aggregated once for every metric, so all charts read the same slice — a
|
||||||
chart leaves ~1.7px each, well under a readable mark or a usable hover
|
// filter row that scoped only some of them would be misleading.
|
||||||
target. Past this many points the same data is drawn as an area instead —
|
const allKeys = useMemo(() => GROUPS.flatMap((g) => g.series.map((s) => s.key)), []);
|
||||||
a line handles density natively because its crosshair snaps to the
|
const buckets = useMemo(
|
||||||
nearest x rather than needing per-mark hit areas. */
|
() => aggregate(days, effective, allKeys),
|
||||||
const DENSE_ABOVE = 90;
|
[days, effective, allKeys]
|
||||||
const dense = days.length > DENSE_ABOVE;
|
|
||||||
const renderType =
|
|
||||||
dense && (group.type === 'bar') ? 'area' as const : group.type;
|
|
||||||
|
|
||||||
const rows = useMemo(
|
|
||||||
() =>
|
|
||||||
days.map((d) => {
|
|
||||||
const row: Record<string, any> = { date: d.date.slice(5) };
|
|
||||||
for (const s of group.series) {
|
|
||||||
const raw = (d as any)[s.key];
|
|
||||||
const factor = group.scale?.[s.key];
|
|
||||||
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}),
|
|
||||||
[days, group]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const primary = group.series[0];
|
const rowsFor = (group: MetricGroup) =>
|
||||||
const summary = stats(rows.map((r) => r[primary.key]));
|
buckets.map((b) => {
|
||||||
/* Precision by magnitude: "11,013.99 步" is both false precision and long
|
const row: Record<string, any> = { date: b.label };
|
||||||
enough to wrap its unit onto a second line. */
|
for (const s of group.series) {
|
||||||
const fmt = (v: number) => {
|
const raw = b.values[s.key];
|
||||||
const abs = Math.abs(v);
|
const factor = group.scale?.[s.key];
|
||||||
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
|
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
|
||||||
return v.toLocaleString(undefined, {
|
}
|
||||||
minimumFractionDigits: 0,
|
return row;
|
||||||
maximumFractionDigits: decimals,
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setHidden((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const granLabel =
|
||||||
|
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>趋势</h2>
|
<h2>趋势</h2>
|
||||||
<p className="subtitle">全部 {GROUPS.length} 组指标</p>
|
<p className="subtitle">
|
||||||
|
{visible.length} / {GROUPS.length} 组指标
|
||||||
|
{days.length > 0 && ` · ${days.length} 天数据`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="range-tabs">
|
|
||||||
{RANGES.map((r) => (
|
<div className="control-stack">
|
||||||
<button
|
<div className="control-row">
|
||||||
key={r}
|
<span className="control-label">范围</span>
|
||||||
className={`range-tab ${r === range ? 'active' : ''}`}
|
<div className="range-tabs">
|
||||||
onClick={() => setRange(r)}
|
{RANGES.map((r) => (
|
||||||
>
|
<button
|
||||||
{r === 365 ? '一年' : `${r} 天`}
|
key={r.days}
|
||||||
</button>
|
className={`range-tab ${r.days === range ? 'active' : ''}`}
|
||||||
))}
|
onClick={() => setRange(r.days)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<span className="control-label">周期</span>
|
||||||
|
<div className="range-tabs">
|
||||||
|
{GRANULARITIES.filter((g) => g.days <= Math.max(range / 2, 1)).map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
className={`range-tab ${g.id === effective ? 'active' : ''}`}
|
||||||
|
onClick={() => setGranularity(g.id)}
|
||||||
|
>
|
||||||
|
{g.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="metric-tabs">
|
<section className="metric-picker">
|
||||||
{GROUPS.map((g) => (
|
<div className="picker-head">
|
||||||
<button
|
<span className="control-label">显示</span>
|
||||||
key={g.id}
|
<div className="picker-actions">
|
||||||
className={`metric-tab ${g.id === active ? 'active' : ''}`}
|
<button className="link-button" onClick={() => setHidden(new Set())}>
|
||||||
onClick={() => setActive(g.id)}
|
全选
|
||||||
>
|
</button>
|
||||||
{g.label}
|
<button
|
||||||
</button>
|
className="link-button"
|
||||||
))}
|
onClick={() => setHidden(new Set(GROUPS.map((g) => g.id)))}
|
||||||
</div>
|
>
|
||||||
|
全不选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="picker-chips">
|
||||||
|
{GROUPS.map((g) => {
|
||||||
|
const on = !hidden.has(g.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
className={`chip ${on ? 'on' : 'off'}`}
|
||||||
|
onClick={() => toggle(g.id)}
|
||||||
|
aria-pressed={on}
|
||||||
|
>
|
||||||
|
{/* A mark, not colour alone, carries the on/off state. */}
|
||||||
|
<span className="chip-mark" aria-hidden="true">{on ? '✓' : '+'}</span>
|
||||||
|
{g.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
{loading && <div className="page-loading">加载中…</div>}
|
{loading && <div className="page-loading">加载中…</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && visible.length === 0 && (
|
||||||
<>
|
<p className="placeholder">所有指标都已隐藏,点上面的标签重新显示。</p>
|
||||||
{summary ? (
|
)}
|
||||||
<div className="tile-grid" style={{ marginBottom: '1.25rem' }}>
|
|
||||||
<StatTile label="平均" value={fmt(summary.mean)} unit={primary.unit} />
|
|
||||||
<StatTile label="中位数" value={fmt(summary.median)} unit={primary.unit} />
|
|
||||||
<StatTile label="最低" value={fmt(summary.min)} unit={primary.unit} />
|
|
||||||
<StatTile label="最高" value={fmt(summary.max)} unit={primary.unit} />
|
|
||||||
<StatTile
|
|
||||||
label="后半段对比前半段"
|
|
||||||
value={`${summary.delta >= 0 ? '+' : ''}${fmt(summary.delta)}`}
|
|
||||||
unit={primary.unit}
|
|
||||||
/>
|
|
||||||
<StatTile label="有效天数" value={summary.count} unit="天" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="placeholder">该指标在所选区间内没有数据。</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="chart-grid one-col">
|
{!loading && !error && visible.length > 0 && (
|
||||||
<Chart
|
<div className="chart-grid">
|
||||||
title={group.label}
|
{visible.map((g) => {
|
||||||
unit={group.unit}
|
const rows = rowsFor(g);
|
||||||
subtitle={
|
const primary = g.series[0];
|
||||||
dense && renderType !== group.type
|
const s = summarise(rows.map((r) => r[primary.key]));
|
||||||
? `${days.length} 天数据:柱形在此密度下不可读,已改用面积图`
|
/* Bars and areas both encode magnitude by extent, so both must
|
||||||
: undefined
|
start at zero — which makes a year of monthly step averages,
|
||||||
}
|
all between 9.6k and 12.7k, render as near-identical shapes and
|
||||||
data={rows}
|
hides exactly the change the reader came for. Once days are
|
||||||
type={renderType}
|
bucketed the question is "how is this trending", and that is a
|
||||||
series={group.series}
|
line's job: it encodes position rather than extent, so a
|
||||||
height={320}
|
non-zero axis is legitimate and the variation becomes visible.
|
||||||
footer={group.note}
|
Dense daily views switch for the same reason plus hit size. */
|
||||||
/>
|
const aggregated = effective !== 'day';
|
||||||
</div>
|
const type =
|
||||||
</>
|
aggregated || (g.type === 'bar' && rows.length > 90)
|
||||||
|
? ('line' as const)
|
||||||
|
: g.type;
|
||||||
|
const meanWord =
|
||||||
|
effective !== 'day' && isCumulative(primary.key) ? '日均' : '平均';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chart
|
||||||
|
key={g.id}
|
||||||
|
title={g.label}
|
||||||
|
unit={g.unit}
|
||||||
|
subtitle={
|
||||||
|
effective === 'day'
|
||||||
|
? undefined
|
||||||
|
: `每点为一个${granLabel}周期的日均值,共 ${rows.length} 个周期`
|
||||||
|
}
|
||||||
|
data={rows}
|
||||||
|
type={type}
|
||||||
|
series={g.series}
|
||||||
|
height={210}
|
||||||
|
footer={
|
||||||
|
s ? (
|
||||||
|
<span className="chart-stats">
|
||||||
|
<span>{meanWord} <b>{fmt(s.mean)}</b></span>
|
||||||
|
<span>低 <b>{fmt(s.min)}</b></span>
|
||||||
|
<span>高 <b>{fmt(s.max)}</b></span>
|
||||||
|
<span>后半段 <b>{s.delta >= 0 ? '+' : ''}{fmt(s.delta)}</b></span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
g.note
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user