diff --git a/backend/services/health.py b/backend/services/health.py index 2f7d213..d47fd7b 100644 --- a/backend/services/health.py +++ b/backend/services/health.py @@ -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. diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 6820bce..60b0902 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -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 diff --git a/client/src/App.tsx b/client/src/App.tsx index ccfa350..d0d9bb0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,6 +9,7 @@ import Recommendations from './pages/Recommendations'; import Settings from './pages/Settings'; import Sleep from './pages/Sleep'; import Achievements from './pages/Achievements'; +import Daily from './pages/Daily'; import { FEATURES } from './features'; function App() { @@ -25,6 +26,7 @@ function App() { } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index e12a0a9..ce45d9a 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -9,6 +9,7 @@ interface LayoutProps { const NAV = [ { path: '/', label: '今日' }, + { path: '/daily', label: '每日数据' }, { path: '/trends', label: '趋势' }, { path: '/sleep', label: '睡眠' }, { path: '/achievements', label: '成就' }, diff --git a/client/src/lib/aggregate.ts b/client/src/lib/aggregate.ts new file mode 100644 index 0000000..68d6e01 --- /dev/null +++ b/client/src/lib/aggregate.ts @@ -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; +} + +/** + * 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(); + 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 = {}; + 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'; +} diff --git a/client/src/pages/Daily.css b/client/src/pages/Daily.css new file mode 100644 index 0000000..872b636 --- /dev/null +++ b/client/src/pages/Daily.css @@ -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; + } +} diff --git a/client/src/pages/Daily.tsx b/client/src/pages/Daily.tsx new file mode 100644 index 0000000..a811abc --- /dev/null +++ b/client/src/pages/Daily.tsx @@ -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 = { + 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(null); + const [activities, setActivities] = useState([]); + 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 ( +
+
+
+

每日数据

+

+ {day + ? `已记录 ${recorded} / ${totalFields} 项指标` + : '该日无数据'} +

+
+ +
+ + e.target.value && setDate(e.target.value)} + /> + +
+
+ + {error &&
{error}
} + {loading &&
加载中…
} + + {!loading && !error && !day && ( +
+

{date} 没有数据。可能当天未佩戴设备,或尚未同步到这一天。

+
+ )} + + {!loading && !error && day && ( + <> + + + {GROUPS.map((g) => { + const fields = onlyRecorded + ? g.fields.filter((f) => valueOf(day, f.key) != null) + : g.fields; + if (fields.length === 0) return null; + return ( +
+

+ {g.title} + {fields.length} 项 +

+
+ {fields.map((f) => { + const raw = valueOf(day, f.key); + return ( +
+
+ {f.label} + {f.hint && {f.hint}} +
+
+ {raw == null ? ( + 未记录 + ) : ( + <> + {fmt(f, raw)} + {f.unit && {f.unit}} + + )} +
+
+ ); + })} +
+
+ ); + })} + +
+

+ 运动记录 + {activities.length} 条 +

+ {activities.length === 0 ? ( +

当天没有运动记录。

+ ) : ( +
+ + + + + + + + + + + + + + {activities.map((a) => ( + + + + + + + + + + ))} + +
开始类型时长距离消耗平均心率最高心率
{a.start_time?.slice(11, 16)} + {ACTIVITY_LABEL[a.activity_type] ?? + a.activity_type?.replace(/_/g, ' ')} + + {a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'} + + {a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'} + + {a.calories != null ? Math.round(a.calories) : '—'} + {a.heart_rate_average ?? '—'}{a.heart_rate_max ?? '—'}
+
+ )} +
+ + )} +
+ ); +} + +export default Daily; diff --git a/client/src/pages/Pages.css b/client/src/pages/Pages.css index 6fac603..b9ef5e7 100644 --- a/client/src/pages/Pages.css +++ b/client/src/pages/Pages.css @@ -349,3 +349,109 @@ 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; +} diff --git a/client/src/pages/Trends.tsx b/client/src/pages/Trends.tsx index 99964e5..d224ac3 100644 --- a/client/src/pages/Trends.tsx +++ b/client/src/pages/Trends.tsx @@ -1,10 +1,20 @@ import { useEffect, useMemo, useState } from 'react'; import { apiClient, errorMessage, HealthDay } from '../services/api'; 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'; -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 * scale and a unit — a chart never carries two y-scales. */ @@ -13,9 +23,8 @@ interface MetricGroup { label: string; unit?: string; type: 'line' | 'bar' | 'area'; - /** Which HealthDay fields to plot, in palette-slot order. */ series: Series[]; - /** Optional transform, e.g. seconds to hours. */ + /** Optional transform, e.g. metres to kilometres. */ scale?: Record; note?: string; } @@ -86,7 +95,7 @@ const GROUPS: MetricGroup[] = [ }, { 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', @@ -110,36 +119,50 @@ const GROUPS: MetricGroup[] = [ }, ]; -function stats(values: Array) { +function summarise(values: Array) { const present = values.filter((v): v is number => v != null); if (!present.length) return null; const sorted = [...present].sort((a, b) => a - b); const mean = present.reduce((a, b) => a + b, 0) / present.length; const mid = Math.floor(present.length / 2); - const firstHalf = present.slice(0, mid); - const secondHalf = present.slice(mid); const delta = - firstHalf.length && secondHalf.length - ? secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length - - firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length + present.length > 1 + ? present.slice(mid).reduce((a, b) => a + b, 0) / (present.length - mid) - + present.slice(0, mid).reduce((a, b) => a + b, 0) / Math.max(mid, 1) : 0; - return { - count: present.length, - mean, - min: sorted[0], - max: sorted[sorted.length - 1], - median: sorted[mid], - delta, - }; + return { mean, min: sorted[0], max: sorted[sorted.length - 1], 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() { const [days, setDays] = useState([]); - const [range, setRange] = useState(30); - const [active, setActive] = useState(GROUPS[0].id); + const [range, setRange] = useState(365); + const [granularity, setGranularity] = useState(null); const [loading, setLoading] = useState(true); 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>(() => { + try { + return new Set(JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]')); + } catch { + return new Set(); + } + }); + + useEffect(() => { + localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden])); + }, [hidden]); + useEffect(() => { const load = async () => { setLoading(true); @@ -162,116 +185,175 @@ function Trends() { load(); }, [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 - chart leaves ~1.7px each, well under a readable mark or a usable hover - target. Past this many points the same data is drawn as an area instead — - a line handles density natively because its crosshair snaps to the - nearest x rather than needing per-mark hit areas. */ - const DENSE_ABOVE = 90; - 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 = { 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] + // Aggregated once for every metric, so all charts read the same slice — a + // filter row that scoped only some of them would be misleading. + const allKeys = useMemo(() => GROUPS.flatMap((g) => g.series.map((s) => s.key)), []); + const buckets = useMemo( + () => aggregate(days, effective, allKeys), + [days, effective, allKeys] ); - const primary = group.series[0]; - const summary = stats(rows.map((r) => r[primary.key])); - /* Precision by magnitude: "11,013.99 步" is both false precision and long - enough to wrap its unit onto a second line. */ - 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, + const rowsFor = (group: MetricGroup) => + buckets.map((b) => { + const row: Record = { date: b.label }; + for (const s of group.series) { + const raw = b.values[s.key]; + const factor = group.scale?.[s.key]; + row[s.key] = raw == null ? null : factor ? raw * factor : raw; + } + return row; }); - }; + + 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 (

趋势

-

全部 {GROUPS.length} 组指标

+

+ {visible.length} / {GROUPS.length} 组指标 + {days.length > 0 && ` · ${days.length} 天数据`} +

-
- {RANGES.map((r) => ( - - ))} + +
+
+ 范围 +
+ {RANGES.map((r) => ( + + ))} +
+
+
+ 周期 +
+ {GRANULARITIES.filter((g) => g.days <= Math.max(range / 2, 1)).map((g) => ( + + ))} +
+
-
- {GROUPS.map((g) => ( - - ))} -
+
+
+ 显示 +
+ + +
+
+
+ {GROUPS.map((g) => { + const on = !hidden.has(g.id); + return ( + + ); + })} +
+
{error &&
{error}
} {loading &&
加载中…
} - {!loading && !error && ( - <> - {summary ? ( -
- - - - - = 0 ? '+' : ''}${fmt(summary.delta)}`} - unit={primary.unit} - /> - -
- ) : ( -

该指标在所选区间内没有数据。

- )} + {!loading && !error && visible.length === 0 && ( +

所有指标都已隐藏,点上面的标签重新显示。

+ )} -
- -
- + {!loading && !error && visible.length > 0 && ( +
+ {visible.map((g) => { + const rows = rowsFor(g); + const primary = g.series[0]; + const s = summarise(rows.map((r) => r[primary.key])); + /* Bars and areas both encode magnitude by extent, so both must + start at zero — which makes a year of monthly step averages, + all between 9.6k and 12.7k, render as near-identical shapes and + hides exactly the change the reader came for. Once days are + bucketed the question is "how is this trending", and that is a + line's job: it encodes position rather than extent, so a + non-zero axis is legitimate and the variation becomes visible. + Dense daily views switch for the same reason plus hit size. */ + const aggregated = effective !== 'day'; + const type = + aggregated || (g.type === 'bar' && rows.length > 90) + ? ('line' as const) + : g.type; + const meanWord = + effective !== 'day' && isCumulative(primary.key) ? '日均' : '平均'; + + return ( + + {meanWord} {fmt(s.mean)} + {fmt(s.min)} + {fmt(s.max)} + 后半段 {s.delta >= 0 ? '+' : ''}{fmt(s.delta)} + + ) : ( + g.note + ) + } + /> + ); + })} +
)}
);