-
健康仪表板
-
- {error &&
{error}
}
-
- {!hasData && !error && (
-
-
还没有任何健康数据。
-
- 去同步 Garmin 数据
-
+
- {hasData && (
- <>
-
- 今日
- {today ? (
-
-
-
-
-
-
- ) : (
- 今日暂无数据,最近一次记录见下方趋势。
+ {/* Activity ---------------------------------------------------------- */}
+
+ 活动
+
+
+
+
+
+
+
+ unit="小时"
+ />
+
+
-
+ {/* Heart & stress ---------------------------------------------------- */}
+
+ 心率与压力
+
+
+
+
+
+
+
+
+
-
- >
- )}
-
- );
-}
+ {/* Sleep & breathing -------------------------------------------------- */}
+
+ 睡眠与呼吸
+
+
+
+
+
+
+
+ 查看睡眠分期详情 →
+
+
-function StatCard({ icon, label, value }: { icon: string; label: string; value: string }) {
- return (
-
-
{icon}
-
+ {/* Trends ------------------------------------------------------------- */}
+
+ 近 {days.length} 天趋势
+
+
+
+
+
+
+
);
}
diff --git a/client/src/pages/Pages.css b/client/src/pages/Pages.css
index 9ff34f8..f56e75d 100644
--- a/client/src/pages/Pages.css
+++ b/client/src/pages/Pages.css
@@ -1,59 +1,351 @@
.page {
- animation: fadeIn 0.3s ease-in;
+ animation: fadeIn 0.25s ease-out;
+ color: var(--text-primary);
}
@keyframes fadeIn {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.page-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.5rem;
}
.page h2 {
- color: #333;
- margin-bottom: 1.5rem;
- font-size: 1.8rem;
+ margin: 0;
+ font-size: 1.4rem;
+ font-weight: 680;
+ color: var(--text-primary);
+}
+
+.subtitle {
+ margin: 0.25rem 0 0;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.section {
+ margin-bottom: 1.75rem;
+}
+
+.section-title {
+ display: flex;
+ align-items: baseline;
+ gap: 0.6rem;
+ font-size: 0.8rem;
+ font-weight: 650;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+ margin: 0 0 0.7rem;
+}
+
+.section-count {
+ font-weight: 400;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.section-link {
+ margin: 0.7rem 0 0;
+ font-size: 0.82rem;
+}
+
+.section-link a,
+.page a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.section-link a:hover,
+.page a:hover {
+ text-decoration: underline;
+}
+
+/* Filters sit in one row above the charts. */
+.range-tabs,
+.metric-tabs {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+}
+
+.metric-tabs {
+ margin-bottom: 1.25rem;
+ padding-bottom: 0.9rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.range-tab,
+.metric-tab {
+ padding: 0.34rem 0.8rem;
+ border: 1px solid var(--border);
+ background: var(--surface-1);
+ border-radius: 999px;
+ font-size: 0.82rem;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: border-color 0.15s ease, color 0.15s ease;
+ font-family: inherit;
+ white-space: nowrap;
+}
+
+.range-tab:hover,
+.metric-tab:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.range-tab.active,
+.metric-tab.active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+.chart-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.chart-grid.one-col {
+ grid-template-columns: 1fr;
}
.page-loading {
display: flex;
align-items: center;
justify-content: center;
- min-height: 400px;
- font-size: 1.2rem;
- color: #666;
+ min-height: 240px;
+ color: var(--text-muted);
+ font-size: 0.95rem;
}
.placeholder {
- color: #999;
- font-size: 1.1rem;
- padding: 2rem;
- background-color: #f9f9f9;
- border-radius: 8px;
text-align: center;
+ padding: 2rem;
+ color: var(--text-muted);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.9rem;
}
+.empty-state {
+ text-align: center;
+ padding: 3rem 2rem;
+ background: var(--surface-1);
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius);
+ color: var(--text-secondary);
+}
+
+.empty-state p {
+ margin: 0 0 1.1rem;
+}
+
+.error-message {
+ background: color-mix(in srgb, var(--status-critical) 10%, var(--surface-1));
+ border: 1px solid color-mix(in srgb, var(--status-critical) 35%, transparent);
+ color: var(--status-critical);
+ padding: 0.85rem 1rem;
+ border-radius: var(--radius);
+ margin-bottom: 1rem;
+ font-size: 0.88rem;
+}
+
+.success-message {
+ background: color-mix(in srgb, var(--status-good) 10%, var(--surface-1));
+ border: 1px solid color-mix(in srgb, var(--status-good) 35%, transparent);
+ color: var(--text-primary);
+ padding: 0.85rem 1rem;
+ border-radius: var(--radius);
+ margin-bottom: 1rem;
+ font-size: 0.88rem;
+}
+
+/* Buttons ------------------------------------------------------------------ */
.btn {
- padding: 0.75rem 1.5rem;
- border: none;
- border-radius: 6px;
- font-size: 1rem;
+ padding: 0.55rem 1.1rem;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ font-size: 0.9rem;
cursor: pointer;
- transition: all 0.3s ease;
- margin-top: 1rem;
+ font-family: inherit;
+ transition: all 0.15s ease;
+ text-decoration: none;
+ display: inline-block;
}
.btn-primary {
- background-color: #667eea;
- color: white;
+ background: var(--accent);
+ color: #fff;
}
-.btn-primary:hover {
- background-color: #5568d3;
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+.btn-primary:hover:not(:disabled) {
+ filter: brightness(1.08);
+}
+
+.btn-primary:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.btn-large {
+ width: 100%;
+ max-width: 300px;
+ padding: 0.75rem 1.5rem;
+ font-size: 1rem;
+}
+
+.btn-plain {
+ background: var(--surface-1);
+ border-color: var(--border);
+ color: var(--text-secondary);
+ font-size: 0.84rem;
+ padding: 0.4rem 0.85rem;
+}
+
+.btn-plain:hover {
+ border-color: var(--border-strong);
+ color: var(--text-primary);
+}
+
+/* Badges ------------------------------------------------------------------- */
+.badge-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 0.65rem;
+}
+
+.badge {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--series-4);
+ border-radius: 8px;
+ padding: 0.7rem 0.85rem;
+}
+
+.badge-name {
+ font-size: 0.86rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ line-height: 1.4;
+}
+
+.badge-meta {
+ margin-top: 0.3rem;
+ font-size: 0.74rem;
+ color: var(--text-muted);
+ display: flex;
+ gap: 0.5rem;
+}
+
+.badge-count {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+/* Tables ------------------------------------------------------------------- */
+.table-wrap {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: auto;
+ background: var(--surface-1);
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+
+.data-table th,
+.data-table td {
+ padding: 0.6rem 0.85rem;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.data-table thead th {
+ background: var(--surface-2);
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ font-weight: 650;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ position: sticky;
+ top: 0;
+}
+
+.data-table tbody th {
+ color: var(--text-primary);
+ font-weight: 550;
+}
+
+.data-table td.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-primary);
+}
+
+.data-table tbody tr:last-child th,
+.data-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+/* Forms -------------------------------------------------------------------- */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ font-size: 0.84rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.form-group input {
+ padding: 0.65rem 0.8rem;
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ font-size: 0.95rem;
+ font-family: inherit;
+ background: var(--surface-2);
+ color: var(--text-primary);
+}
+
+.form-group input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+
+.field-hint {
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ line-height: 1.7;
+ margin: 0;
+}
+
+@media (max-width: 640px) {
+ .chart-grid {
+ grid-template-columns: 1fr;
+ }
+ .page-head {
+ flex-direction: column;
+ }
}
diff --git a/client/src/pages/Sleep.tsx b/client/src/pages/Sleep.tsx
new file mode 100644
index 0000000..051e009
--- /dev/null
+++ b/client/src/pages/Sleep.tsx
@@ -0,0 +1,175 @@
+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 './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([]);
+ const [range, setRange] = useState(30);
+ 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 (
+
+
+
+ {error &&
{error}
}
+ {loading &&
加载中…
}
+
+ {!loading && !error && nights.length === 0 && (
+
+ )}
+
+ {!loading && !error && nights.length > 0 && (
+ <>
+
+ {nights.length} 晚平均
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default Sleep;
diff --git a/client/src/pages/Trends.tsx b/client/src/pages/Trends.tsx
new file mode 100644
index 0000000..373f9f4
--- /dev/null
+++ b/client/src/pages/Trends.tsx
@@ -0,0 +1,257 @@
+import { useEffect, useMemo, useState } from 'react';
+import { apiClient, errorMessage, HealthDay } from '../services/api';
+import Chart, { Series } from '../components/charts/Chart';
+import StatTile from '../components/charts/StatTile';
+import './Pages.css';
+
+const RANGES = [7, 14, 30, 90, 365];
+
+/** Each group is one chart. Metrics only share a chart when they share a
+ * scale and a unit — a chart never carries two y-scales. */
+interface MetricGroup {
+ id: string;
+ label: string;
+ unit?: string;
+ type: 'line' | 'bar' | 'area';
+ /** Which HealthDay fields to plot, in palette-slot order. */
+ series: Series[];
+ /** Optional transform, e.g. seconds to hours. */
+ scale?: Record;
+ note?: string;
+}
+
+const GROUPS: MetricGroup[] = [
+ {
+ id: 'steps', label: '步数', unit: '步', type: 'bar',
+ series: [{ key: 'steps', label: '步数', slot: 1, unit: '步' }],
+ },
+ {
+ id: 'distance', label: '距离', unit: 'km', type: 'bar',
+ scale: { distanceMeters: 1 / 1000 },
+ series: [{ key: 'distanceMeters', label: '距离', slot: 1, unit: 'km', decimals: 2 }],
+ },
+ {
+ id: 'calories', label: '能量消耗', unit: 'kcal', type: 'bar',
+ series: [
+ { key: 'bmrCalories', label: '基础代谢', slot: 1, unit: 'kcal' },
+ { key: 'activeCalories', label: '活动消耗', slot: 2, unit: 'kcal' },
+ ],
+ note: '两者相加即当日总消耗。',
+ },
+ {
+ id: 'heart', label: '心率', unit: 'bpm', type: 'line',
+ series: [
+ { key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
+ { key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
+ { key: 'heartRateMin', label: '最低', slot: 3, unit: 'bpm' },
+ ],
+ },
+ {
+ id: 'hrv', label: '心率变异性', unit: 'ms', type: 'area',
+ series: [{ key: 'heartRateVariability', label: 'HRV', slot: 1, unit: 'ms', decimals: 1 }],
+ note: 'HRV 反映自主神经恢复情况,持续偏低常与压力或训练过量相关。',
+ },
+ {
+ id: 'stress', label: '压力', type: 'line',
+ series: [
+ { key: 'stress', label: '平均', slot: 1 },
+ { key: 'stressMax', label: '峰值', slot: 2 },
+ ],
+ },
+ {
+ id: 'battery', label: '身体电量', type: 'line',
+ series: [
+ { key: 'bodyBatteryHigh', label: '最高', slot: 1 },
+ { key: 'bodyBatteryLow', label: '最低', slot: 2 },
+ ],
+ },
+ {
+ id: 'sleep', label: '睡眠时长', unit: '小时', type: 'area',
+ series: [{ key: 'sleepDuration', label: '时长', slot: 1, unit: '小时', decimals: 1 }],
+ },
+ {
+ id: 'spo2', label: '血氧', unit: '%', type: 'line',
+ series: [
+ { key: 'spo2Avg', label: '平均', slot: 1, unit: '%', decimals: 1 },
+ { key: 'spo2Min', label: '最低', slot: 2, unit: '%' },
+ ],
+ },
+ {
+ id: 'respiration', label: '呼吸频率', unit: '次/分', type: 'line',
+ series: [
+ { key: 'respirationAvg', label: '平均', slot: 1, unit: '次/分', decimals: 1 },
+ { key: 'respirationMax', label: '最高', slot: 2, unit: '次/分', decimals: 1 },
+ { key: 'respirationMin', label: '最低', slot: 3, unit: '次/分', decimals: 1 },
+ ],
+ },
+ {
+ id: 'floors', label: '爬楼', unit: '层', type: 'bar',
+ series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层', decimals: 0 }],
+ },
+ {
+ id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
+ series: [{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }],
+ },
+ {
+ id: 'sedentary', label: '久坐与活动时长', unit: '小时', type: 'bar',
+ scale: { sedentarySeconds: 1 / 3600, activeSeconds: 1 / 3600 },
+ series: [
+ { key: 'sedentarySeconds', label: '久坐', slot: 1, unit: '小时', decimals: 1 },
+ { key: 'activeSeconds', label: '活动', slot: 2, unit: '小时', decimals: 1 },
+ ],
+ },
+ {
+ id: 'training', label: '训练准备度', unit: '/100', type: 'area',
+ series: [{ key: 'trainingReadiness', label: '准备度', slot: 1 }],
+ },
+ {
+ id: 'endurance', label: '耐力分', type: 'area',
+ series: [{ key: 'enduranceScore', label: '耐力分', slot: 1 }],
+ },
+];
+
+function stats(values: Array) {
+ const present = values.filter((v): v is number => v != null);
+ if (!present.length) return null;
+ const sorted = [...present].sort((a, b) => a - b);
+ const mean = present.reduce((a, b) => a + b, 0) / present.length;
+ const mid = Math.floor(present.length / 2);
+ const firstHalf = present.slice(0, mid);
+ const secondHalf = present.slice(mid);
+ const delta =
+ firstHalf.length && secondHalf.length
+ ? secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length -
+ firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
+ : 0;
+ return {
+ count: present.length,
+ mean,
+ min: sorted[0],
+ max: sorted[sorted.length - 1],
+ median: sorted[mid],
+ delta,
+ };
+}
+
+function Trends() {
+ const [days, setDays] = useState([]);
+ const [range, setRange] = useState(30);
+ const [active, setActive] = useState(GROUPS[0].id);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ const load = async () => {
+ setLoading(true);
+ setError('');
+ 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 group = GROUPS.find((g) => g.id === active) ?? GROUPS[0];
+
+ const rows = useMemo(
+ () =>
+ days.map((d) => {
+ const row: Record = { date: d.date.slice(5) };
+ for (const s of group.series) {
+ const raw = (d as any)[s.key];
+ const factor = group.scale?.[s.key];
+ row[s.key] = raw == null ? null : factor ? raw * factor : raw;
+ }
+ return row;
+ }),
+ [days, group]
+ );
+
+ const primary = group.series[0];
+ const summary = stats(rows.map((r) => r[primary.key]));
+ const fmt = (v: number) =>
+ (Math.round(v * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 });
+
+ return (
+
+
+
+
+ {GROUPS.map((g) => (
+ setActive(g.id)}
+ >
+ {g.label}
+
+ ))}
+
+
+ {error &&
{error}
}
+ {loading &&
加载中…
}
+
+ {!loading && !error && (
+ <>
+ {summary ? (
+
+
+
+
+
+ = 0 ? '+' : ''}${fmt(summary.delta)}`}
+ unit={primary.unit}
+ />
+
+
+ ) : (
+
该指标在所选区间内没有数据。
+ )}
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default Trends;
diff --git a/client/src/services/api.ts b/client/src/services/api.ts
index 9cd7c50..28e4d51 100644
--- a/client/src/services/api.ts
+++ b/client/src/services/api.ts
@@ -12,14 +12,87 @@ export interface AuthResponse {
token: string;
}
+export interface SleepDetail {
+ duration: number;
+ quality: number | null;
+ deepSeconds: number | null;
+ lightSeconds: number | null;
+ remSeconds: number | null;
+ awakeSeconds: number | null;
+}
+
+/** One day, with every metric the sync stores. All are nullable: a device
+ * that does not record a metric leaves it null rather than zero. */
export interface HealthDay {
date: string;
steps: number | null;
- heartRate: number | null;
- heartRateVariability: number | null;
- sleep: { duration: number; quality: number | null } | null;
- stress: number | null;
+ stepGoal: number | null;
+ distanceMeters: number | null;
caloriesBurned: number | null;
+ activeCalories: number | null;
+ bmrCalories: number | null;
+ floorsAscended: number | null;
+ floorsDescended: number | null;
+ intensityMinutes: number | null;
+ sedentarySeconds: number | null;
+ activeSeconds: number | null;
+ heartRate: number | null;
+ heartRateMax: number | null;
+ heartRateMin: number | null;
+ heartRateVariability: number | null;
+ stress: number | null;
+ stressMax: number | null;
+ bodyBatteryHigh: number | null;
+ bodyBatteryLow: number | null;
+ bodyBatteryCharged: number | null;
+ bodyBatteryDrained: number | null;
+ spo2Avg: number | null;
+ spo2Min: number | null;
+ respirationAvg: number | null;
+ respirationMin: number | null;
+ respirationMax: number | null;
+ sleepDuration: number | null;
+ sleepQuality: number | null;
+ sleepSpo2Avg: number | null;
+ sleepRespirationAvg: number | null;
+ sleepStressAvg: number | null;
+ trainingReadiness: number | null;
+ vo2max: number | null;
+ enduranceScore: number | null;
+ sleep: SleepDetail | null;
+}
+
+export interface Badge {
+ id: string;
+ badge_key: string | null;
+ name: string | null;
+ category_id: number | null;
+ difficulty_id: number | null;
+ earned_date: string | null;
+ earned_count: number | null;
+ points: number | null;
+}
+
+export interface PersonalRecord {
+ id: string;
+ type_id: number | null;
+ activity_id: string | null;
+ activity_name: string | null;
+ activity_type: string | null;
+ value: number | null;
+ achieved_at: string | null;
+}
+
+export interface Activity {
+ id: string;
+ activity_type: string;
+ start_time: string;
+ end_time: string;
+ duration: number | null;
+ distance: number | null;
+ calories: number | null;
+ heart_rate_average: number | null;
+ heart_rate_max: number | null;
}
export interface SyncStatus {
@@ -266,13 +339,25 @@ class ApiClient {
}
async getActivities(startDate?: string, endDate?: string) {
- const { data } = await this.client.get(
+ const { data } = await this.client.get(
'/health/activities',
this.range(startDate, endDate)
);
return data;
}
+ async getBadges() {
+ const { data } = await this.client.get('/health/badges');
+ return data;
+ }
+
+ async getPersonalRecords() {
+ const { data } = await this.client.get(
+ '/health/personal-records'
+ );
+ return data;
+ }
+
// --- analysis ---
async getTrends(metricType: string, startDate?: string, endDate?: string) {
const { data } = await this.client.get('/analysis/trends', {
diff --git a/client/src/theme.css b/client/src/theme.css
new file mode 100644
index 0000000..7d84f04
--- /dev/null
+++ b/client/src/theme.css
@@ -0,0 +1,102 @@
+/*
+ * Design tokens.
+ *
+ * The series colours are the validated categorical palette, in its documented
+ * slot order — the ordering is the colour-blind-safety mechanism, not a
+ * cosmetic choice, so slots are assigned in order and never cycled.
+ * Verified with the palette validator in both modes:
+ * light worst adjacent CVD ΔE 9.1, normal-vision ΔE 22.9
+ * dark worst adjacent CVD ΔE 8.4, normal-vision ΔE 19.8
+ * On the light surface aqua (2.74:1) and yellow (2.11:1) fall below 3:1, so
+ * every chart using them ships visible labels plus a table view.
+ */
+
+:root {
+ color-scheme: light;
+
+ /* surfaces & ink */
+ --surface-0: #f4f4f2;
+ --surface-1: #fcfcfb;
+ --surface-2: #ffffff;
+ --border: #e4e3df;
+ --border-strong: #d3d2cd;
+ --text-primary: #0b0b0b;
+ --text-secondary: #52514e;
+ --text-muted: #86847e;
+
+ /* categorical series — assign in order */
+ --series-1: #2a78d6; /* blue */
+ --series-2: #eb6834; /* orange */
+ --series-3: #1baf7a; /* aqua */
+ --series-4: #eda100; /* yellow */
+ --series-5: #e87ba4; /* magenta */
+ --series-6: #008300; /* green */
+
+ /* status — reserved, never reused as a series */
+ --status-good: #0ca30c;
+ --status-warning: #fab219;
+ --status-serious: #ec835a;
+ --status-critical: #d03b3b;
+
+ --grid: #eceae5;
+ --accent: #2a78d6;
+ --accent-soft: #eef4fd;
+
+ --radius: 10px;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03);
+}
+
+/* Dark steps are the same hues re-stepped for the dark surface — selected and
+ validated as a set, not an automatic inversion. Declared under both the OS
+ media query and the explicit toggle so either can win. */
+@media (prefers-color-scheme: dark) {
+ :root:where(:not([data-theme='light'])) {
+ color-scheme: dark;
+ --surface-0: #131312;
+ --surface-1: #1a1a19;
+ --surface-2: #232322;
+ --border: #34342f;
+ --border-strong: #45443e;
+ --text-primary: #ffffff;
+ --text-secondary: #c3c2b7;
+ --text-muted: #8f8e85;
+
+ --series-1: #3987e5;
+ --series-2: #d95926;
+ --series-3: #199e70;
+ --series-4: #c98500;
+ --series-5: #d55181;
+ --series-6: #008300;
+
+ --grid: #2c2c28;
+ --accent: #3987e5;
+ --accent-soft: #1d2938;
+
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
+ }
+}
+
+:root[data-theme='dark'] {
+ color-scheme: dark;
+ --surface-0: #131312;
+ --surface-1: #1a1a19;
+ --surface-2: #232322;
+ --border: #34342f;
+ --border-strong: #45443e;
+ --text-primary: #ffffff;
+ --text-secondary: #c3c2b7;
+ --text-muted: #8f8e85;
+
+ --series-1: #3987e5;
+ --series-2: #d95926;
+ --series-3: #199e70;
+ --series-4: #c98500;
+ --series-5: #d55181;
+ --series-6: #008300;
+
+ --grid: #2c2c28;
+ --accent: #3987e5;
+ --accent-soft: #1d2938;
+
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
+}