diff --git a/backend/services/garmin.py b/backend/services/garmin.py index 69eb929..e97f20e 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -123,12 +123,44 @@ def save_token(user_id, token, garmin_email=None): f"ON CONFLICT(user_id) DO UPDATE SET {updates}") execute(sql, [user_id, token, garmin_email, datetime.datetime.utcnow().isoformat(timespec="seconds")]) + # A re-bind means the old session is stale; the next call must build a + # fresh one rather than keep using the session the old token minted. + forget_client(user_id) def has_token(user_id): return load_token(user_id) is not None +# An authenticated client, reused across requests in this process. +# +# Building one costs ~11s against Garmin — loading the token, refreshing the +# OAuth2 grant and fetching the profile — which dwarfed the ~4s of actual data +# fetching behind an activity-detail request. The session is a requests.Session +# underneath, so it is reusable; it is dropped after CLIENT_TTL so a refreshed +# or revoked token is picked up rather than being cached indefinitely. +CLIENT_TTL_SECONDS = 900 +_clients = {} +_clients_lock = threading.Lock() + + +def _cached_client(user_id): + entry = _clients.get(user_id) + if entry and (datetime.datetime.utcnow() - entry[1]).total_seconds() < CLIENT_TTL_SECONDS: + return entry[0] + return None + + +def _cache_client(user_id, client): + if user_id: + _clients[user_id] = (client, datetime.datetime.utcnow()) + + +def forget_client(user_id): + """Drop the cached session — call after re-binding an account.""" + _clients.pop(user_id, None) + + def _connect(creds, user_id=None): """Obtain a logged-in Garmin client. @@ -138,6 +170,12 @@ def _connect(creds, user_id=None): "EOFError: EOF when reading a line"). Tokens are minted once by `garmin_login.py`, which runs in a terminal where a code can be typed. """ + if user_id: + with _clients_lock: + cached = _cached_client(user_id) + if cached is not None: + return cached + Garmin = _import_garmin() client = Garmin(is_cn=_is_cn()) @@ -150,6 +188,8 @@ def _connect(creds, user_id=None): # garminconnect builds most of its URLs from display_name, so leaving # it unset sends every request to ".../None". client.display_name = client.garth.profile["displayName"] + with _clients_lock: + _cache_client(user_id, client) return client if not creds.get("garminPassword"): @@ -167,6 +207,8 @@ def _connect(creds, user_id=None): "系统会提示你输入验证码。" ) from e _use_api_user_agent(client) + with _clients_lock: + _cache_client(user_id, client) return client @@ -519,8 +561,10 @@ def _build_detail(client, activity_id): rest of the page intact rather than fail the request. """ summary = _safe(lambda: client.get_activity_evaluation(activity_id), {}) or {} + # 500 is already more samples than the 300 we keep, and asking for 2000 + # triples the payload for points that get thinned away anyway. details = _safe( - lambda: client.get_activity_details(activity_id, maxchart=2000, maxpoly=0), {} + lambda: client.get_activity_details(activity_id, maxchart=500, maxpoly=0), {} ) or {} return { diff --git a/client/.env.production b/client/.env.production new file mode 100644 index 0000000..26f06ca --- /dev/null +++ b/client/.env.production @@ -0,0 +1,5 @@ +# In production the Flask app serves the built client from its own origin, so +# the API is a same-origin path. Without this the build bakes in the +# development default (localhost:5000) and every request fails with +# "Network Error" on any machine that is not the developer's. +REACT_APP_API_URL=/api diff --git a/client/src/components/Screen.css b/client/src/components/Screen.css index 911cdea..8621071 100644 --- a/client/src/components/Screen.css +++ b/client/src/components/Screen.css @@ -398,6 +398,13 @@ } .metric-tab { + /* Framework7 styles every - 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/DailyPage.tsx b/client/src/pages/DailyPage.tsx index 80e73f9..3ed4feb 100644 --- a/client/src/pages/DailyPage.tsx +++ b/client/src/pages/DailyPage.tsx @@ -3,6 +3,7 @@ import { apiClient, Activity, errorMessage, HealthDay } from '../services/api'; import Skeleton from '../components/Skeleton'; import './Daily.css'; import Screen from '../components/Screen'; +import { iso, today as todayIso } from '../lib/day'; /** Every stored metric, grouped the way the device groups them. */ interface Field { @@ -109,10 +110,8 @@ function valueOf(day: HealthDay, key: Field['key']): number | null { return typeof v === 'number' ? v : null; } -const iso = (d: Date) => d.toISOString().slice(0, 10); - function DailyPage() { - const [date, setDate] = useState(() => iso(new Date())); + const [date, setDate] = useState(todayIso); const [day, setDay] = useState(null); const [activities, setActivities] = useState([]); const [loading, setLoading] = useState(true); diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx deleted file mode 100644 index 6176d75..0000000 --- a/client/src/pages/Dashboard.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { apiClient, errorMessage, HealthDay } from '../services/api'; -import Chart from '../components/charts/Chart'; -import MetricCard from '../components/charts/MetricCard'; -import MetricStrip from '../components/charts/MetricStrip'; -import Ring from '../components/charts/Ring'; -import Skeleton from '../components/Skeleton'; -import { useCountUp } from '../lib/motion'; -import './Dashboard.css'; -import './Pages.css'; - -const DAYS = 30; - -/** MM-DD keeps the axis readable at 30 points. */ -const short = (iso: string) => iso.slice(5); - -function avg(values: Array): number | null { - const present = values.filter((v): v is number => v != null); - if (!present.length) return null; - return present.reduce((a, b) => a + b, 0) / present.length; -} - -/** The one hero figure on this view: today's steps against the day's goal. */ -function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) { - const goal = today.stepGoal ?? null; - const steps = today.steps ?? null; - const progress = steps != null && goal ? steps / goal : null; - const animated = useCountUp(steps); - - const week = history.slice(-7).map((d) => d.steps).filter((v): v is number => v != null); - const weekAvg = week.length - ? Math.round(week.reduce((a, b) => a + b, 0) / week.length) - : null; - - const remaining = steps != null && goal ? goal - steps : null; - - return ( -
- - - {steps == null ? '—' : Math.round(animated ?? steps).toLocaleString()} - - - - -
-
- {progress == null - ? '今日暂无步数记录' - : progress >= 1 - ? '今日目标已完成' - : `距目标还差 ${remaining!.toLocaleString()} 步`} -
-
-
-
目标
-
{goal ? goal.toLocaleString() : '—'}
-
-
-
近 7 日均
-
{weekAvg ? weekAvg.toLocaleString() : '—'}
-
-
-
完成度
-
{progress != null ? `${Math.round(progress * 100)}%` : '—'}
-
-
-
-
- ); -} - -function Dashboard() { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [days, setDays] = useState([]); - - useEffect(() => { - const load = async () => { - try { - const end = new Date(); - const start = new Date(end.getTime() - (DAYS - 1) * 86400000); - setDays( - await apiClient.getHealthSummary( - start.toISOString().slice(0, 10), - end.toISOString().slice(0, 10) - ) - ); - } catch (err: any) { - setError(errorMessage(err, '加载数据失败')); - } finally { - setLoading(false); - } - }; - load(); - }, []); - - if (loading) { - return ( -
-

今日概览

- - ); - } - - if (error) { - return ( -
-

今日概览

-
{error}
-
- ); - } - - if (days.length === 0) { - return ( -
-

今日概览

-
-

还没有任何健康数据。

- 去同步 Garmin 数据 -
-
- ); - } - - const today = days[days.length - 1]; - const rows = days.map((d) => ({ ...d, date: short(d.date) })); - - const avgSteps = avg(days.map((d) => d.steps)); - - return ( -
-
-
-

今日概览

-

{today.date} · 近 {days.length} 天数据

-
- 查看全部趋势 → -
- - - - {/* Activity ---------------------------------------------------------- */} -
-

活动

-
- d.steps)} - /> - d.intensityMinutes)} - /> - d.floorsAscended)} - /> - d.distanceMeters)} - detail={ - today.caloriesBurned != null - ? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal` - : undefined - } - /> -
-
- - {/* Heart & stress ---------------------------------------------------- */} -
-

心率与压力

-
- d.heartRate)} - /> - d.heartRateVariability)} - /> - d.stress)} - /> - d.trainingReadiness)} - /> -
-
- - {/* Sleep & breathing -------------------------------------------------- */} -
-

睡眠与呼吸

-
- d.sleepDuration)} - /> - d.sleepQuality)} - /> -
- -
- - - -

- 查看睡眠分期详情 → -

-
- - {/* Trends ------------------------------------------------------------- */} -
-

近 {days.length} 天趋势

-
- - - - -
-
-
- ); -} - -export default Dashboard; diff --git a/client/src/pages/ExercisePage.tsx b/client/src/pages/ExercisePage.tsx index 49b32a5..2a74be0 100644 --- a/client/src/pages/ExercisePage.tsx +++ b/client/src/pages/ExercisePage.tsx @@ -4,6 +4,7 @@ import { apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord, } from '../services/api'; import Screen from '../components/Screen'; +import { daysAgo, today as todayIso } from '../lib/day'; import MetricCard from '../components/charts/MetricCard'; import Chart from '../components/charts/Chart'; import Skeleton from '../components/Skeleton'; @@ -50,14 +51,12 @@ function ExercisePage() { 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([ + 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) + daysAgo(WINDOW_DAYS - 1), todayIso() ), ]); setActivities(a); @@ -74,8 +73,7 @@ function ExercisePage() { }, []); const recent = useMemo(() => { - const cutoff = new Date(Date.now() - WINDOW_DAYS * 86400000) - .toISOString().slice(0, 10); + const cutoff = daysAgo(WINDOW_DAYS); return activities.filter((a) => (a.start_time ?? '') >= cutoff); }, [activities]); diff --git a/client/src/pages/Health.tsx b/client/src/pages/Health.tsx deleted file mode 100644 index 30e520d..0000000 --- a/client/src/pages/Health.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { apiClient, errorMessage, HealthDay } from '../services/api'; -import MetricCard from '../components/charts/MetricCard'; -import Skeleton from '../components/Skeleton'; -import './Pages.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), - }, - ], - }, -]; - -function Health() { - 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); - setDays( - await apiClient.getHealthSummary( - start.toISOString().slice(0, 10), - end.toISOString().slice(0, 10) - ) - ); - } catch (err: any) { - setError(errorMessage(err, '加载失败')); - } finally { - setLoading(false); - } - }; - load(); - }, []); - - if (loading) { - return ( -
-

健康

- -
- ); - } - - if (error) { - return ( -
-

健康

-
{error}
-
- ); - } - - if (days.length === 0) { - return ( -
-

健康

-
-

还没有任何健康数据。

- 去同步 Garmin 数据 -
-
- ); - } - - // The most recent day that actually recorded a given metric — showing "—" - // because today's sleep has not synced yet would hide data that exists. - const latest = (pick: (d: HealthDay) => number | null) => { - for (let i = days.length - 1; i >= 0; i--) { - const v = pick(days[i]); - if (v != null) return { value: v, date: days[i].date }; - } - return { value: null, date: null }; - }; - - return ( -
-
-
-

健康

-

每项指标的最新值与参考区间

-
-
- - {SECTIONS.map((section) => ( -
-

{section.title}

-
- {section.items.map((item) => { - const { value, date } = latest(item.pick); - const stale = date != null && date !== days[days.length - 1].date; - return ( - - ); - })} -
-
- ))} - -

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

-
- ); -} - -export default Health; diff --git a/client/src/pages/HealthPage.tsx b/client/src/pages/HealthPage.tsx index 0d27d95..1555b45 100644 --- a/client/src/pages/HealthPage.tsx +++ b/client/src/pages/HealthPage.tsx @@ -5,6 +5,7 @@ import { METRICS, metricHref } from '../lib/metrics'; import MetricCard from '../components/charts/MetricCard'; import Skeleton from '../components/Skeleton'; import Screen from '../components/Screen'; +import { daysAgo, today as todayIso } from '../lib/day'; import './Health.css'; const WINDOW_DAYS = 30; @@ -73,12 +74,9 @@ function HealthPage() { useEffect(() => { const load = async () => { try { - const end = new Date(); - const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000); - setDays( + setDays( await apiClient.getHealthSummary( - start.toISOString().slice(0, 10), - end.toISOString().slice(0, 10) + daysAgo(WINDOW_DAYS - 1), todayIso() ) ); } catch (err: any) { diff --git a/client/src/pages/MetricDetailPage.tsx b/client/src/pages/MetricDetailPage.tsx index 7bd13c1..21a3693 100644 --- a/client/src/pages/MetricDetailPage.tsx +++ b/client/src/pages/MetricDetailPage.tsx @@ -3,6 +3,7 @@ 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 { daysAgo, today as todayIso } from '../lib/day'; import Chart from '../components/charts/Chart'; import BandBar from '../components/charts/BandBar'; import Skeleton from '../components/Skeleton'; @@ -37,10 +38,8 @@ function MetricDetailPage({ id, f7route }: Props) { 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) + const rows = await apiClient.getHealthSummary( + daysAgo(window - 1), todayIso() ); if (!cancelled) setDays(rows); } catch (err: any) { diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index d7e235c..17faf88 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -5,6 +5,7 @@ import { } from '../services/api'; import { FEATURES } from '../features'; import Screen from '../components/Screen'; +import { today as todayIso } from '../lib/day'; import Skeleton from '../components/Skeleton'; import './Settings.css'; @@ -145,7 +146,7 @@ function SettingsPage() { className="set-input" type="date" value={s.birthDate ?? ''} - max={new Date().toISOString().slice(0, 10)} + max={todayIso()} onChange={(e) => save({ birthDate: e.target.value || null })} /> diff --git a/client/src/pages/Sleep.tsx b/client/src/pages/Sleep.tsx deleted file mode 100644 index d3d0d0a..0000000 --- a/client/src/pages/Sleep.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { apiClient, errorMessage, HealthDay } from '../services/api'; -import Chart from '../components/charts/Chart'; -import StatTile from '../components/charts/StatTile'; -import Skeleton from '../components/Skeleton'; -import './Pages.css'; - -const RANGES = [7, 14, 30, 90]; -const H = 3600; - -function avg(values: Array): number | null { - const present = values.filter((v): v is number => v != null); - return present.length ? present.reduce((a, b) => a + b, 0) / present.length : null; -} - -function Sleep() { - const [days, setDays] = useState([]); - // 14 by default: the stacked chart needs bars wide enough to read the - // thinnest stage and to give hover a ~24px hit target. Longer windows stay - // available for the trend, where density matters less. - const [range, setRange] = useState(14); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - useEffect(() => { - const load = async () => { - setLoading(true); - try { - const end = new Date(); - const start = new Date(end.getTime() - (range - 1) * 86400000); - setDays( - await apiClient.getHealthSummary( - start.toISOString().slice(0, 10), - end.toISOString().slice(0, 10) - ) - ); - } catch (err: any) { - setError(errorMessage(err, '加载睡眠数据失败')); - } finally { - setLoading(false); - } - }; - load(); - }, [range]); - - const nights = days.filter((d) => d.sleepDuration != null); - - // Stage seconds are converted to hours here so the stacked bar and the - // duration chart share one y-scale — a chart never carries two scales. - const rows = nights.map((d) => ({ - date: d.date.slice(5), - deep: d.sleep?.deepSeconds != null ? d.sleep.deepSeconds / H : null, - light: d.sleep?.lightSeconds != null ? d.sleep.lightSeconds / H : null, - rem: d.sleep?.remSeconds != null ? d.sleep.remSeconds / H : null, - awake: d.sleep?.awakeSeconds != null ? d.sleep.awakeSeconds / H : null, - quality: d.sleepQuality, - spo2: d.sleepSpo2Avg, - respiration: d.sleepRespirationAvg, - stress: d.sleepStressAvg, - })); - - const avgDeep = avg(rows.map((r) => r.deep)); - const avgRem = avg(rows.map((r) => r.rem)); - const avgLight = avg(rows.map((r) => r.light)); - const avgAwake = avg(rows.map((r) => r.awake)); - const avgDuration = avg(nights.map((d) => d.sleepDuration)); - const avgQuality = avg(nights.map((d) => d.sleepQuality)); - - const totalStages = [avgDeep, avgLight, avgRem].reduce( - (sum, v) => sum + (v ?? 0), 0 - ); - const share = (v: number | null) => - v == null || totalStages === 0 ? undefined : `占 ${Math.round((v / totalStages) * 100)}%`; - - const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d); - - return ( -
-
-
-

睡眠

-

分期、评分与夜间生理指标

-
-
- {RANGES.map((r) => ( - - ))} -
-
- - {error &&
{error}
} - {loading && ( - <> - -
- - - )} - - {!loading && !error && nights.length === 0 && ( -
-

所选区间内没有睡眠记录。

- 去同步数据 -
- )} - - {!loading && !error && nights.length > 0 && ( - <> -
-

{nights.length} 晚平均

-
- - - - - - -
-
- -
-
- -
- -
- - - - -
-
- - )} -
- ); -} - -export default Sleep; diff --git a/client/src/pages/SleepPage.tsx b/client/src/pages/SleepPage.tsx index e4146c5..2028e7e 100644 --- a/client/src/pages/SleepPage.tsx +++ b/client/src/pages/SleepPage.tsx @@ -5,6 +5,7 @@ import Chart from '../components/charts/Chart'; import StatTile from '../components/charts/StatTile'; import Skeleton from '../components/Skeleton'; import Screen from '../components/Screen'; +import { daysAgo, today as todayIso } from '../lib/day'; const RANGES = [7, 14, 30, 90]; const H = 3600; @@ -27,12 +28,9 @@ function SleepPage() { const load = async () => { setLoading(true); try { - const end = new Date(); - const start = new Date(end.getTime() - (range - 1) * 86400000); - setDays( + setDays( await apiClient.getHealthSummary( - start.toISOString().slice(0, 10), - end.toISOString().slice(0, 10) + daysAgo(range - 1), todayIso() ) ); } catch (err: any) { diff --git a/client/src/pages/TodayPage.tsx b/client/src/pages/TodayPage.tsx index 481cefd..fb62e7e 100644 --- a/client/src/pages/TodayPage.tsx +++ b/client/src/pages/TodayPage.tsx @@ -9,6 +9,7 @@ import Skeleton from '../components/Skeleton'; import { useCountUp } from '../lib/motion'; import { RANGES } from '../lib/ranges'; import { METRICS, metricHref } from '../lib/metrics'; +import { daysAgo, iso, shiftDay, today as todayIso } from '../lib/day'; import './Today.css'; /* A year is loaded up front so stepping back a day costs no request. */ @@ -125,10 +126,6 @@ const SECTIONS: Array<{ title: string; items: string[] }> = [ { title: '睡眠', items: ['sleepDuration', 'sleepQuality'] }, ]; -const iso = (d: Date) => d.toISOString().slice(0, 10); -const shift = (date: string, days: number) => - iso(new Date(new Date(`${date}T12:00:00`).getTime() + days * 86400000)); - function TodayPage() { const [days, setDays] = useState([]); const [selected, setSelected] = useState(null); @@ -140,9 +137,9 @@ function TodayPage() { try { // A year in one read: browsing back a day should not cost a request, // and the cards' sparklines need the surrounding days anyway. - const end = new Date(); - const start = new Date(end.getTime() - (HISTORY_DAYS - 1) * 86400000); - setDays(await apiClient.getHealthSummary(iso(start), iso(end))); + setDays(await apiClient.getHealthSummary( + daysAgo(HISTORY_DAYS - 1), todayIso() + )); } catch (err: any) { setError(errorMessage(err, '加载数据失败')); } finally { @@ -167,7 +164,7 @@ function TodayPage() { const go = (delta: number) => { if (!date) return; - const target = shift(date, delta); + const target = shiftDay(date, delta); // Days with no record at all are skipped over rather than shown blank. const nearest = delta < 0 ? [...days].reverse().find((d) => d.date <= target) @@ -196,7 +193,7 @@ function TodayPage() { calendar.open(); }; - const isToday = date === iso(new Date()); + const isToday = date === todayIso(); const weekday = date ? ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][ new Date(`${date}T12:00:00`).getDay() @@ -206,7 +203,7 @@ function TodayPage() { const open = (id: string) => f7.views.current.router.navigate(metricHref(id)); return ( - + {loading && ( <>