Fixed Garmin sync progress bar granularity, timeout issue, and lookback days input, plus redesigned the sleep timeline with taller bars and yellow Awake colour.
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 48s
Build and push images / build-worker (push) Successful in 44s
Build and push images / build-frontend (push) Successful in 28s

This commit is contained in:
2026-06-07 18:15:07 +01:00
parent bf1920eb9d
commit 492418586a
5 changed files with 120 additions and 59 deletions
+11 -4
View File
@@ -63,11 +63,12 @@ docker compose -f docker-compose.deploy.yml up -d
- `main.py` — FastAPI app, DB init on startup (creates tables, seeds admin user, creates TimescaleDB hypertable) - `main.py` — FastAPI app, DB init on startup (creates tables, seeds admin user, creates TimescaleDB hypertable)
- `core/``config.py` (pydantic-settings from env), `database.py` (async engine for FastAPI + sync engine for Celery), `security.py` (JWT, bcrypt) - `core/``config.py` (pydantic-settings from env), `database.py` (async engine for FastAPI + sync engine for Celery), `security.py` (JWT, bcrypt)
- `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile` - `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile`, `garmin_sync`
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `RouteSegment`, `PersonalRecord`, `HealthMetric`, `WeightLog` - `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `RouteSegment`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig`
- `services/fit_parser.py` — parses Garmin FIT and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion - `services/fit_parser.py` — parses Garmin FIT and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion
- `services/wellness_parser.py` — parses Garmin wellness FIT files (metrics, sleep, HRV, SPO2, etc.) - `services/wellness_parser.py` — parses Garmin wellness FIT files (metrics, sleep, HRV, SPO2, etc.)
- `services/route_matcher.py` — bounding-box pre-filter + DTW (Dynamic Time Warping) for GPS track similarity - `services/route_matcher.py` — bounding-box pre-filter + DTW (Dynamic Time Warping) for GPS track similarity
- `services/garmin_connect_sync.py` — Garmin Connect API integration; `authenticate_garmin()` tries stored OAuth token first, falls back to email/password; Garmin credentials stored Fernet-encrypted using `SECRET_KEY` as the key
- `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `detect_route`, `compute_personal_records`, `process_garmin_health_zip` - `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `detect_route`, `compute_personal_records`, `process_garmin_health_zip`
### Key design decisions ### Key design decisions
@@ -76,7 +77,7 @@ docker compose -f docker-compose.deploy.yml up -d
**File routing in Celery**: `process_activity_file` inspects the filename; files matching wellness suffixes (`_METRICS.fit`, `_WELLNESS.fit`, `_SLEEP.fit`, etc.) are routed to `parse_wellness_fit` instead. **File routing in Celery**: `process_activity_file` inspects the filename; files matching wellness suffixes (`_METRICS.fit`, `_WELLNESS.fit`, `_SLEEP.fit`, etc.) are routed to `parse_wellness_fit` instead.
**Schema management**: No Alembic migrations are used in production. `Base.metadata.create_all` runs at startup with retry logic to handle multi-worker races. Health metrics upserts use raw SQL `ON CONFLICT ... DO UPDATE SET ... COALESCE(EXCLUDED.x, existing.x)` to merge data from multiple file sources without overwriting. **Schema management**: No Alembic migrations are used in production. `Base.metadata.create_all` runs at startup with retry logic to handle multi-worker races. Post-initial schema changes (new columns, constraint changes) are applied as `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` / `DROP CONSTRAINT IF EXISTS` statements in `init_db()` in `main.py` — this is the only place schema migrations happen. Health metrics upserts use raw SQL `ON CONFLICT ... DO UPDATE SET ... COALESCE(EXCLUDED.x, existing.x)` to merge data from multiple file sources without overwriting.
**PocketID OIDC**: Optional passkey auth. Config is read from the admin user's DB record first, falling back to env vars. The OAuth callback redirects to `/?token=<jwt>` and `useAuth.js` extracts the token from the URL at module load time. **PocketID OIDC**: Optional passkey auth. Config is read from the admin user's DB record first, falling back to env vars. The OAuth callback redirects to `/?token=<jwt>` and `useAuth.js` extracts the token from the URL at module load time.
@@ -85,8 +86,10 @@ docker compose -f docker-compose.deploy.yml up -d
- `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here - `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here
- `hooks/useAuth.js` — Zustand store for auth state, reads JWT from `localStorage`, handles PocketID token-in-URL flow - `hooks/useAuth.js` — Zustand store for auth state, reads JWT from `localStorage`, handles PocketID token-in-URL flow
- `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler - `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler
- `pages/` — one file per route - `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc.
- `pages/` — one file per route; includes `SegmentsPage` for route segment management
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable` - `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`
- `components/ui/RouteMiniMap` — small Leaflet map used in route/segment cards
The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time. The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time.
@@ -104,6 +107,10 @@ Required in `.env` (or passed to Docker Compose):
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer | | `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer |
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
## milevault_export/
`milevault_export/` is a sanitised snapshot of the project used for public distribution (stripped of dev-only configs). It mirrors the main project structure. When making changes that affect deployment files (`docker-compose.yml`, `nginx.conf`, `scripts/manage.sh`, `docker/init.sql`, etc.), keep this directory in sync manually.
## Rules ## Rules
- The current build will always be running in docker at ~/milevault_docker with the following container names: - The current build will always be running in docker at ~/milevault_docker with the following container names:
`milevault_backend` `milevault_backend`
+16 -3
View File
@@ -72,7 +72,8 @@ def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str
# ── Activity sync ───────────────────────────────────────────────────────────── # ── Activity sync ─────────────────────────────────────────────────────────────
def sync_activities(garmin, user_id: int, since: Optional[datetime], def sync_activities(garmin, user_id: int, since: Optional[datetime],
db, file_store_path: str, lookback_days: int = 30) -> int: db, file_store_path: str, lookback_days: int = 30,
status_callback=None) -> int:
""" """
List activities from Garmin Connect, skip any already in the DB, download List activities from Garmin Connect, skip any already in the DB, download
FIT ZIPs for new ones, and queue them for processing. FIT ZIPs for new ones, and queue them for processing.
@@ -108,6 +109,10 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
logger.error("Failed to list Garmin activities: %s", exc) logger.error("Failed to list Garmin activities: %s", exc)
return 0 return 0
total = len(activities)
if status_callback and total:
status_callback(f"Syncing activities: 0/{total} queued")
queued = 0 queued = 0
for act in activities: for act in activities:
garmin_id = str(act.get("activityId", "")).strip() garmin_id = str(act.get("activityId", "")).strip()
@@ -173,6 +178,9 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
process_activity_file.delay(str(dest), user_id, "fit", garmin_id) process_activity_file.delay(str(dest), user_id, "fit", garmin_id)
queued += 1 queued += 1
if status_callback and (queued % 5 == 0 or queued == total):
status_callback(f"Syncing activities: {queued}/{total} queued")
# Brief pause to avoid hammering the Garmin API # Brief pause to avoid hammering the Garmin API
time.sleep(0.5) time.sleep(0.5)
@@ -182,7 +190,7 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
# ── Wellness sync ───────────────────────────────────────────────────────────── # ── Wellness sync ─────────────────────────────────────────────────────────────
def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
lookback_days: int = 90) -> int: lookback_days: int = 90, status_callback=None) -> int:
""" """
Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each
day in the window and upsert into health_metrics. day in the window and upsert into health_metrics.
@@ -209,8 +217,13 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
import time as _time import time as _time
import json as _json import json as _json
for i in range(max(days, 1)): total_days = max(days, 1)
if status_callback:
status_callback(f"Syncing wellness: 0/{total_days} days")
for i in range(total_days):
day = start_date + timedelta(days=i) day = start_date + timedelta(days=i)
if status_callback and (i % 5 == 0 or i == total_days - 1):
status_callback(f"Syncing wellness: {i + 1}/{total_days} days")
day_str = day.isoformat() day_str = day.isoformat()
stats = _safe(garmin.get_stats, day_str) stats = _safe(garmin.get_stats, day_str)
+8 -4
View File
@@ -511,24 +511,28 @@ def sync_garmin_connect_user(user_id: int):
wellness_days = 0 wellness_days = 0
errors = [] errors = []
if sync_acts: def _set_status(text):
cfg.last_sync_status = "Syncing activities..." cfg.last_sync_status = text
db.commit() db.commit()
if sync_acts:
_set_status("Syncing activities...")
try: try:
activities_queued = sync_activities( activities_queued = sync_activities(
garmin, user_id, last_sync_at, db, settings.file_store_path, garmin, user_id, last_sync_at, db, settings.file_store_path,
lookback_days=lookback, lookback_days=lookback,
status_callback=_set_status,
) )
except Exception as exc: except Exception as exc:
errors.append(f"activities: {exc}") errors.append(f"activities: {exc}")
if sync_well: if sync_well:
cfg.last_sync_status = "Syncing wellness data..." _set_status("Syncing wellness...")
db.commit()
try: try:
wellness_days = sync_wellness( wellness_days = sync_wellness(
garmin, user_id, last_sync_at, db, garmin, user_id, last_sync_at, db,
lookback_days=lookback, lookback_days=lookback,
status_callback=_set_status,
) )
except Exception as exc: except Exception as exc:
errors.append(f"wellness: {exc}") errors.append(f"wellness: {exc}")
+20 -15
View File
@@ -169,7 +169,7 @@ function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
{ key: 'deep', secs: deep || 0, color: '#6366f1', label: 'Deep' }, { key: 'deep', secs: deep || 0, color: '#6366f1', label: 'Deep' },
{ key: 'rem', secs: rem || 0, color: '#8b5cf6', label: 'REM' }, { key: 'rem', secs: rem || 0, color: '#8b5cf6', label: 'REM' },
{ key: 'light', secs: light || 0, color: '#a78bfa', label: 'Light' }, { key: 'light', secs: light || 0, color: '#a78bfa', label: 'Light' },
{ key: 'awake', secs: awake || 0, color: '#374151', label: 'Awake' }, { key: 'awake', secs: awake || 0, color: '#eab308', label: 'Awake' },
].filter(s => s.secs > 0) ].filter(s => s.secs > 0)
// Generate hour tick marks within the sleep window // Generate hour tick marks within the sleep window
@@ -185,27 +185,32 @@ function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
} }
return ( return (
<div className="space-y-1.5"> <div>
{/* Time bar */} {/* Stage bars rising from the time axis */}
<div className="relative"> <div className="relative flex overflow-hidden rounded-t-sm" style={{ height: 48 }}>
<div className="flex rounded-md overflow-hidden h-5 w-full"> {stages.map(s => (
{stages.map((s, i) => (
<div <div
key={s.key} key={s.key}
title={`${s.label}: ${Math.round(s.secs / 60)} min`}
style={{ width: `${(s.secs / stageSecs * 100).toFixed(2)}%`, backgroundColor: s.color }} style={{ width: `${(s.secs / stageSecs * 100).toFixed(2)}%`, backgroundColor: s.color }}
/> />
))} ))}
</div> {/* Tick lines overlaid on bars */}
{/* Tick marks */}
{ticks.map((t, i) => ( {ticks.map((t, i) => (
<div key={i} className="absolute top-0 h-5 flex flex-col items-center pointer-events-none" style={{ left: `${t.pct}%` }}> <div key={i} className="absolute top-0 bottom-0 w-px bg-black/25 pointer-events-none"
<div className="w-px h-full bg-black/40" /> style={{ left: `${t.pct}%` }} />
))}
</div> </div>
{/* Axis line */}
<div className="border-t border-gray-600 relative">
{ticks.map((t, i) => (
<div key={i} className="absolute top-0 w-px h-1.5 bg-gray-600"
style={{ left: `${t.pct}%` }} />
))} ))}
</div> </div>
{/* Time labels */} {/* Time labels */}
<div className="relative h-4"> <div className="relative h-4 mt-1">
<span className="absolute left-0 text-xs text-gray-500" style={{ transform: 'translateX(-0%)' }}> <span className="absolute left-0 text-xs text-gray-500">
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} {new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</span> </span>
{ticks.map((t, i) => ( {ticks.map((t, i) => (
@@ -318,7 +323,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
['Deep', day.sleep_deep_s, '#6366f1'], ['Deep', day.sleep_deep_s, '#6366f1'],
['REM', day.sleep_rem_s, '#8b5cf6'], ['REM', day.sleep_rem_s, '#8b5cf6'],
['Light', day.sleep_light_s, '#a78bfa'], ['Light', day.sleep_light_s, '#a78bfa'],
['Awake', day.sleep_awake_s, '#4b5563'], ['Awake', day.sleep_awake_s, '#eab308'],
].map(([label, secs, color]) => secs ? ( ].map(([label, secs, color]) => secs ? (
<div key={label} className="flex items-center gap-1.5"> <div key={label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} /> <div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
@@ -565,7 +570,7 @@ function SleepChart({ data, selectedDate, onDayClick }) {
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" /> <Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" /> <Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" /> <Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} /> <Bar dataKey="awake" name="Awake" stackId="a" fill="#eab308" radius={[2, 2, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) )
@@ -704,7 +709,7 @@ export default function HealthPage() {
<SleepChart data={metrics} <SleepChart data={metrics}
selectedDate={selDateForCharts} onDayClick={handleDayClick} /> selectedDate={selDateForCharts} onDayClick={handleDayClick} />
<div className="flex gap-4 mt-2"> <div className="flex gap-4 mt-2">
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => ( {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5"> <div key={l} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} /> <div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
<span className="text-xs text-gray-400">{l}</span> <span className="text-xs text-gray-400">{l}</span>
+53 -21
View File
@@ -115,7 +115,7 @@ export default function ProfilePage() {
queryKey: ['garmin-config'], queryKey: ['garmin-config'],
queryFn: () => api.get('/garmin-sync/config').then(r => r.data), queryFn: () => api.get('/garmin-sync/config').then(r => r.data),
}) })
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: 30 }) const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
const [gcSaved, setGcSaved] = useState(false) const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('') const [gcError, setGcError] = useState('')
const [gcSyncing, setGcSyncing] = useState(false) const [gcSyncing, setGcSyncing] = useState(false)
@@ -129,7 +129,7 @@ export default function ProfilePage() {
sync_enabled: garminConfig.sync_enabled, sync_enabled: garminConfig.sync_enabled,
sync_activities: garminConfig.sync_activities, sync_activities: garminConfig.sync_activities,
sync_wellness: garminConfig.sync_wellness, sync_wellness: garminConfig.sync_wellness,
sync_lookback_days: garminConfig.sync_lookback_days ?? 30, sync_lookback_days: String(garminConfig.sync_lookback_days ?? 30),
})) }))
} }
}, [garminConfig]) }, [garminConfig])
@@ -148,14 +148,14 @@ export default function ProfilePage() {
mutationFn: () => api.delete('/garmin-sync/config'), mutationFn: () => api.delete('/garmin-sync/config'),
onSuccess: () => { onSuccess: () => {
refetchGarmin() refetchGarmin()
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: 30 }) setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
}, },
}) })
const triggerGarminSync = async () => { const triggerGarminSync = async () => {
setGcSyncing(true) setGcSyncing(true)
try { try {
await api.post('/garmin-sync/trigger') await api.post('/garmin-sync/trigger')
// Poll every 2s: wait until we've seen an in-progress status, then wait for terminal // Poll every 3s: wait until we've seen an in-progress status, then wait for terminal
let seenInProgress = false let seenInProgress = false
syncPollRef.current = setInterval(async () => { syncPollRef.current = setInterval(async () => {
const result = await refetchGarmin() const result = await refetchGarmin()
@@ -167,23 +167,36 @@ export default function ProfilePage() {
syncPollRef.current = null syncPollRef.current = null
setGcSyncing(false) setGcSyncing(false)
} }
}, 2000) }, 3000)
// Safety: stop polling after 10 minutes regardless // Absolute safety: stop polling after 4 hours but keep bar visible — sync may still be running
setTimeout(() => { setTimeout(() => {
if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null } if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null }
setGcSyncing(false) }, 4 * 60 * 60 * 1000)
}, 600000)
} catch { } catch {
setGcSyncing(false) setGcSyncing(false)
} }
} }
const syncProgressPct = status => { const syncProgressPct = status => {
if (!status) return 5 if (!status) return 3
if (status.startsWith('Connecting')) return 10 if (status.startsWith('Connecting')) return 10
if (status.startsWith('Syncing activities')) return 35 if (status.startsWith('Syncing activities')) {
if (status.startsWith('Syncing wellness')) return 70 const m = status.match(/(\d+)\/(\d+)/)
return 5 if (m) {
const done = parseInt(m[1], 10), total = parseInt(m[2], 10)
if (total > 0) return 15 + Math.round(done / total * 30)
}
return 20
}
if (status.startsWith('Syncing wellness')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m) {
const done = parseInt(m[1], 10), total = parseInt(m[2], 10)
if (total > 0) return 45 + Math.round(done / total * 45)
}
return 50
}
return 3
} }
// PocketID config // PocketID config
@@ -344,8 +357,8 @@ export default function ProfilePage() {
<Field label="Sync lookback days" hint="-1 syncs all available history (back to 2010). Leave at 30 for incremental syncs."> <Field label="Sync lookback days" hint="-1 syncs all available history (back to 2010). Leave at 30 for incremental syncs.">
<Input type="number" value={gcForm.sync_lookback_days} min={-1} <Input type="number" value={gcForm.sync_lookback_days} min={-1}
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: parseInt(e.target.value, 10) || 30 }))} /> onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
{gcForm.sync_lookback_days > 365 && gcForm.sync_lookback_days !== -1 && ( {(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
<p className="text-yellow-400 text-xs mt-1">Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.</p> <p className="text-yellow-400 text-xs mt-1">Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.</p>
)} )}
</Field> </Field>
@@ -360,7 +373,10 @@ export default function ProfilePage() {
setGcError('Password is required for first-time setup') setGcError('Password is required for first-time setup')
return return
} }
const payload = { ...gcForm } const payload = {
...gcForm,
sync_lookback_days: parseInt(gcForm.sync_lookback_days, 10) || 30,
}
if (!payload.password) delete payload.password if (!payload.password) delete payload.password
saveGarmin.mutate(payload) saveGarmin.mutate(payload)
}} }}
@@ -385,19 +401,35 @@ export default function ProfilePage() {
)} )}
</div> </div>
{gcSyncing && ( {gcSyncing && (() => {
<div className="space-y-1.5"> const status = garminConfig?.last_sync_status || ''
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden"> const pct = syncProgressPct(status)
const phase = status.startsWith('Connecting') ? 0
: status.startsWith('Syncing activities') ? 1
: status.startsWith('Syncing wellness') ? 2
: status.startsWith('OK') || status.startsWith('Partial') ? 3 : -1
return (
<div className="space-y-2 pt-1">
<div className="flex items-center gap-1 text-xs">
{[['Connect', 0], ['Activities', 1], ['Wellness', 2]].map(([label, idx]) => (
<span key={label} className={`flex items-center gap-1 ${phase >= idx ? 'text-blue-400' : 'text-gray-600'}`}>
{idx > 0 && <span className="text-gray-700"></span>}
{label}
</span>
))}
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div <div
className="h-full bg-blue-500 rounded-full transition-all duration-700" className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${syncProgressPct(garminConfig?.last_sync_status)}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<p className="text-xs text-blue-400"> <p className="text-xs text-blue-400">
{garminConfig?.last_sync_status || 'Starting sync…'} {status || 'Starting sync…'}
</p> </p>
</div> </div>
)} )
})()}
</Section> </Section>
{/* PocketID — admin only */} {/* PocketID — admin only */}