diff --git a/client/src/components/Screen.css b/client/src/components/Screen.css index 1c9ba0b..911cdea 100644 --- a/client/src/components/Screen.css +++ b/client/src/components/Screen.css @@ -234,59 +234,95 @@ .metric-empty { color: var(--text-muted); font-size: 0.8rem; } /* Metric picker (趋势) ----------------------------------------------------- */ -.metric-picker { - background: var(--surface-1); - border: 1px solid var(--border); - border-radius: 14px; - padding: 0.85rem 1rem; +.trend-tools { + display: grid; + gap: 0.5rem; margin-bottom: 1.1rem; } -.picker-head { +.picker-open { display: flex; align-items: center; - justify-content: space-between; - margin-bottom: 0.6rem; + gap: 0.6rem; + width: 100%; + padding: 0.7rem 0.95rem; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-primary); + font-family: inherit; + font-size: 0.88rem; + cursor: pointer; + text-align: left; + text-decoration: none; + transition: background 0.15s var(--ease); } -.picker-actions { display: flex; gap: 0.85rem; } +.picker-open:active { background: var(--surface-2); } +.picker-open-label { flex: 1; font-weight: 550; } + +.picker-open-count { + font-size: 0.79rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.picker-open-link { color: var(--text-primary); } + +.picker-body { padding: 1rem; } + +.picker-actions { + display: flex; + gap: 1.1rem; + justify-content: flex-end; + margin-bottom: 0.7rem; +} .link-button { background: none; border: none; color: var(--accent); - font-size: 0.78rem; + font-size: 0.83rem; cursor: pointer; padding: 0; font-family: inherit; } -.picker-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; } - -.chip { - display: inline-flex; - align-items: center; - gap: 0.3rem; - padding: 0.32rem 0.72rem; - border-radius: 999px; - font-size: 0.79rem; - cursor: pointer; - font-family: inherit; +.picker-rows { border: 1px solid var(--border); - transition: all 0.15s var(--ease); + border-radius: 14px; + overflow: hidden; + background: var(--surface-1); } -.chip.on { - background: var(--accent-soft); - border-color: color-mix(in srgb, var(--accent) 40%, transparent); +.picker-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.8rem 1rem; + background: none; + border: none; + border-bottom: 1px solid var(--border); + font-family: inherit; + font-size: 0.9rem; + color: var(--text-secondary); + cursor: pointer; + text-align: left; +} + +.picker-row:last-child { border-bottom: none; } +.picker-row.on { color: var(--text-primary); font-weight: 550; } +.picker-row:active { background: var(--surface-2); } + +.picker-row-mark { color: var(--accent); - font-weight: 600; + font-size: 0.95rem; + width: 1em; + text-align: center; } -.chip.off { background: var(--surface-0); color: var(--text-muted); } -.chip:active { transform: scale(0.96); } -.chip-mark { font-size: 0.72em; opacity: 0.85; } - .chart-grid { display: grid; grid-template-columns: 1fr; diff --git a/client/src/lib/metrics.ts b/client/src/lib/metrics.ts new file mode 100644 index 0000000..498ba38 --- /dev/null +++ b/client/src/lib/metrics.ts @@ -0,0 +1,142 @@ +import { HealthDay } from '../services/api'; + +/** + * One registry for every metric the app can show. + * + * The label, unit and accessor used to live separately in 今日, 健康 and 趋势, + * which meant three places to edit and three chances to disagree. A card can + * now hand its id to the detail route and the detail page knows the rest. + */ +export interface MetricDef { + id: string; + label: string; + unit?: string; + decimals?: number; + pick: (d: HealthDay) => number | null; + /** Key into RANGES; absent when the metric has no reference band. */ + range?: string; + /** Cumulative over a period (summed), rather than a level (averaged). */ + cumulative?: boolean; + /** What the number is, in one sentence. Shown on the detail screen. */ + about: string; + /** A screen to open instead of the generic detail page. */ + route?: string; +} + +const share = (part: number | null | undefined, hours: number | null) => + part != null && hours ? (part / 3600 / hours) * 100 : null; + +export const METRICS: Record = { + steps: { + id: 'steps', label: '步数', unit: '步', range: 'steps', cumulative: true, + pick: (d) => d.steps, + about: '一天走过的步数,由手表的加速度计计数。', + }, + distance: { + id: 'distance', label: '距离', unit: 'km', decimals: 2, cumulative: true, + pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null), + about: '一天移动的总距离,包含步行、跑步与骑行。', + }, + caloriesBurned: { + id: 'caloriesBurned', label: '总消耗', unit: 'kcal', cumulative: true, + pick: (d) => d.caloriesBurned, + about: '基础代谢与活动消耗之和。', + }, + activeCalories: { + id: 'activeCalories', label: '活动消耗', unit: 'kcal', cumulative: true, + pick: (d) => d.activeCalories, + about: '扣除基础代谢后,由活动产生的消耗。', + }, + bmrCalories: { + id: 'bmrCalories', label: '基础代谢', unit: 'kcal', cumulative: true, + pick: (d) => d.bmrCalories, + about: '维持生命活动所需的最低能量,由身高体重年龄估算。', + }, + floorsAscended: { + id: 'floorsAscended', label: '爬楼', unit: '层', range: 'floorsAscended', + cumulative: true, pick: (d) => d.floorsAscended, + about: '由气压计推算的爬升层数,约每 3 米记 1 层。', + }, + intensityMinutes: { + id: 'intensityMinutes', label: '强度分钟', unit: '分钟', + range: 'intensityMinutes', cumulative: true, pick: (d) => d.intensityMinutes, + about: '中等及以上强度活动的时长,高强度按双倍计。', + }, + sedentary: { + id: 'sedentary', label: '久坐', unit: '小时', decimals: 1, cumulative: true, + pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null), + about: '清醒但几乎没有移动的时长。', + }, + heartRate: { + id: 'heartRate', label: '静息心率', unit: 'bpm', range: 'heartRate', + pick: (d) => d.heartRate, + about: '一天中最低的稳定心率,通常出现在睡眠时。规律有氧训练会让它长期下降。', + }, + heartRateMax: { + id: 'heartRateMax', label: '最高心率', unit: 'bpm', pick: (d) => d.heartRateMax, + about: '当天记录到的最高心率。', + }, + heartRateVariability: { + id: 'heartRateVariability', label: '心率变异性', unit: 'ms', decimals: 1, + range: 'heartRateVariability', pick: (d) => d.heartRateVariability, + about: '相邻心跳间隔的波动幅度(夜间 RMSSD)。个体差异极大,' + + '和自己的基线比才有意义,和别人比没有意义。', + }, + stress: { + id: 'stress', label: '平均压力', range: 'stress', pick: (d) => d.stress, + about: 'Garmin 由心率变异性推算的 0–100 压力值,反映自主神经的负荷。', + }, + bodyBatteryHigh: { + id: 'bodyBatteryHigh', label: '身体电量峰值', range: 'bodyBatteryHigh', + pick: (d) => d.bodyBatteryHigh, + about: '当天身体电量的最高点,通常是睡醒时。恢复得越好,起点越高。', + }, + spo2Avg: { + id: 'spo2Avg', label: '血氧', unit: '%', range: 'spo2Avg', pick: (d) => d.spo2Avg, + about: '血氧饱和度。腕表用光学方式测量,误差比指夹式大,看趋势为主。', + }, + respirationAvg: { + id: 'respirationAvg', label: '呼吸频率', unit: '次/分', decimals: 1, + range: 'respirationAvg', pick: (d) => d.respirationAvg, + about: '每分钟呼吸次数的平均值。', + }, + sleepDuration: { + id: 'sleepDuration', label: '睡眠时长', unit: '小时', decimals: 1, + range: 'sleepDuration', pick: (d) => d.sleepDuration, route: '/sleep/', + about: '实际入睡时长,不含卧床清醒的时间。', + }, + sleepQuality: { + id: 'sleepQuality', label: '睡眠评分', unit: '/100', range: 'sleepQuality', + pick: (d) => d.sleepQuality, route: '/sleep/', + about: 'Garmin 综合时长、深睡比例、夜醒次数与静息心率给出的评分。', + }, + deepShare: { + id: 'deepShare', label: '深睡占比', unit: '%', + pick: (d) => share(d.sleep?.deepSeconds, d.sleepDuration), route: '/sleep/', + about: '深睡时长占总睡眠的比例,成人常见范围约 13%–23%。', + }, + remShare: { + id: 'remShare', label: 'REM 占比', unit: '%', + pick: (d) => share(d.sleep?.remSeconds, d.sleepDuration), route: '/sleep/', + about: 'REM 睡眠占比,成人常见范围约 20%–25%。', + }, + trainingReadiness: { + id: 'trainingReadiness', label: '训练准备度', unit: '/100', + range: 'trainingReadiness', pick: (d) => d.trainingReadiness, + about: 'Garmin 综合睡眠、恢复时间、HRV 状态与近期负荷给出的当日训练建议分。', + }, + enduranceScore: { + id: 'enduranceScore', label: '耐力分', pick: (d) => d.enduranceScore, + about: 'Garmin 由长期训练负荷与 VO₂max 推算的耐力水平,变化很慢。', + }, + vo2max: { + id: 'vo2max', label: 'VO₂max', unit: 'ml/kg/min', pick: (d) => d.vo2max, + about: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。', + }, +}; + +export const metric = (id: string): MetricDef | undefined => METRICS[id]; + +/** Where a card should navigate when tapped. */ +export const metricHref = (id: string) => + METRICS[id]?.route ?? `/metric/${id}/`; diff --git a/client/src/pages/ActivityDetail.css b/client/src/pages/ActivityDetail.css new file mode 100644 index 0000000..6232894 --- /dev/null +++ b/client/src/pages/ActivityDetail.css @@ -0,0 +1,158 @@ +/* Activity detail -------------------------------------------------------- */ +.ad-hero { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.4rem 1.1rem; + text-align: center; + margin-bottom: 0.85rem; +} + +.ad-hero-main { display: inline-flex; align-items: baseline; gap: 0.3rem; } + +.ad-hero-value { + font-size: 2.9rem; + font-weight: 700; + line-height: 1; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; +} + +.ad-hero-unit { font-size: 1rem; color: var(--text-muted); } +.ad-hero-label { margin-top: 0.45rem; font-size: 0.78rem; color: var(--text-muted); } + +.ad-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; + margin-bottom: 1.3rem; +} + +.ad-tile { + background: var(--surface-1); + padding: 0.8rem 0.6rem; + display: flex; + flex-direction: column; + gap: 0.22rem; + align-items: center; + text-align: center; +} + +.ad-tile-value { + font-size: 1.02rem; + font-weight: 640; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.ad-tile-label { font-size: 0.72rem; color: var(--text-muted); } + +/* Stats rows */ +.ad-rows { + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; + background: var(--surface-1); +} + +.ad-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.62rem 0.95rem; + border-bottom: 1px solid var(--border); +} + +.ad-row:last-child { border-bottom: none; } +.ad-row-label { font-size: 0.85rem; color: var(--text-secondary); } +.ad-row-value { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ad-eval { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + padding: 0.9rem 1rem; +} + +.ad-eval-name { font-size: 1rem; font-weight: 640; color: var(--text-primary); } +.ad-eval-note { margin-top: 0.2rem; font-size: 0.75rem; color: var(--text-muted); } + +.ad-gear { display: flex; flex-wrap: wrap; gap: 0.45rem; } + +.ad-gear-item { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.35rem 0.8rem; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.ad-total th, .ad-total td { + background: var(--surface-2); + font-weight: 640; + color: var(--text-primary); +} + +/* Heart-rate zones ------------------------------------------------------- */ +.zones { + display: flex; + flex-direction: column; + gap: 0.7rem; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + padding: 0.95rem 1rem; +} + +.zone-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.3rem; +} + +.zone-name { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); } +.zone-range { font-weight: 400; font-size: 0.74rem; color: var(--text-muted); margin-left: 0.4rem; } + +.zone-time { + font-size: 0.83rem; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.zone-pct { color: var(--text-muted); margin-left: 0.5rem; font-size: 0.76rem; } + +.zone-bar { + height: 6px; + background: var(--surface-0); + border-radius: 999px; + overflow: hidden; +} + +/* Zones are an ordered scale, so they take one hue getting darker rather + than five unrelated categorical colours. */ +.zone-fill { height: 100%; border-radius: 999px; transition: width 0.5s var(--ease); } +.zone-fill.z1 { background: var(--seq-1); } +.zone-fill.z2 { background: var(--seq-2); } +.zone-fill.z3 { background: var(--seq-3); } +.zone-fill.z4 { background: var(--seq-4); } +.zone-fill.z5 { background: var(--seq-5); } + +@media (prefers-reduced-motion: reduce) { + .zone-fill { transition: none; } +} diff --git a/client/src/pages/ActivityDetailPage.tsx b/client/src/pages/ActivityDetailPage.tsx new file mode 100644 index 0000000..65d9b98 --- /dev/null +++ b/client/src/pages/ActivityDetailPage.tsx @@ -0,0 +1,421 @@ +import { useEffect, useMemo, useState } from 'react'; +import { apiClient, ActivityDetail, errorMessage } from '../services/api'; +import Screen from '../components/Screen'; +import Chart from '../components/charts/Chart'; +import Skeleton from '../components/Skeleton'; +import './ActivityDetail.css'; + +type Tab = 'overview' | 'stats' | 'laps' | 'charts'; +type Axis = 'time' | 'distance'; + +const TABS: Array<[Tab, string]> = [ + ['overview', '概览'], ['stats', '数据'], ['laps', '分段'], ['charts', '图表'], +]; + +const TYPE_LABEL: Record = { + running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步', + swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练', + indoor_cycling: '室内骑行', treadmill_running: '跑步机', badminton: '羽毛球', + yoga: '瑜伽', mountaineering: '登山', elliptical: '椭圆机', rowing: '划船', + open_water_swimming: '公开水域游泳', stair_climbing: '爬楼梯', + fitness_equipment: '健身器械', +}; + +/* --- formatting ---------------------------------------------------------- */ + +const hms = (seconds?: number | null) => { + if (seconds == null) return '—'; + const s = Math.round(seconds); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const pad = (n: number) => String(n).padStart(2, '0'); + return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; +}; + +/** Pace from speed in m/s, as mm:ss per km — the reading runners actually use. */ +const pace = (metresPerSecond?: number | null) => { + if (!metresPerSecond) return '—'; + const secondsPerKm = 1000 / metresPerSecond; + const m = Math.floor(secondsPerKm / 60); + const s = Math.round(secondsPerKm % 60); + return `${m}:${String(s).padStart(2, '0')} /km`; +}; + +const kmh = (metresPerSecond?: number | null) => + metresPerSecond == null ? '—' : `${(metresPerSecond * 3.6).toFixed(1)} km/h`; + +const num = (v?: number | null, digits = 0, unit = '') => + v == null ? '—' : `${v.toLocaleString(undefined, { + minimumFractionDigits: digits, maximumFractionDigits: digits, + })}${unit ? ` ${unit}` : ''}`; + +const km = (metres?: number | null) => + metres == null ? '—' : `${(metres / 1000).toFixed(2)} km`; + +/* Groups mirror the watch app's Stats tab, so a value sits where it is + expected. A group with nothing recorded is dropped rather than shown empty. */ +function statGroups(s: Record) { + return [ + ['配速', [ + ['平均配速', pace(s.averageSpeed)], + ['移动平均配速', pace(s.averageMovingSpeed)], + ['最快配速', pace(s.maxSpeed)], + ]], + ['速度', [ + ['平均速度', kmh(s.averageSpeed)], + ['移动平均速度', kmh(s.averageMovingSpeed)], + ['最高速度', kmh(s.maxSpeed)], + ]], + ['计时', [ + ['总时间', hms(s.duration)], + ['移动时间', hms(s.movingDuration)], + ['流逝时间', hms(s.elapsedDuration)], + ]], + ['心率', [ + ['平均心率', num(s.averageHR, 0, 'bpm')], + ['最高心率', num(s.maxHR, 0, 'bpm')], + ]], + ['训练效果', [ + ['主要收益', s.trainingEffectLabel ?? '—'], + ['有氧', num(s.trainingEffect, 1)], + ['无氧', num(s.anaerobicTrainingEffect, 1)], + ['运动负荷', num(s.activityTrainingLoad, 0)], + ]], + ['营养与补水', [ + ['静息消耗', num(s.bmrCalories, 0, 'kcal')], + ['活动消耗', num( + s.calories != null && s.bmrCalories != null + ? s.calories - s.bmrCalories : null, 0, 'kcal')], + ['总消耗', num(s.calories, 0, 'kcal')], + ['预估出汗量', num(s.waterEstimated, 0, 'ml')], + ]], + ['温度', [ + ['平均温度', num(s.averageTemperature, 0, '°C')], + ['最低温度', num(s.minTemperature, 0, '°C')], + ['最高温度', num(s.maxTemperature, 0, '°C')], + ]], + ['强度分钟', [ + ['中等', num(s.moderateIntensityMinutes, 0, '分钟')], + ['高强度', num(s.vigorousIntensityMinutes, 0, '分钟')], + ['合计', num( + (s.moderateIntensityMinutes ?? 0) + (s.vigorousIntensityMinutes ?? 0) || null, + 0, '分钟')], + ]], + ['海拔', [ + ['总爬升', num(s.elevationGain, 0, 'm')], + ['总下降', num(s.elevationLoss, 0, 'm')], + ['最低海拔', num(s.minElevation, 0, 'm')], + ['最高海拔', num(s.maxElevation, 0, 'm')], + ]], + ['步频', [ + ['平均步频', num(s.averageRunCadence ?? s.averageBikeCadence, 0, 'spm')], + ['最高步频', num(s.maxRunCadence ?? s.maxBikeCadence, 0, 'spm')], + ]], + ['功率', [ + ['平均功率', num(s.averagePower, 0, 'W')], + ['最大功率', num(s.maxPower, 0, 'W')], + ['标准化功率', num(s.normPower, 0, 'W')], + ]], + ] as Array<[string, Array<[string, string]>]>; +} + +/* --- heart-rate zones ---------------------------------------------------- */ + +const ZONE_NAME = ['', '热身', '轻松', '有氧', '阈值', '最大']; + +function Zones({ zones }: { zones: ActivityDetail['hrZones'] }) { + const total = zones.reduce((sum, z) => sum + (z.seconds || 0), 0); + if (!total) return null; + + return ( +
+

心率区间

+
+ {[...zones].reverse().map((z) => { + const share = total ? (z.seconds / total) * 100 : 0; + return ( +
+
+ + 区间 {z.zone} + + {z.lowBoundary != null ? `≥ ${Math.round(z.lowBoundary)} bpm` : ''} + {ZONE_NAME[z.zone] ? ` · ${ZONE_NAME[z.zone]}` : ''} + + + + {hms(z.seconds)}{Math.round(share)}% + +
+
+
+
+
+ ); + })} +
+
+ ); +} + +/* --- page ---------------------------------------------------------------- */ + +interface Props { + id?: string; + f7route?: { params: { id: string } }; +} + +function ActivityDetailPage({ id, f7route }: Props) { + const activityId = id ?? f7route?.params.id ?? ''; + const [detail, setDetail] = useState(null); + const [tab, setTab] = useState('overview'); + const [axis, setAxis] = useState('time'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + apiClient + .getActivityDetail(activityId) + .then((d) => { if (!cancelled) setDetail(d); }) + .catch((err) => { if (!cancelled) setError(errorMessage(err, '加载失败')); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [activityId]); + + /* The series arrive as parallel arrays; recharts wants one row per sample. + The x value is whichever axis is selected, formatted for reading. */ + const chartRows = useMemo(() => { + if (!detail) return []; + const s = detail.series; + const length = Math.max(...Object.values(s).map((a) => a.length), 0); + if (!length) return []; + + const elapsed = s.elapsed ?? s.duration; + return Array.from({ length }, (_, i) => ({ + x: axis === 'distance' + ? s.distance?.[i] != null ? +(s.distance[i]! / 1000).toFixed(2) : null + : elapsed?.[i] != null ? Math.round(elapsed[i]! / 60) : i, + heartRate: s.heartRate?.[i] ?? null, + speed: s.speed?.[i] != null ? +(s.speed[i]! * 3.6).toFixed(1) : null, + elevation: s.elevation?.[i] ?? null, + cadence: s.cadence?.[i] ?? null, + temperature: s.temperature?.[i] ?? null, + power: s.power?.[i] ?? null, + })).filter((row) => row.x != null); + }, [detail, axis]); + + const title = detail + ? detail.activityName + || TYPE_LABEL[detail.activityType ?? ''] + || detail.activityType + || '运动' + : '运动'; + + if (loading) { + return ( + +

正在从 Garmin 获取本次运动的完整数据…

+ +
+ ); + } + + if (error || !detail) { + return ( + +
{error || '加载失败'}
+
+ ); + } + + const s = detail.summary as Record; + const hasDistance = !!detail.series.distance?.some((v) => v != null); + + const charts: Array<[string, string, string, 'area' | 'line', number]> = [ + ['heartRate', '心率', 'bpm', 'area', 1], + ['speed', '速度', 'km/h', 'area', 2], + ['elevation', '海拔', 'm', 'area', 3], + ['cadence', '步频', 'spm', 'line', 4], + ['power', '功率', 'W', 'line', 5], + ['temperature', '温度', '°C', 'line', 6], + ]; + + return ( + +
+ {TABS.map(([tabId, text]) => ( + + ))} +
+ + {tab === 'overview' && ( + <> +
+
+ + {s.distance ? (s.distance / 1000).toFixed(2) : hms(s.duration)} + + {s.distance ? 'km' : ''} +
+
{s.distance ? '距离' : '总时间'}
+
+ +
+ {[ + ['总时间', hms(s.duration)], + ['平均心率', num(s.averageHR, 0, 'bpm')], + ['平均速度', kmh(s.averageSpeed)], + ['总消耗', num(s.calories, 0, 'kcal')], + ['总爬升', num(s.elevationGain, 0, 'm')], + ['有氧效果', num(s.trainingEffect, 1)], + ].map(([label, value]) => ( +
+ {value} + {label} +
+ ))} +
+ + {s.trainingEffectLabel && ( +
+

评估

+
+
{s.trainingEffectLabel}
+
主要收益
+
+
+ )} + + + + {!!detail.gear.length && ( +
+

装备

+
+ {detail.gear.map((g, i) => ( +
+ {g.displayName ?? g.customMakeModel ?? '装备'} +
+ ))} +
+
+ )} + + )} + + {tab === 'stats' && ( +
+ {statGroups(s) + .filter(([, rows]) => rows.some(([, v]) => v !== '—')) + .map(([group, rows]) => ( +
+

{group}

+
+ {rows.filter(([, v]) => v !== '—').map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ ))} +
+ )} + + {tab === 'laps' && ( + detail.laps.length === 0 ? ( +

这次运动没有分段记录。

+ ) : ( +
+ + + + + + + + + + + + {detail.laps.map((lap) => ( + + + + + + + + ))} + + + + + + + + +
分段时间距离平均速度平均心率
{lap.index}{hms(lap.duration)}{km(lap.distance)}{kmh(lap.averageSpeed)}{num(lap.averageHR, 0)}
合计{hms(s.duration)}{km(s.distance)}{kmh(s.averageSpeed)}{num(s.averageHR, 0)}
+
+ ) + )} + + {tab === 'charts' && ( + chartRows.length === 0 ? ( +

这次运动没有采样曲线。

+ ) : ( + <> + {hasDistance && ( +
+ 横轴 +
+ + +
+
+ )} + +
+ {charts + .filter(([key]) => chartRows.some((r) => (r as any)[key] != null)) + .map(([key, label, unit, type, slot]) => ( + + ))} +
+ + ) + )} +
+ ); +} + +export default ActivityDetailPage; diff --git a/client/src/pages/BodyAge.css b/client/src/pages/BodyAge.css new file mode 100644 index 0000000..cfa38f5 --- /dev/null +++ b/client/src/pages/BodyAge.css @@ -0,0 +1,84 @@ +.ba-hero { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.6rem 1.1rem; + text-align: center; + margin-bottom: 1.2rem; +} + +.ba-value { + font-size: 3.4rem; + font-weight: 700; + line-height: 1; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; +} + +.ba-unit { font-size: 1.1rem; font-weight: 500; color: var(--text-muted); margin-left: 0.25rem; letter-spacing: 0; } +.ba-delta { margin-top: 0.7rem; font-size: 0.95rem; font-weight: 600; color: var(--text-primary); } +.ba-delta.good { color: var(--status-good); } +.ba-delta.warn { color: var(--status-warning); } +.ba-actual { margin-top: 0.3rem; font-size: 0.78rem; color: var(--text-muted); } + +.ba-steps { + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; + background: var(--surface-1); +} + +.ba-step { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.72rem 0.95rem; + border-bottom: 1px solid var(--border); +} + +.ba-step:last-child { border-bottom: none; } + +.ba-step-name { + font-size: 0.87rem; + color: var(--text-secondary); + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.ba-step-input { font-size: 0.73rem; color: var(--text-muted); font-variant-numeric: tabular-nums; } + +.ba-step-years { + font-size: 0.95rem; + font-weight: 620; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ba-step-total { background: var(--surface-2); } +.ba-step-total .ba-step-name { color: var(--text-primary); font-weight: 620; } + +.ba-note, .ba-summary { + margin: 0.8rem 0 0; + font-size: 0.8rem; + line-height: 1.75; + color: var(--text-muted); +} + +.ba-summary { margin: 0 0 0.9rem; color: var(--text-secondary); font-size: 0.86rem; } + +.ba-basis { display: flex; flex-direction: column; gap: 0.6rem; } + +.ba-basis-item { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.8rem 0.95rem; +} + +.ba-basis-name { font-size: 0.86rem; font-weight: 620; color: var(--text-primary); margin-bottom: 0.3rem; } +.ba-basis-detail { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); } +.ba-basis-source { margin-top: 0.35rem; font-size: 0.74rem; color: var(--text-muted); } diff --git a/client/src/pages/BodyAgePage.tsx b/client/src/pages/BodyAgePage.tsx new file mode 100644 index 0000000..44efa15 --- /dev/null +++ b/client/src/pages/BodyAgePage.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'framework7-react'; +import { apiClient, errorMessage, FitnessAge } from '../services/api'; +import Screen from '../components/Screen'; +import Skeleton from '../components/Skeleton'; +import './BodyAge.css'; + +function BodyAgePage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + apiClient + .getFitnessAge() + .then(setData) + .catch((err) => setError(errorMessage(err, '加载失败'))) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ; + } + if (error || !data) { + return ( + +
{error || '加载失败'}
+
+ ); + } + + const delta = data.delta ?? 0; + + return ( + + {data.value == null ? ( +
+

还差 {data.missing.join('、')} 才能推算。

+ + 去补全资料 + +
+ ) : ( + <> +
+
+ {data.value} +
+
0 ? 'warn' : ''}`}> + {delta === 0 + ? '与实际年龄相当' + : `比实际年龄${delta < 0 ? '年轻' : '大'} ${Math.abs(delta)} 岁`} +
+
实际年龄 {data.chronologicalAge} 岁
+
+ +
+

推算过程

+
+ {(data.steps ?? []).map((s) => ( +
+
+ {s.label} + {s.input} +
+
+ {s.kind === 'base' + ? `${s.years} 岁` + : s.years === 0 + ? '不修正' + : `${s.years > 0 ? '+' : ''}${s.years} 岁`} +
+
+ ))} +
+
身体年龄
+
{data.value} 岁
+
+
+ {data.clamped && ( +

+ 原始结果超出了实际年龄 ±20 岁,已收敛到边界——参考表以外的外推 + 说明的是表的边界,不是你。 +

+ )} +
+ + )} + +
+

{data.basis.title}

+

{data.basis.summary}

+
+ {data.basis.steps.map((s) => ( +
+
{s.name}
+
{s.detail}
+
来源:{s.source}
+
+ ))} +
+
+ +

{data.basis.caveat}

+
+ ); +} + +export default BodyAgePage; diff --git a/client/src/pages/Exercise.css b/client/src/pages/Exercise.css index 63b49fa..d500cd3 100644 --- a/client/src/pages/Exercise.css +++ b/client/src/pages/Exercise.css @@ -61,10 +61,24 @@ border-radius: 12px; padding: 0.7rem 0.85rem; transition: border-color 0.15s var(--ease), transform 0.15s var(--ease); + /* Now a ))} ) diff --git a/client/src/pages/Health.css b/client/src/pages/Health.css new file mode 100644 index 0000000..14ccb2b --- /dev/null +++ b/client/src/pages/Health.css @@ -0,0 +1,51 @@ +/* Body age card ---------------------------------------------------------- */ +.bodyage { + display: flex; + align-items: center; + gap: 0.9rem; + width: 100%; + text-align: left; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1rem 1.05rem; + cursor: pointer; + font-family: inherit; + transition: transform 0.15s var(--ease), background 0.15s var(--ease); +} + +.bodyage:active { transform: scale(0.985); background: var(--surface-2); } + +.bodyage-main { display: flex; align-items: baseline; gap: 0.2rem; } + +.bodyage-value { + font-size: 2.4rem; + font-weight: 700; + line-height: 1; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.bodyage-unit { font-size: 0.9rem; color: var(--text-muted); } + +.bodyage-side { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.bodyage-delta { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); } +.bodyage-delta.good { color: var(--status-good); } +.bodyage-delta.warn { color: var(--status-warning); } +.bodyage-note { font-size: 0.74rem; color: var(--text-muted); } + +.bodyage-empty { justify-content: space-between; } +.bodyage-missing { font-size: 0.85rem; color: var(--text-secondary); line-height: 1.6; } + +@media (prefers-reduced-motion: reduce) { + .bodyage { transition: none; } + .bodyage:active { transform: none; } +} diff --git a/client/src/pages/HealthPage.tsx b/client/src/pages/HealthPage.tsx index c04d110..0d27d95 100644 --- a/client/src/pages/HealthPage.tsx +++ b/client/src/pages/HealthPage.tsx @@ -1,94 +1,72 @@ import { useEffect, useState } from 'react'; -import { Link } from 'framework7-react'; -import { apiClient, errorMessage, HealthDay } from '../services/api'; +import { Link, f7 } from 'framework7-react'; +import { apiClient, errorMessage, FitnessAge, HealthDay } from '../services/api'; +import { METRICS, metricHref } from '../lib/metrics'; import MetricCard from '../components/charts/MetricCard'; import Skeleton from '../components/Skeleton'; import Screen from '../components/Screen'; +import './Health.css'; const WINDOW_DAYS = 30; -interface Item { - metric?: string; - label: string; - pick: (d: HealthDay) => number | null; - unit?: string; - decimals?: number; - detail?: (d: HealthDay) => string | undefined; -} - -const SECTIONS: Array<{ title: string; items: Item[] }> = [ - { - title: '身体指标', - items: [ - { metric: 'heartRate', label: '静息心率', pick: (d) => d.heartRate, unit: 'bpm' }, - { - metric: 'heartRateVariability', label: '心率变异性', - pick: (d) => d.heartRateVariability, unit: 'ms', decimals: 1, - }, - { metric: 'respirationAvg', label: '呼吸频率', pick: (d) => d.respirationAvg, unit: '次/分', decimals: 1 }, - { metric: 'spo2Avg', label: '血氧', pick: (d) => d.spo2Avg, unit: '%' }, - ], - }, - { - title: '恢复', - items: [ - { metric: 'bodyBatteryHigh', label: '身体电量峰值', pick: (d) => d.bodyBatteryHigh }, - { metric: 'stress', label: '平均压力', pick: (d) => d.stress }, - { metric: 'trainingReadiness', label: '训练准备度', pick: (d) => d.trainingReadiness, unit: '/100' }, - { label: '耐力分', pick: (d) => d.enduranceScore }, - ], - }, - { - title: '睡眠', - items: [ - { metric: 'sleepDuration', label: '睡眠时长', pick: (d) => d.sleepDuration, unit: '小时', decimals: 1 }, - { metric: 'sleepQuality', label: '睡眠评分', pick: (d) => d.sleepQuality, unit: '/100' }, - { - label: '深睡占比', unit: '%', - pick: (d) => - d.sleep?.deepSeconds != null && d.sleepDuration - ? (d.sleep.deepSeconds / 3600 / d.sleepDuration) * 100 - : null, - decimals: 0, - }, - { - label: 'REM 占比', unit: '%', - pick: (d) => - d.sleep?.remSeconds != null && d.sleepDuration - ? (d.sleep.remSeconds / 3600 / d.sleepDuration) * 100 - : null, - decimals: 0, - }, - ], - }, - { - title: '活动', - items: [ - { metric: 'steps', label: '步数', pick: (d) => d.steps, unit: '步' }, - { metric: 'intensityMinutes', label: '强度分钟', pick: (d) => d.intensityMinutes, unit: '分钟' }, - { metric: 'floorsAscended', label: '爬楼', pick: (d) => d.floorsAscended, unit: '层' }, - { - label: '距离', unit: 'km', decimals: 2, - pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null), - }, - ], - }, - { - title: '能量', - items: [ - { label: '总消耗', pick: (d) => d.caloriesBurned, unit: 'kcal' }, - { label: '活动消耗', pick: (d) => d.activeCalories, unit: 'kcal' }, - { label: '基础代谢', pick: (d) => d.bmrCalories, unit: 'kcal' }, - { - label: '久坐', unit: '小时', decimals: 1, - pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null), - }, - ], - }, +/* Sections name metric ids; the labels, units and accessors come from the + registry so 今日 / 健康 / 趋势 cannot disagree about what a metric is. */ +const SECTIONS: Array<{ title: string; items: string[] }> = [ + { title: '身体指标', items: ['heartRate', 'heartRateVariability', 'respirationAvg', 'spo2Avg'] }, + { title: '恢复', items: ['bodyBatteryHigh', 'stress', 'trainingReadiness', 'enduranceScore'] }, + { title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] }, + { title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] }, + { title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] }, ]; +function BodyAge({ data }: { data: FitnessAge | null }) { + if (!data) return null; + + const go = () => f7.views.current.router.navigate('/body-age/'); + + if (data.value == null) { + return ( +
+

身体年龄

+ +
+ ); + } + + const delta = data.delta ?? 0; + + return ( +
+

身体年龄

+ +
+ ); +} + function HealthPage() { const [days, setDays] = useState([]); + const [bodyAge, setBodyAge] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -110,12 +88,14 @@ function HealthPage() { } }; load(); + // Body age is a separate, optional read: an empty profile must not stop + // the rest of the page from rendering. + apiClient.getFitnessAge().then(setBodyAge).catch(() => setBodyAge(null)); }, []); if (loading) { return ( - }> -

健康

+ ); @@ -123,8 +103,7 @@ function HealthPage() { if (error) { return ( - }> -

健康

+
{error}
); @@ -132,11 +111,12 @@ function HealthPage() { if (days.length === 0) { return ( - }> -

健康

+

还没有任何健康数据。

- 去同步 Garmin 数据 + + 去同步 Garmin 数据 +
); @@ -149,29 +129,33 @@ function HealthPage() { const v = pick(days[i]); if (v != null) return { value: v, date: days[i].date }; } - return { value: null, date: null }; + return { value: null as number | null, date: null as string | null }; }; return ( - }> + + {SECTIONS.map((section) => (

{section.title}

- {section.items.map((item) => { - const { value, date } = latest(item.pick); + {section.items.map((id) => { + const def = METRICS[id]; + if (!def) return null; + const { value, date } = latest(def.pick); const stale = date != null && date !== days[days.length - 1].date; return ( f7.views.current.router.navigate(metricHref(id))} /> ); })} diff --git a/client/src/pages/MetricDetail.css b/client/src/pages/MetricDetail.css new file mode 100644 index 0000000..d7ad7dc --- /dev/null +++ b/client/src/pages/MetricDetail.css @@ -0,0 +1,90 @@ +/* Metric detail --------------------------------------------------------- */ +.md-hero { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.2rem 1.1rem 1rem; + margin-bottom: 1rem; +} + +.md-value { + font-size: 2.6rem; + font-weight: 700; + line-height: 1; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.md-unit { + font-size: 0.95rem; + font-weight: 500; + color: var(--text-muted); + margin-left: 0.35rem; + letter-spacing: 0; +} + +.md-verdict { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin: 0.7rem 0 0.55rem; + font-size: 0.86rem; +} + +/* Status is icon + word, never colour alone. */ +.md-tone { display: inline-flex; align-items: center; gap: 0.3rem; font-weight: 600; } +.md-tone.tone-good { color: var(--status-good); } +.md-tone.tone-warning { color: var(--status-warning); } +.md-tone.tone-serious { color: var(--status-serious); } +.md-tone.tone-critical { color: var(--status-critical); } + +.md-target { color: var(--text-muted); font-size: 0.79rem; } +.md-when { margin-top: 0.6rem; font-size: 0.75rem; color: var(--text-muted); } + +.md-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; + margin-bottom: 1.1rem; +} + +.md-stat { + background: var(--surface-1); + padding: 0.7rem 0.6rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + align-items: center; +} + +.md-stat-label { font-size: 0.72rem; color: var(--text-muted); } +.md-stat-value { + font-size: 1.02rem; + font-weight: 640; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.md-about { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem 1.1rem 0.4rem; +} + +.md-about p { + margin: 0 0 1rem; + font-size: 0.86rem; + line-height: 1.75; + color: var(--text-secondary); +} + +.md-about .sec-title { margin-bottom: 0.5rem; } +.md-bands { font-variant-numeric: tabular-nums; } +.md-source { color: var(--text-muted) !important; font-size: 0.79rem !important; } diff --git a/client/src/pages/MetricDetailPage.tsx b/client/src/pages/MetricDetailPage.tsx new file mode 100644 index 0000000..7bd13c1 --- /dev/null +++ b/client/src/pages/MetricDetailPage.tsx @@ -0,0 +1,209 @@ +import { useEffect, useMemo, useState } from 'react'; +import { apiClient, errorMessage, HealthDay, RatingBasis } from '../services/api'; +import { METRICS } from '../lib/metrics'; +import { classify, formatTarget, RANGES } from '../lib/ranges'; +import Screen from '../components/Screen'; +import Chart from '../components/charts/Chart'; +import BandBar from '../components/charts/BandBar'; +import Skeleton from '../components/Skeleton'; +import { useCountUp } from '../lib/motion'; +import './MetricDetail.css'; + +const WINDOWS = [7, 30, 90, 365]; + +const TONE_ICON: Record = { + good: '✓', warning: '!', serious: '↓', critical: '!', +}; + +interface Props { + /** Framework7 passes route params to the page component. */ + id?: string; + f7route?: { params: { id: string } }; +} + +function MetricDetailPage({ id, f7route }: Props) { + const key = id ?? f7route?.params.id ?? ''; + const def = METRICS[key]; + + const [days, setDays] = useState([]); + const [basis, setBasis] = useState(null); + const [window, setWindow] = useState(30); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + if (!def) return; + let cancelled = false; + setLoading(true); + const load = async () => { + try { + const end = new Date(); + const start = new Date(end.getTime() - (window - 1) * 86400000); + const rows = await apiClient.getHealthSummary( + start.toISOString().slice(0, 10), end.toISOString().slice(0, 10) + ); + if (!cancelled) setDays(rows); + } catch (err: any) { + if (!cancelled) setError(errorMessage(err, '加载失败')); + } finally { + if (!cancelled) setLoading(false); + } + }; + load(); + return () => { cancelled = true; }; + }, [def, window]); + + useEffect(() => { + apiClient.getRatingBasis().then(setBasis).catch(() => setBasis(null)); + }, []); + + const values = useMemo( + () => (def ? days.map(def.pick).filter((v): v is number => v != null) : []), + [days, def] + ); + + const latest = useMemo(() => { + if (!def) return { value: null as number | null, date: null as string | null }; + for (let i = days.length - 1; i >= 0; i--) { + const v = def.pick(days[i]); + if (v != null) return { value: v, date: days[i].date }; + } + return { value: null, date: null }; + }, [days, def]); + + const animated = useCountUp(latest.value); + + if (!def) { + return ( + +

没有这个指标。

+
+ ); + } + + const verdict = def.range ? classify(def.range, latest.value) : null; + const range = def.range ? RANGES[def.range] : undefined; + + const avg = values.length + ? values.reduce((a, b) => a + b, 0) / values.length : null; + const max = values.length ? Math.max(...values) : null; + const min = values.length ? Math.min(...values) : null; + const inBand = range + ? days.filter((d) => { + const v = def.pick(d); + return v != null && v >= range.goodFrom && v <= range.goodTo; + }).length + : null; + + const fmt = (v: number | null) => + v == null ? '—' : v.toLocaleString(undefined, { + minimumFractionDigits: 0, maximumFractionDigits: def.decimals ?? 0, + }); + + const rows = days.map((d) => ({ date: d.date.slice(5), value: def.pick(d) })); + const source = basis?.bands.find((b) => b.metric === def.label); + + return ( + + {loading && days.length === 0 ? ( + + ) : error ? ( +
{error}
+ ) : ( + <> +
+
+ {latest.value == null ? '—' : fmt(animated ?? latest.value)} + {def.unit && latest.value != null && ( + {def.unit} + )} +
+ + {verdict && latest.value != null && ( +
+ + + {verdict.band.label} + + 参考 {formatTarget(verdict.range)} +
+ )} + + {verdict && latest.value != null && ( + + )} + +
+ {latest.date ? `最近记录 ${latest.date}` : '暂无数据'} +
+
+ +
+ 范围 +
+ {WINDOWS.map((w) => ( + + ))} +
+
+ +
+ {[ + ['平均', avg], ['最高', max], ['最低', min], + ].map(([label, value]) => ( +
+ {label} + {fmt(value as number | null)} +
+ ))} + {inBand != null && ( +
+ 达标天数 + {inBand}/{days.length} +
+ )} +
+ +
+ +
+ +
+

这个指标是什么

+

{def.about}

+ + {source && ( + <> +

评分依据

+

{source.bands}

+

来源:{source.source}

+ + )} +
+ +

+ 参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。 +

+ + )} +
+ ); +} + +export default MetricDetailPage; diff --git a/client/src/pages/TrendsPage.tsx b/client/src/pages/TrendsPage.tsx index 4a810d4..126cbe3 100644 --- a/client/src/pages/TrendsPage.tsx +++ b/client/src/pages/TrendsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Link } from 'framework7-react'; +import { Link, Popup, Page, Navbar, NavRight, Link as F7Link } from 'framework7-react'; import { apiClient, errorMessage, HealthDay } from '../services/api'; import Chart, { Series } from '../components/charts/Chart'; import Skeleton from '../components/Skeleton'; @@ -148,6 +148,7 @@ function TrendsPage() { const [days, setDays] = useState([]); const [range, setRange] = useState(365); const [granularity, setGranularity] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -221,7 +222,7 @@ function TrendsPage() { GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? ''; return ( - }> +
范围 @@ -243,45 +244,82 @@ function TrendsPage() {
-
-
- 显示 -
- - -
-
-
- {GROUPS.map((g) => { - const on = !hidden.has(g.id); - return ( - + + + 每日数据 + + +
+ + {/* A wall of fifteen chips pushed the charts off the screen and looked + like a form. The choice lives in a sheet now; the row above says what + is on without spending the space. */} + setPickerOpen(false)} + > + + + + 完成 + + + +
+
+ - ); - })} -
-
+ + + +
+ {GROUPS.map((g) => { + const on = !hidden.has(g.id); + return ( + + ); + })} +
+ + + {error &&
{error}
} {loading && } {!loading && !error && visible.length === 0 && ( -

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

+

所有指标都已隐藏,点上面的「显示指标」重新打开。

)} {!loading && !error && visible.length > 0 && ( diff --git a/client/src/routes.ts b/client/src/routes.ts index 62e5773..7e79b92 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -4,6 +4,9 @@ import TodayPage from './pages/TodayPage'; import HealthPage from './pages/HealthPage'; import DailyPage from './pages/DailyPage'; import TrendsPage from './pages/TrendsPage'; +import MetricDetailPage from './pages/MetricDetailPage'; +import ActivityDetailPage from './pages/ActivityDetailPage'; +import BodyAgePage from './pages/BodyAgePage'; import ExercisePage from './pages/ExercisePage'; import SleepPage from './pages/SleepPage'; import SyncPage from './pages/SyncPage'; @@ -26,6 +29,9 @@ const routes: Router.RouteParameters[] = [ { path: '/trends/', component: TrendsPage }, { path: '/exercise/', component: ExercisePage }, { path: '/sleep/', component: SleepPage }, + { path: '/metric/:id/', component: MetricDetailPage }, + { path: '/activity/:id/', component: ActivityDetailPage }, + { path: '/body-age/', component: BodyAgePage }, { path: '/sync/', component: SyncPage }, { path: '/settings/', component: SettingsPage }, { path: '/login/', component: LoginPage }, diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 3427291..a1500e5 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -157,6 +157,98 @@ export interface ModelInfo { default: boolean; } +export interface UserSettings { + heightCm: number | null; + weightKg: number | null; + birthDate: string | null; + sex: 'male' | 'female' | 'other' | null; + units: 'metric' | 'imperial'; + autoSync: boolean; + autoSyncMinutes: number; + /** 0 means "everything Garmin has". */ + historyDays: number; + age: number | null; + bmi: number | null; +} + +export interface SettingsOptions { + sexes: string[]; + units: string[]; + autoSyncMinutes: number[]; + historyDays: number[]; +} + +export interface BasisStep { + name: string; + detail: string; + source: string; +} + +export interface RatingBasis { + fitnessAge: { + title: string; + summary: string; + steps: BasisStep[]; + caveat: string; + }; + bands: Array<{ metric: string; bands: string; source: string }>; + note: string; +} + +export interface FitnessAge { + value: number | null; + chronologicalAge?: number; + delta?: number; + clamped?: boolean; + steps?: Array<{ label: string; input: string; years: number; kind: string }>; + missing: string[]; + basis: RatingBasis['fitnessAge']; +} + +export interface AutoSyncStatus { + enabled: boolean; + intervalSeconds: number; + tickSeconds: number; + days: number; + lastRunAt: string | null; + nextRunAt: string | null; + running: boolean; + account?: { + autoSync: boolean; + intervalMinutes: number; + dueAt: string | null; + }; +} + +/** One activity in full. Shapes mirror Garmin's own payload, which is why the + * summary is left loosely typed — it carries dozens of optional fields that + * differ by sport. */ +export interface ActivityDetail { + activityId: string; + activityName: string | null; + activityType: string | null; + summary: Record; + laps: Array<{ + index: number; + duration: number | null; + movingDuration: number | null; + distance: number | null; + averageSpeed: number | null; + maxSpeed: number | null; + calories: number | null; + averageHR: number | null; + maxHR: number | null; + elevationGain: number | null; + elevationLoss: number | null; + }>; + hrZones: Array<{ zone: number; seconds: number; lowBoundary: number | null }>; + weather: Record; + gear: Array>; + exerciseSets: Array>; + series: Record>; + cached: boolean; +} + export interface TrendPoint { date: string; value: number; @@ -349,6 +441,53 @@ class ApiClient { return data; } + // --- settings --- + async getSettings() { + const { data } = await this.client.get('/settings'); + return data; + } + + async saveSettings(patch: Partial) { + const { data } = await this.client.put('/settings', patch); + return data; + } + + async getSettingsOptions() { + const { data } = await this.client.get('/settings/options'); + return data; + } + + async getRatingBasis() { + const { data } = await this.client.get('/settings/rating-basis'); + return data; + } + + async getFitnessAge() { + const { data } = await this.client.get('/health/fitness-age'); + return data; + } + + async getAutoSyncStatus() { + const { data } = await this.client.get('/garmin/auto-sync'); + return data; + } + + /** Pull the last few days inline — fast enough to await, unlike a backfill. */ + async syncLatest(days = 2) { + const { data } = await this.client.post( + '/garmin/sync-latest', { days } + ); + return data; + } + + async getActivityDetail(activityId: string, refresh = false) { + const { data } = await this.client.get( + `/garmin/activities/${activityId}/detail`, + { params: refresh ? { refresh: 1 } : {}, timeout: 60000 } + ); + return data; + } + async getBadges() { const { data } = await this.client.get('/health/badges'); return data; diff --git a/client/src/theme.css b/client/src/theme.css index aab90b9..7fe2998 100644 --- a/client/src/theme.css +++ b/client/src/theme.css @@ -32,6 +32,14 @@ --series-5: #e87ba4; /* magenta */ --series-6: #008300; /* green */ + /* Sequential ramp, one hue light->dark. Heart-rate zones are an ordered + scale, not categories, so they must not take categorical hues. */ + --seq-1: #c2d9f7; + --seq-2: #8ab6ec; + --seq-3: #5090de; + --seq-4: #2a6fc4; + --seq-5: #17497f; + /* status — reserved, never reused as a series */ --status-good: #0ca30c; --status-warning: #fab219; @@ -78,6 +86,11 @@ --text-muted: #78818f; --series-1: #3987e5; + --seq-1: #1d3f6b; + --seq-2: #2a5f9e; + --seq-3: #3987e5; + --seq-4: #6fa9ee; + --seq-5: #a8c9f6; --series-2: #d95926; --series-3: #199e70; --series-4: #c98500; @@ -112,6 +125,11 @@ --text-muted: #78818f; --series-1: #3987e5; + --seq-1: #1d3f6b; + --seq-2: #2a5f9e; + --seq-3: #3987e5; + --seq-4: #6fa9ee; + --seq-5: #a8c9f6; --series-2: #d95926; --series-3: #199e70; --series-4: #c98500; diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 93d2a3d..466b8a3 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -39,8 +39,9 @@ | 3.2 | Tab:今日 / 健康 / 趋势 / 运动 / 设置 | 每日是趋势的子页 | ✅ | | 3.3 | 首页改三圆环:步数 / 睡眠 / HRV | 并列三环,满环=进入参考区间 | ✅ | | 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ | -| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的 | 📋 | -| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走 | 📋 | +| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 | +| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的,已移除 | ✅ | +| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走;每日入口同样移进趋势页内容 | ✅ | ## 四、数据展示 @@ -49,12 +50,12 @@ | 4.1 | 每日详情模块,看某天所有数据 | 每日页 | ✅ | | 4.2 | 趋势模块,7 天 / 月 / 季 / 年 周期 | 按周期取日均,桶长不同也可比 | ✅ | | 4.3 | 趋势覆盖全部 15 组指标,可自选显示/隐藏 | 指标选择器 | ✅ | -| 4.4 | 指标选择器太丑,改成选择弹窗 | 用 F7 Popup/Sheet 承载 | 📋 | -| 4.5 | 所有卡片可点击进入详情 | 每个指标一个详情页:历史曲线 + 参考区间 + 说明 | 📋 | +| 4.4 | 指标选择器太丑,改成选择弹窗 | 改为 F7 Popup,页面上只留一行「显示指标 n/15」 | ✅ | +| 4.5 | 所有卡片可点击进入详情 | `/metric/:id/`:大数值 + 参考区间 + 趋势图 + 统计 + 依据;新增 `lib/metrics.ts` 统一指标注册表 | ✅ 健康页完成,🚧 今日页 | | 4.6 | 今日页左右箭头切换前一天/后一天 | | 📋 | | 4.7 | 今日页顶部日期选择控件,可看历史任一天 | | 📋 | -| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表:配速、速度、计时、心率、训练效果、营养补水、温度、强度分钟、海拔、心率区间、分段表、采样曲线 | ✅ 接口完成,🚧 界面 | -| 4.9 | 健康页增加身体年龄 | 本地按公开常模推算,展示推算过程 | ✅ 接口完成,🚧 界面 | +| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表四个 Tab,含心率区间条与时间/距离横轴切换 | ✅ | +| 4.9 | 健康页增加身体年龄 | 健康页卡片 + `/body-age/` 展示每一步推算过程与出处 | ✅ | ## 五、设置