diff --git a/CLAUDE.md b/CLAUDE.md index be9080c..9bc3331 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,12 +90,13 @@ 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) - `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`, `garmin_sync`, `users`, `segments` -- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `Segment`, `SegmentEffort`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it) -- `services/fit_parser.py` — parses Garmin FIT and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion +- `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile`, `garmin_sync`, `strava_sync`, `users`, `segments` +- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `Segment`, `SegmentEffort`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig`, `StravaConfig` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it) +- `services/fit_parser.py` — parses Garmin FIT, GPX, and Strava `.tcx` 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/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 +- `services/strava_sync.py` — Strava API OAuth live sync (pulls activities via streams) and bulk-export import; a shared `persist_activity` path is used by both. On dedup, existing Garmin data is preferred over Strava for the same activity - `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `detect_route`, `compute_personal_records`, `match_segment`, `match_activity_segments`, `process_garmin_health_zip`, `sync_garmin_connect_user`, `sync_all_garmin_connect` (beat-scheduled), `recalculate_hr_zones_for_user`, `backfill_moving_time`, `backfill_indoor_distances`, `recompute_personal_records_all` ### Key design decisions @@ -110,6 +111,8 @@ docker compose -f docker-compose.deploy.yml up -d **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=` and `useAuth.js` extracts the token from the URL at module load time. +**Personal records source filter**: Personal records are computed only from watch-recorded FIT activities; phone/Strava GPX/TCX imports are excluded because their GPS can "teleport" and produce bogus fast splits. Keep this filter in mind when touching `compute_personal_records` / `recompute_personal_records_all`. + ### Frontend (`frontend/src/`) - `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here @@ -121,7 +124,7 @@ docker compose -f docker-compose.deploy.yml up -d - TanStack Query (`@tanstack/react-query`) handles all server-state fetching and caching; Zustand is used only for auth, sync, and unit-preference state - `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc. - `utils/track.js` — projects a lat/lng onto a GPS track (interpolated along-line snapping, used for map hover and segment selection); `utils/bodyBattery.js` — shared Body Battery colour/state helpers used by both the Health page and Dashboard mini chart -- `pages/` — one `*Page.jsx` file per route: `Dashboard` (drag-to-edit widget grid), `Activities`, `ActivityDetail`, `Routes`, `Records`, `Health`, `Upload`, `Profile`, `Users`, `Login` +- `pages/` — one `*Page.jsx` file per route: `Dashboard` (drag-to-edit widget grid), `Activities` (type/year/date-range/distance filters + week totals), `ActivityDetail`, `Routes`, `Records`, `Health`, `Summary` (all-time and per-year/per-sport totals + distance-per-year chart), `Upload`, `Profile`, `Users`, `Login` - `components/activity/` — `ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route) - `components/health/` — `SleepHypnogram` (renders the `sleep_stages` hypnogram) - `components/ui/` — `Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards), `UnitToggle` (km/mi switch), `HrvBadge` @@ -173,6 +176,7 @@ Required in `.env` (or passed to Docker Compose): | `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | | `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group | +| `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` | Optional — enables Strava API OAuth live sync (register an app at strava.com/settings/api) | ## milevault_export/ diff --git a/backend/app/api/segments.py b/backend/app/api/segments.py index d1d99e2..b72eb58 100644 --- a/backend/app/api/segments.py +++ b/backend/app/api/segments.py @@ -22,6 +22,10 @@ class SegmentCreate(BaseModel): end_distance_m: float +class SegmentUpdate(BaseModel): + name: Optional[str] = None + + class EffortOut(BaseModel): activity_id: int activity_name: str @@ -203,6 +207,29 @@ async def get_segment( ) +@router.patch("/{segment_id}", response_model=SegmentOut) +async def update_segment( + segment_id: int, + body: SegmentUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + seg = await _own_segment(segment_id, current_user.id, db) + if body.name is not None and body.name.strip(): + seg.name = body.name.strip() + await db.commit() + await db.refresh(seg) + agg = (await db.execute( + select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s)) + .where(SegmentEffort.segment_id == seg.id) + )).one() + return SegmentOut( + id=seg.id, name=seg.name, sport_type=seg.sport_type, polyline=seg.polyline, + distance_m=seg.distance_m, created_from_activity_id=seg.created_from_activity_id, + effort_count=agg[0] or 0, best_s=agg[1], + ) + + @router.delete("/{segment_id}", status_code=204) async def delete_segment( segment_id: int, diff --git a/frontend/src/components/activity/SegmentsPanel.jsx b/frontend/src/components/activity/SegmentsPanel.jsx index bab1639..8d01a2c 100644 --- a/frontend/src/components/activity/SegmentsPanel.jsx +++ b/frontend/src/components/activity/SegmentsPanel.jsx @@ -75,6 +75,8 @@ export default function SegmentsPanel({ segments, activityId }) { const qc = useQueryClient() const unit = useUnit() const [open, setOpen] = useState(null) + const [editingId, setEditingId] = useState(null) + const [editName, setEditName] = useState('') const remove = async (id) => { if (!confirm('Delete this segment?')) return @@ -82,6 +84,20 @@ export default function SegmentsPanel({ segments, activityId }) { qc.invalidateQueries() } + const startRename = (seg) => { + setEditingId(seg.segment_id) + setEditName(seg.name) + } + const saveRename = async (id) => { + const next = editName.trim() + if (next) { + await api.patch(`/segments/${id}`, { name: next }) + qc.invalidateQueries({ queryKey: ['activity-segments', activityId] }) + qc.invalidateQueries({ queryKey: ['segment', id] }) + } + setEditingId(null) + } + return (
@@ -105,14 +121,28 @@ export default function SegmentsPanel({ segments, activityId }) { className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30" > - diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 29b058f..0f74615 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -1,4 +1,4 @@ -import { useParams } from 'react-router-dom' +import { useParams, Link } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState, useMemo } from 'react' import api from '../utils/api' @@ -41,6 +41,9 @@ export default function ActivityDetailPage() { const [editingName, setEditingName] = useState(false) const [nameInput, setNameInput] = useState('') const [nameError, setNameError] = useState('') + const [routeCreate, setRouteCreate] = useState(false) + const [routeName, setRouteName] = useState('') + const [routeError, setRouteError] = useState('') const qc = useQueryClient() const { data: activity, isLoading } = useQuery({ @@ -110,6 +113,20 @@ export default function ActivityDetailPage() { } } + const createRoute = async () => { + const name = routeName.trim() + if (!name) { setRouteError('Name cannot be empty'); return } + setRouteError('') + try { + await api.post('/routes/', { name, activity_id: Number(id) }) + setRouteCreate(false); setRouteName('') + qc.invalidateQueries({ queryKey: ['activity', id] }) + qc.invalidateQueries({ queryKey: ['routes'] }) + } catch (e) { + setRouteError(e.response?.data?.detail || 'Failed to create route') + } + } + const startRename = () => { setNameInput(activity.name) setNameError('') @@ -191,6 +208,40 @@ export default function ActivityDetailPage() {

)}

