diff --git a/client/src/App.tsx b/client/src/App.tsx index ffa7b10..9506461 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,9 +18,9 @@ Framework7.use(Framework7React); const TABS = [ { id: 'today', path: '/', label: '今日', icon: 'square_grid_2x2' }, { id: 'health', path: '/health/', label: '健康', icon: 'heart' }, - { id: 'daily', path: '/daily/', label: '每日', icon: 'calendar' }, { id: 'trends', path: '/trends/', label: '趋势', icon: 'chart_bar_alt_fill' }, - { id: 'awards', path: '/achievements/', label: '成就', icon: 'rosette' }, + { id: 'exercise', path: '/exercise/', label: '运动', icon: 'flame' }, + { id: 'settings', path: '/settings/', label: '设置', icon: 'gear_alt' }, ]; /** @@ -66,7 +66,6 @@ function App() { theme="ios" darkMode="auto" routes={routes} - view={{ browserHistory: true, browserHistorySeparator: '' }} touch={{ tapHold: true }} > @@ -94,6 +93,11 @@ function App() { tab tabActive={tab.id === 'today'} url={tab.path} + // Only the main view drives the address bar. With it enabled on + // every tab each one reads the browser URL instead of its own + // `url`, so all five loaded the root page. + browserHistory={tab.id === 'today'} + browserHistorySeparator="" /> ))} diff --git a/client/src/components/Screen.tsx b/client/src/components/Screen.tsx index 95a0f27..3303042 100644 --- a/client/src/components/Screen.tsx +++ b/client/src/components/Screen.tsx @@ -1,5 +1,5 @@ import { ReactNode, useEffect, useState } from 'react'; -import { Page, Navbar, NavRight, Link, f7 } from 'framework7-react'; +import { Page, Navbar, NavRight, f7 } from 'framework7-react'; import { apiClient } from '../services/api'; import './Screen.css'; @@ -46,13 +46,9 @@ function Screen({ - {right ?? ( - <> - - - - - )} + {/* Only what this screen actually offers — a fixed set of icons on + every page turns the navbar into decoration. */} + {right} diff --git a/client/src/components/charts/Chart.css b/client/src/components/charts/Chart.css index 3f49360..8c7a59d 100644 --- a/client/src/components/charts/Chart.css +++ b/client/src/components/charts/Chart.css @@ -202,3 +202,21 @@ @media (prefers-reduced-motion: reduce) { .viz { animation: none; transition: none; } } + +/* The header must not let a short title collapse into vertical characters + when the toggle button claims the row's width. */ +.viz-head h4 { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.viz-head > div { + min-width: 0; + flex: 1; +} + +.viz-toggle { + flex-shrink: 0; +} diff --git a/client/src/pages/AchievementsPage.tsx b/client/src/pages/AchievementsPage.tsx deleted file mode 100644 index 2774c0f..0000000 --- a/client/src/pages/AchievementsPage.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - apiClient, Activity, Badge, errorMessage, PersonalRecord, -} from '../services/api'; -import StatTile from '../components/charts/StatTile'; -import Skeleton from '../components/Skeleton'; -import Screen from '../components/Screen'; - -type Tab = 'badges' | 'records' | 'activities'; - -const ACTIVITY_LABEL: Record = { - running: '跑步', - cycling: '骑行', - walking: '步行', - hiking: '徒步', - swimming: '游泳', - table_tennis: '乒乓球', - strength_training: '力量训练', - indoor_cycling: '室内骑行', - treadmill_running: '跑步机', - fitness_equipment: '健身器械', -}; - -const label = (key: string | null) => - key ? ACTIVITY_LABEL[key] ?? key.replace(/_/g, ' ') : '—'; - -const date = (value: string | null) => (value ? value.slice(0, 10) : '—'); - -function AchievementsPage() { - const [tab, setTab] = useState('badges'); - const [badges, setBadges] = useState([]); - const [records, setRecords] = useState([]); - const [activities, setActivities] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - useEffect(() => { - const load = async () => { - try { - const [b, r, a] = await Promise.all([ - apiClient.getBadges(), - apiClient.getPersonalRecords(), - apiClient.getActivities(), - ]); - setBadges(b); - setRecords(r); - setActivities(a); - } catch (err: any) { - setError(errorMessage(err, '加载失败')); - } finally { - setLoading(false); - } - }; - load(); - }, []); - - if (loading) { - return ( - -

成就

- -
- ); - } - - // Badges cluster heavily by year, which is the only grouping that reads. - const byYear = badges.reduce>((acc, b) => { - const year = b.earned_date ? b.earned_date.slice(0, 4) : '未知'; - (acc[year] ||= []).push(b); - return acc; - }, {}); - const years = Object.keys(byYear).sort().reverse(); - - const totalPoints = badges.reduce((sum, b) => sum + (b.points ?? 0), 0); - - return ( - - - {error &&
{error}
} - -
- - - - -
- -
- {([ - ['badges', `奖励 (${badges.length})`], - ['records', `个人纪录 (${records.length})`], - ['activities', `运动 (${activities.length})`], - ] as Array<[Tab, string]>).map(([id, text]) => ( - - ))} -
- - {tab === 'badges' && ( - badges.length === 0 ? ( -

还没有同步到徽章。

- ) : ( - years.map((year) => ( -
-

- {year === '未知' ? '未知年份' : `${year} 年`} - {byYear[year].length} 个 -

-
- {byYear[year].map((b) => ( -
-
{b.name || b.badge_key}
-
- {date(b.earned_date)} - {b.earned_count && b.earned_count > 1 && ( - ×{b.earned_count} - )} -
-
- ))} -
-
- )) - ) - )} - - {tab === 'records' && ( - records.length === 0 ? ( -

还没有同步到个人纪录。

- ) : ( -
- - - - - - - - - - - {records.map((r) => ( - - - - - - - ))} - -
活动类型数值日期
{r.activity_name || '—'}{label(r.activity_type)} - {r.value != null ? r.value.toLocaleString(undefined, { - maximumFractionDigits: 2, - }) : '—'} - {date(r.achieved_at)}
-
- ) - )} - - {tab === 'activities' && ( - activities.length === 0 ? ( -

所选区间内没有运动记录。

- ) : ( -
- - - - - - - - - - - - - {activities.map((a) => ( - - - - - - - - - ))} - -
时间类型时长距离消耗平均心率
{a.start_time?.slice(0, 16).replace('T', ' ')}{label(a.activity_type)} - {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 ?? '—'}
-
- ) - )} -
- ); -} - -export default AchievementsPage; diff --git a/client/src/pages/DailyPage.tsx b/client/src/pages/DailyPage.tsx index 0a4c4e7..80e73f9 100644 --- a/client/src/pages/DailyPage.tsx +++ b/client/src/pages/DailyPage.tsx @@ -165,7 +165,7 @@ function DailyPage() { }; return ( - +
diff --git a/client/src/pages/Exercise.css b/client/src/pages/Exercise.css new file mode 100644 index 0000000..63b49fa --- /dev/null +++ b/client/src/pages/Exercise.css @@ -0,0 +1,94 @@ +/* Sport split ------------------------------------------------------------- */ +.sport-list { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.sport { + display: flex; + align-items: center; + gap: 0.7rem; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.7rem 0.85rem; +} + +.sport-icon { font-size: 1.25rem; line-height: 1; flex-shrink: 0; } +.sport-body { flex: 1; min-width: 0; } + +.sport-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.35rem; +} + +.sport-name { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); } +.sport-stat { font-size: 0.74rem; color: var(--text-muted); white-space: nowrap; } + +.sport-bar { + height: 5px; + background: var(--surface-0); + border-radius: 999px; + overflow: hidden; +} + +/* One hue, length carries the share — a different colour per sport would + imply categories that mean something beyond "which row is this". */ +.sport-fill { + height: 100%; + background: var(--accent); + border-radius: 999px; + transition: width 0.8s var(--ease); +} + +/* Activity list ----------------------------------------------------------- */ +.act-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.act { + display: flex; + align-items: center; + gap: 0.7rem; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.7rem 0.85rem; + transition: border-color 0.15s var(--ease), transform 0.15s var(--ease); +} + +.act:active { transform: scale(0.99); } + +.act-icon { font-size: 1.2rem; line-height: 1; flex-shrink: 0; } +.act-body { flex: 1; min-width: 0; } +.act-name { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); } +.act-meta { font-size: 0.73rem; color: var(--text-muted); margin-top: 0.15rem; } + +.act-stats { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.1rem; + font-size: 0.76rem; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.act-stats span:first-child { + color: var(--text-primary); + font-weight: 600; + font-size: 0.85rem; +} + +@media (prefers-reduced-motion: reduce) { + .sport-fill { transition: none; } + .act { transition: none; } + .act:active { transform: none; } +} diff --git a/client/src/pages/ExercisePage.tsx b/client/src/pages/ExercisePage.tsx new file mode 100644 index 0000000..4a721f0 --- /dev/null +++ b/client/src/pages/ExercisePage.tsx @@ -0,0 +1,286 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'framework7-react'; +import { + apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord, +} from '../services/api'; +import Screen from '../components/Screen'; +import MetricCard from '../components/charts/MetricCard'; +import Chart from '../components/charts/Chart'; +import Skeleton from '../components/Skeleton'; +import './Exercise.css'; + +const WINDOW_DAYS = 30; +type Tab = 'activities' | 'records' | 'badges'; + +const ACTIVITY_LABEL: Record = { + running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步', + swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练', + indoor_cycling: '室内骑行', treadmill_running: '跑步机', + fitness_equipment: '健身器械', badminton: '羽毛球', yoga: '瑜伽', + mountaineering: '登山', open_water_swimming: '公开水域游泳', + elliptical: '椭圆机', rowing: '划船', stair_climbing: '爬楼梯', +}; + +const ACTIVITY_ICON: Record = { + running: '🏃', cycling: '🚴', walking: '🚶', hiking: '🥾', + swimming: '🏊', table_tennis: '🏓', strength_training: '🏋️', + indoor_cycling: '🚴', treadmill_running: '🏃', badminton: '🏸', yoga: '🧘', +}; + +const label = (k: string | null) => { + if (!k) return '—'; + const known = ACTIVITY_LABEL[k]; + if (known) return known; + // Unmapped types come through as Garmin's raw key; make it readable rather + // than leaking snake_case into the UI. + return k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +}; +const icon = (k: string | null) => (k ? ACTIVITY_ICON[k] ?? '🏅' : '🏅'); +const day = (v: string | null) => (v ? v.slice(0, 10) : '—'); + +function ExercisePage() { + const [tab, setTab] = useState('activities'); + const [activities, setActivities] = useState([]); + const [records, setRecords] = useState([]); + const [badges, setBadges] = useState([]); + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const load = async () => { + try { + const end = new Date(); + const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000); + const [a, r, b, d] = await Promise.all([ + apiClient.getActivities(), + apiClient.getPersonalRecords(), + apiClient.getBadges(), + apiClient.getHealthSummary( + start.toISOString().slice(0, 10), end.toISOString().slice(0, 10) + ), + ]); + setActivities(a); + setRecords(r); + setBadges(b); + setDays(d); + } catch (err: any) { + setError(errorMessage(err, '加载失败')); + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const recent = useMemo(() => { + const cutoff = new Date(Date.now() - WINDOW_DAYS * 86400000) + .toISOString().slice(0, 10); + return activities.filter((a) => (a.start_time ?? '') >= cutoff); + }, [activities]); + + const totalMinutes = Math.round( + recent.reduce((sum, a) => sum + (a.duration ?? 0), 0) / 60 + ); + const totalDistance = + recent.reduce((sum, a) => sum + (a.distance ?? 0), 0) / 1000; + const totalCalories = Math.round( + recent.reduce((sum, a) => sum + (a.calories ?? 0), 0) + ); + + /* Which sports, and how much of each — the split is the useful reading, not + a bare session count. */ + const byType = useMemo(() => { + const map = new Map(); + for (const a of recent) { + const key = a.activity_type ?? 'unknown'; + const cur = map.get(key) ?? { minutes: 0, count: 0 }; + cur.minutes += Math.round((a.duration ?? 0) / 60); + cur.count += 1; + map.set(key, cur); + } + return [...map.entries()].sort((a, b) => b[1].minutes - a[1].minutes); + }, [recent]); + + const intensityRows = days.map((d) => ({ + date: d.date.slice(5), + intensityMinutes: d.intensityMinutes, + })); + + const badgesByYear = useMemo(() => { + const map = badges.reduce>((acc, b) => { + const y = b.earned_date ? b.earned_date.slice(0, 4) : '未知'; + (acc[y] ||= []).push(b); + return acc; + }, {}); + return Object.keys(map).sort().reverse().map((y) => [y, map[y]] as const); + }, [badges]); + + if (loading) { + return ( + + + + ); + } + + return ( + + {error &&
{error}
} + +
+
+ + + + d.intensityMinutes)} /> +
+
+ + {byType.length > 0 && ( +
+

项目分布

+
+ {byType.map(([type, s]) => { + const share = totalMinutes ? (s.minutes / totalMinutes) * 100 : 0; + return ( +
+ +
+
+ {label(type)} + {s.minutes} 分钟 · {s.count} 次 +
+
+
+
+
+
+ ); + })} +
+
+ )} + +
+ +
+ +
+ {([ + ['activities', `记录 (${activities.length})`], + ['records', `个人纪录 (${records.length})`], + ['badges', `奖励 (${badges.length})`], + ] as Array<[Tab, string]>).map(([id, text]) => ( + + ))} +
+ + {tab === 'activities' && ( + activities.length === 0 ? ( +
+

还没有运动记录。

+ 去同步 +
+ ) : ( +
+ {activities.map((a) => ( +
+ +
+
{label(a.activity_type)}
+
+ {a.start_time?.slice(0, 16).replace('T', ' ')} +
+
+
+ {a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'} + {a.distance ? {(a.distance / 1000).toFixed(2)} km : null} + {a.calories != null ? {Math.round(a.calories)} kcal : null} +
+
+ ))} +
+ ) + )} + + {tab === 'records' && ( + records.length === 0 ? ( +

还没有同步到个人纪录。

+ ) : ( +
+ + + + + + + + + {records.map((r) => ( + + + + + + + ))} + +
活动类型数值日期
{r.activity_name || '—'}{label(r.activity_type)} + {r.value != null + ? r.value.toLocaleString(undefined, { maximumFractionDigits: 2 }) + : '—'} + {day(r.achieved_at)}
+
+ ) + )} + + {tab === 'badges' && ( + badges.length === 0 ? ( +

还没有同步到徽章。

+ ) : ( + badgesByYear.map(([year, list]) => ( +
+

+ {year === '未知' ? '未知年份' : `${year} 年`} + {list.length} 个 +

+
+ {list.map((b) => ( +
+
{b.name || b.badge_key}
+
+ {day(b.earned_date)} + {b.earned_count && b.earned_count > 1 && ( + ×{b.earned_count} + )} +
+
+ ))} +
+
+ )) + ) + )} +
+ ); +} + +export default ExercisePage; diff --git a/client/src/pages/HealthPage.tsx b/client/src/pages/HealthPage.tsx index 886f35a..c04d110 100644 --- a/client/src/pages/HealthPage.tsx +++ b/client/src/pages/HealthPage.tsx @@ -114,7 +114,7 @@ function HealthPage() { if (loading) { return ( - + }>

健康

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

健康

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

健康

还没有任何健康数据。

@@ -153,7 +153,7 @@ function HealthPage() { }; return ( - + }> {SECTIONS.map((section) => (
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 3cd91a9..a348218 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -1,12 +1,11 @@ import Screen from '../components/Screen'; import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link, f7 } from 'framework7-react'; import { apiClient, ModelInfo } from '../services/api'; import { FEATURES } from '../features'; import './Settings.css'; function SettingsPage() { - const navigate = useNavigate(); const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); @@ -24,7 +23,7 @@ function SettingsPage() { const handleLogout = async () => { await apiClient.logout(); - navigate('/login'); + f7.views.current.router.navigate('/login/', { reloadAll: true }); }; return ( @@ -76,6 +75,14 @@ function SettingsPage() {
)} +
+

数据

+

从 Garmin Connect 拉取健康数据,或重新绑定账号。

+ + 前往数据同步 + +
+

数据与隐私

    diff --git a/client/src/pages/SleepPage.tsx b/client/src/pages/SleepPage.tsx index 2362c03..e4146c5 100644 --- a/client/src/pages/SleepPage.tsx +++ b/client/src/pages/SleepPage.tsx @@ -76,7 +76,7 @@ function SleepPage() { const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d); return ( - +
    范围 diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx index 0bbfcca..dd7fa48 100644 --- a/client/src/pages/SyncPage.tsx +++ b/client/src/pages/SyncPage.tsx @@ -188,7 +188,7 @@ function SyncPage() { const pct = total > 0 ? Math.round((current / total) * 100) : 0; return ( - +

    数据同步

    从 Garmin Connect 拉取最近 7 天的健康数据

    diff --git a/client/src/pages/TrendsPage.tsx b/client/src/pages/TrendsPage.tsx index 71c593b..4a810d4 100644 --- a/client/src/pages/TrendsPage.tsx +++ b/client/src/pages/TrendsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'framework7-react'; import { apiClient, errorMessage, HealthDay } from '../services/api'; import Chart, { Series } from '../components/charts/Chart'; import Skeleton from '../components/Skeleton'; @@ -220,7 +221,7 @@ function TrendsPage() { GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? ''; return ( - + }>
    范围 diff --git a/client/src/routes.ts b/client/src/routes.ts index 829c36d..62e5773 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -4,7 +4,7 @@ import TodayPage from './pages/TodayPage'; import HealthPage from './pages/HealthPage'; import DailyPage from './pages/DailyPage'; import TrendsPage from './pages/TrendsPage'; -import AchievementsPage from './pages/AchievementsPage'; +import ExercisePage from './pages/ExercisePage'; import SleepPage from './pages/SleepPage'; import SyncPage from './pages/SyncPage'; import SettingsPage from './pages/SettingsPage'; @@ -12,16 +12,19 @@ import LoginPage from './pages/LoginPage'; import NotFoundPage from './pages/NotFoundPage'; /** - * Secondary screens are reachable from more than one tab, so they are - * registered on every view's router rather than pinned to one — pushing 睡眠 - * from 今日 should stay inside 今日's stack. + * Every route is registered on every view's router, so a detail screen opens + * inside whichever tab the user was in: 睡眠 pushed from 今日 stays in 今日's + * stack rather than yanking them over to 健康. + * + * 每日 sits under 趋势, 睡眠 under 健康 and 同步 under 设置 — they are detail + * views of those tabs, not destinations of their own. */ const routes: Router.RouteParameters[] = [ { path: '/', component: TodayPage }, { path: '/health/', component: HealthPage }, { path: '/daily/', component: DailyPage }, { path: '/trends/', component: TrendsPage }, - { path: '/achievements/', component: AchievementsPage }, + { path: '/exercise/', component: ExercisePage }, { path: '/sleep/', component: SleepPage }, { path: '/sync/', component: SyncPage }, { path: '/settings/', component: SettingsPage },