Fixed Garmin sync progress bar granularity, timeout issue, and lookback days input, plus redesigned the sleep timeline with taller bars and yellow Awake colour.
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user