feat(sync): 运动详情改为同步入库,详情页只读本地
按需回源是错的:点一次运动要等七个 Garmin 接口,网络好的时候慢, 网络差的时候直接超时(实测公网下 Network Error)。 - sync_data 顺带补齐缺详情的运动 - POST /api/garmin/sync-details 后台补齐存量,GET 查进度 - 详情页只读本地库;没有就提示去同步,不再回源 - 同步页新增「补齐运动详情」按钮,带进度 身体年龄:加入公开的阻尼系数 - 34 岁 VO₂max 46 原本算出 21 岁。不是算错,是方法本身会饱和: 人与人之间的 VO₂max 标准差约 7,而年龄每年只带来约 0.35 的衰减, 于是稍微能练的人都会撞到参考表最年轻一档。 - 按 50% 向实际年龄收拢,收敛范围 ±20 → ±12 岁,同一算例现在给 27 岁。 - 去掉「高于最年轻一档按 20 岁计」的硬地板,那是一道正好落在用户身上的悬崖。 - 界面同时显示未收拢的原始值,阻尼系数写进评分依据。 路由:为每个路径补无斜杠别名 - F7 写地址栏时去掉尾斜杠,于是 /daily/ 在地址栏是 /daily, 而那个地址匹配不到任何路由,刷新或分享就落到「找不到页面」。 布局:让页面结构上无法被撑宽 - 网格改用 minmax(min(210px,100%),1fr):裸的 minmax(210px,1fr) 允许 两列加起来超过窄屏宽度,第二张卡就被切掉在屏幕外。 - .ring-row 用 minmax(0,1fr),1fr 会以 min-content 兜底,一句长说明就能 把整行顶宽。 - .page-inner 加 overflow-x: clip。 - html/body 用 100dvh:手机浏览器把自己的地址栏盖在布局视口上, 100% 高的应用会把底部 Tab 栏顶到它们下面——对用户来说就是没有 Tab 栏。 测试:新增 122 项(设置 44、身体年龄 44、运动详情 42),全量 446 项通过。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Nothing inside a screen may widen the screen. Wide content (tables, charts)
|
||||
scrolls inside its own container instead; without this a single unbreakable
|
||||
caption drags the whole page sideways and everything else is clipped. */
|
||||
.page-inner { overflow-x: clip; }
|
||||
|
||||
.sec-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -33,10 +38,10 @@
|
||||
window is filled with content instead of whitespace. */
|
||||
@media (min-width: 861px) {
|
||||
.page-inner .mcard-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(232px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(232px, 100%), 1fr));
|
||||
}
|
||||
.page-inner .chart-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,10 @@
|
||||
/* Two columns, the way the phone apps lay these out. */
|
||||
.mcard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
/* min() keeps a track from ever being wider than the container: a bare
|
||||
minmax(210px, 1fr) lets two 210px columns add up past a narrow phone's
|
||||
width, and the second card is then clipped off the right edge. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Mobile browsers overlay their own address and tool bars on top of the
|
||||
layout viewport, so a 100%-tall app puts its bottom tab bar underneath
|
||||
them — the tab bar simply is not there for the user. dvh tracks the
|
||||
visible area as that chrome shows and hides. The 100% above stays as the
|
||||
fallback for browsers without dvh. */
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, ActivityDetail, errorMessage } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import Chart from '../components/charts/Chart';
|
||||
@@ -195,13 +196,23 @@ function ActivityDetailPage({ id, f7route }: Props) {
|
||||
const [axis, setAxis] = useState<Axis>('time');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [needsSync, setNeedsSync] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiClient
|
||||
.getActivityDetail(activityId)
|
||||
.then((d) => { if (!cancelled) setDetail(d); })
|
||||
.catch((err) => { if (!cancelled) setError(errorMessage(err, '加载失败')); })
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
// Not yet synced is an ordinary state with an obvious next step, not
|
||||
// a failure to apologise for.
|
||||
if (err?.response?.status === 404 && err.response.data?.needsSync) {
|
||||
setNeedsSync(true);
|
||||
} else {
|
||||
setError(errorMessage(err, '加载失败'));
|
||||
}
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [activityId]);
|
||||
@@ -238,12 +249,24 @@ function ActivityDetailPage({ id, f7route }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Screen title="运动详情" backLink>
|
||||
<p className="screen-note">正在从 Garmin 获取本次运动的完整数据…</p>
|
||||
<Skeleton count={4} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsSync) {
|
||||
return (
|
||||
<Screen title="运动详情" backLink>
|
||||
<div className="screen-empty">
|
||||
<p>这条运动的详细数据还没同步到本机。</p>
|
||||
<Link href="/sync/" className="button button-fill button-round">
|
||||
去同步
|
||||
</Link>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<Screen title="运动详情" backLink>
|
||||
|
||||
@@ -61,7 +61,12 @@ function BodyAgePage() {
|
||||
<div className="ba-step" key={s.label}>
|
||||
<div className="ba-step-name">
|
||||
{s.label}
|
||||
<span className="ba-step-input">{s.input}</span>
|
||||
<span className="ba-step-input">
|
||||
{s.input}
|
||||
{s.raw != null && s.damping != null && (
|
||||
<> · 未收拢 {s.raw} 岁,按 {Math.round(s.damping * 100)}% 收拢</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ba-step-years">
|
||||
{s.kind === 'base'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
apiClient, AutoSyncStatus, errorMessage, GarminLoginStatus, parseUtc,
|
||||
SyncStatus, UserSettings,
|
||||
apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus,
|
||||
parseUtc, SyncStatus, UserSettings,
|
||||
} from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import './DataSync.css';
|
||||
@@ -25,6 +25,7 @@ function SyncPage() {
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
const [auto, setAuto] = useState<AutoSyncStatus | null>(null);
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [details, setDetails] = useState<DetailSyncStatus | null>(null);
|
||||
const [hasToken, setHasToken] = useState<boolean | null>(null);
|
||||
|
||||
// Garmin login (only needed until a token is stored)
|
||||
@@ -49,12 +50,14 @@ function SyncPage() {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [s, a] = await Promise.all([
|
||||
const [s, a, d] = await Promise.all([
|
||||
apiClient.getGarminSyncStatus(),
|
||||
apiClient.getAutoSyncStatus().catch(() => null),
|
||||
apiClient.getDetailSyncStatus().catch(() => null),
|
||||
]);
|
||||
setSyncStatus(s);
|
||||
if (a) setAuto(a);
|
||||
if (d) setDetails(d);
|
||||
return s;
|
||||
} catch {
|
||||
// A failed status poll should not blank the page.
|
||||
@@ -196,6 +199,34 @@ function SyncPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Pull the per-activity detail for anything stored without it. */
|
||||
const syncDetails = async () => {
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
setDetails(await apiClient.startDetailSync());
|
||||
beginDetailPolling();
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '同步失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const beginDetailPolling = () => {
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const d = await apiClient.getDetailSyncStatus();
|
||||
setDetails(d);
|
||||
if (!d.running) {
|
||||
window.clearInterval(timer);
|
||||
if (d.error) setError(d.error);
|
||||
else if (d.done) setMessage(`已补齐 ${d.done} 条运动的详细数据`);
|
||||
}
|
||||
} catch {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// The backfill runs in the background, so the page follows it by polling
|
||||
// rather than by holding a request open for the whole thing.
|
||||
const beginSyncPolling = () => {
|
||||
@@ -331,6 +362,15 @@ function SyncPage() {
|
||||
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
|
||||
同步历史({historyLabel(settings?.historyDays ?? 365)})
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-plain"
|
||||
onClick={syncDetails}
|
||||
disabled={busy || !!details?.running}
|
||||
>
|
||||
{details?.running
|
||||
? `正在补运动详情 ${details.done}/${details.total || '…'}`
|
||||
: '补齐运动详情'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
.ring-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
/* minmax(0, …) rather than 1fr: a plain 1fr floors at the track's
|
||||
min-content, so one long caption widens the whole row past the screen. */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -48,6 +50,8 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ring-cell > * { max-width: 100%; }
|
||||
|
||||
.ring-goal {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
@@ -98,7 +102,7 @@
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(330px, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import NotFoundPage from './pages/NotFoundPage';
|
||||
* 每日 sits under 趋势, 睡眠 under 健康 and 同步 under 设置 — they are detail
|
||||
* views of those tabs, not destinations of their own.
|
||||
*/
|
||||
const routes: Router.RouteParameters[] = [
|
||||
const SCREENS: Router.RouteParameters[] = [
|
||||
{ path: '/', component: TodayPage },
|
||||
{ path: '/health/', component: HealthPage },
|
||||
{ path: '/daily/', component: DailyPage },
|
||||
@@ -37,6 +37,22 @@ const routes: Router.RouteParameters[] = [
|
||||
{ path: '/sync/', component: SyncPage },
|
||||
{ path: '/settings/', component: SettingsPage },
|
||||
{ path: '/login/', component: LoginPage },
|
||||
];
|
||||
|
||||
/**
|
||||
* Both spellings of every path.
|
||||
*
|
||||
* Framework7 writes the address bar without the trailing slash, so a screen
|
||||
* registered only as `/daily/` becomes `…:8123/daily` in the URL — and that
|
||||
* address matches nothing, so reloading or sharing it lands on 找不到页面.
|
||||
* Registering the slashless twin makes the two spellings the same screen.
|
||||
*/
|
||||
const routes: Router.RouteParameters[] = [
|
||||
...SCREENS.flatMap((route) => {
|
||||
const path = route.path as string;
|
||||
if (path === '/' || !path.endsWith('/')) return [route];
|
||||
return [route, { ...route, path: path.slice(0, -1) }];
|
||||
}),
|
||||
{ path: '(.*)', component: NotFoundPage },
|
||||
];
|
||||
|
||||
|
||||
@@ -200,11 +200,22 @@ export interface FitnessAge {
|
||||
chronologicalAge?: number;
|
||||
delta?: number;
|
||||
clamped?: boolean;
|
||||
steps?: Array<{ label: string; input: string; years: number; kind: string }>;
|
||||
steps?: Array<{
|
||||
label: string; input: string; years: number; kind: string;
|
||||
/** Base step only: the undamped figure, and the factor applied to it. */
|
||||
raw?: number; damping?: number;
|
||||
}>;
|
||||
missing: string[];
|
||||
basis: RatingBasis['fitnessAge'];
|
||||
}
|
||||
|
||||
export interface DetailSyncStatus {
|
||||
running: boolean;
|
||||
done: number;
|
||||
total: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface AutoSyncStatus {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
@@ -480,16 +491,26 @@ class ApiClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
async getActivityDetail(activityId: string, refresh = false) {
|
||||
/** A local read: details are stored during the sync, not fetched on tap. */
|
||||
async getActivityDetail(activityId: string) {
|
||||
const { data } = await this.client.get<ActivityDetail>(
|
||||
`/garmin/activities/${activityId}/detail`,
|
||||
// The first open goes out to Garmin for seven endpoints; only the
|
||||
// cached reads afterwards are fast.
|
||||
{ params: refresh ? { refresh: 1 } : {}, timeout: 120000 }
|
||||
`/garmin/activities/${activityId}/detail`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async startDetailSync(limit?: number) {
|
||||
const { data } = await this.client.post<DetailSyncStatus>(
|
||||
'/garmin/sync-details', limit ? { limit } : {}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getDetailSyncStatus() {
|
||||
const { data } = await this.client.get<DetailSyncStatus>('/garmin/sync-details');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getBadges() {
|
||||
const { data } = await this.client.get<Badge[]>('/health/badges');
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user