{formatDateTime(activity.start_time)}

+ {/* Named route link / create-route control */} + {activity.named_route_id ? ( +

+ 📍 {activity.named_route_name} +

+ ) : activity.polyline && activity.distance_m > 0 ? ( + routeCreate ? ( +
+ setRouteName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') createRoute() + if (e.key === 'Escape') { setRouteCreate(false); setRouteError('') } + }} + placeholder="Route name" + className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + + {routeError && {routeError}} +
+ ) : ( + + ) + ) : null} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 6966de0..921fe43 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -371,6 +371,22 @@ function FeaturedActivity({ activity, segments }) { function RecentActivities({ activities }) { const unit = useUnit() + const qc = useQueryClient() + + const createRoute = async (activity, e) => { + e.preventDefault() + e.stopPropagation() + const name = window.prompt('Name for the new route:', activity.name) + if (!name || !name.trim()) return + try { + await api.post('/routes/', { name: name.trim(), activity_id: activity.id }) + qc.invalidateQueries({ queryKey: ['routes'] }) + qc.invalidateQueries({ queryKey: ['activities-recent'] }) + } catch (err) { + alert(err.response?.data?.detail || 'Failed to create route') + } + } + return ( {activities?.length ? ( @@ -380,7 +396,7 @@ function RecentActivities({ activities }) {
{activities.slice(0, 6).map(activity => ( + className="group flex items-center gap-3 flex-1 min-h-0 overflow-hidden px-2 border-b border-gray-800/50 last:border-0 hover:bg-gray-800/30 rounded-lg transition-colors">

{activity.name}

@@ -389,6 +405,15 @@ function RecentActivities({ activities }) { )}

{formatDate(activity.start_time)}

+ {!activity.named_route_id && activity.polyline && activity.distance_m > 0 && ( + + )}

{formatDistance(activity.distance_m, unit)}

{formatHeartRate(activity.avg_heart_rate)}

- + {editingId === seg.segment_id ? ( + setEditName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') saveRename(seg.segment_id) + if (e.key === 'Escape') setEditingId(null) + }} + onBlur={() => saveRename(seg.segment_id)} + className="bg-gray-800 text-white rounded px-2 py-0.5 border border-gray-600 focus:border-blue-500 focus:outline-none text-sm" + /> + ) : ( + + )} {formatDuration(seg.duration_s)} @@ -127,7 +157,8 @@ export default function SegmentsPanel({ segments, activityId }) { ? +{formatDuration(delta)} : --} + +