feat: rename segments + create routes from an activity (detail page & dashboard recent activities)
Build and push images / validate (push) Successful in 9s
Build and push images / build-backend (push) Successful in 1m15s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 10s

This commit is contained in:
2026-06-23 11:45:19 +01:00
parent 156ce60aa0
commit 4272c5dc4c
5 changed files with 153 additions and 15 deletions
+8 -4
View File
@@ -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) - `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`, `garmin_sync`, `users`, `segments` - `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` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it) - `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 and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion - `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/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 - `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` - `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 ### 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=<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.
**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/`) ### Frontend (`frontend/src/`)
- `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here - `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 - 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/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 - `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/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/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` - `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`) | | `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_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group | | `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/ ## milevault_export/
+27
View File
@@ -22,6 +22,10 @@ class SegmentCreate(BaseModel):
end_distance_m: float end_distance_m: float
class SegmentUpdate(BaseModel):
name: Optional[str] = None
class EffortOut(BaseModel): class EffortOut(BaseModel):
activity_id: int activity_id: int
activity_name: str 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) @router.delete("/{segment_id}", status_code=204)
async def delete_segment( async def delete_segment(
segment_id: int, segment_id: int,
@@ -75,6 +75,8 @@ export default function SegmentsPanel({ segments, activityId }) {
const qc = useQueryClient() const qc = useQueryClient()
const unit = useUnit() const unit = useUnit()
const [open, setOpen] = useState(null) const [open, setOpen] = useState(null)
const [editingId, setEditingId] = useState(null)
const [editName, setEditName] = useState('')
const remove = async (id) => { const remove = async (id) => {
if (!confirm('Delete this segment?')) return if (!confirm('Delete this segment?')) return
@@ -82,6 +84,20 @@ export default function SegmentsPanel({ segments, activityId }) {
qc.invalidateQueries() 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 ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -105,6 +121,19 @@ export default function SegmentsPanel({ segments, activityId }) {
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30" className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
> >
<td className="py-2"> <td className="py-2">
{editingId === seg.segment_id ? (
<input
autoFocus
value={editName}
onChange={e => 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"
/>
) : (
<button <button
onClick={() => setOpen(isOpen ? null : seg.segment_id)} onClick={() => setOpen(isOpen ? null : seg.segment_id)}
className="text-left text-gray-300 hover:text-white" className="text-left text-gray-300 hover:text-white"
@@ -113,6 +142,7 @@ export default function SegmentsPanel({ segments, activityId }) {
{seg.name} {seg.name}
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m, unit)}</span> <span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m, unit)}</span>
</button> </button>
)}
</td> </td>
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}> <td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{formatDuration(seg.duration_s)} {formatDuration(seg.duration_s)}
@@ -127,7 +157,8 @@ export default function SegmentsPanel({ segments, activityId }) {
? <span className="text-gray-500">+{formatDuration(delta)}</span> ? <span className="text-gray-500">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>} : <span className="text-gray-700">--</span>}
</td> </td>
<td className="py-2 text-right"> <td className="py-2 text-right whitespace-nowrap">
<button onClick={() => startRename(seg)} className="text-gray-700 hover:text-white text-xs mr-2" title="Rename segment"></button>
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button> <button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button>
</td> </td>
</tr> </tr>
+52 -1
View File
@@ -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 { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import api from '../utils/api' import api from '../utils/api'
@@ -41,6 +41,9 @@ export default function ActivityDetailPage() {
const [editingName, setEditingName] = useState(false) const [editingName, setEditingName] = useState(false)
const [nameInput, setNameInput] = useState('') const [nameInput, setNameInput] = useState('')
const [nameError, setNameError] = useState('') const [nameError, setNameError] = useState('')
const [routeCreate, setRouteCreate] = useState(false)
const [routeName, setRouteName] = useState('')
const [routeError, setRouteError] = useState('')
const qc = useQueryClient() const qc = useQueryClient()
const { data: activity, isLoading } = useQuery({ 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 = () => { const startRename = () => {
setNameInput(activity.name) setNameInput(activity.name)
setNameError('') setNameError('')
@@ -191,6 +208,40 @@ export default function ActivityDetailPage() {
</p> </p>
)} )}
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p> <p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
{/* Named route link / create-route control */}
{activity.named_route_id ? (
<p className="text-sm text-blue-400 mt-1">
📍 <Link to="/routes" className="hover:underline">{activity.named_route_name}</Link>
</p>
) : activity.polyline && activity.distance_m > 0 ? (
routeCreate ? (
<div className="flex flex-wrap items-center gap-2 mt-2">
<input
autoFocus
value={routeName}
onChange={e => 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"
/>
<button onClick={createRoute} disabled={!routeName.trim()}
className="text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white px-3 py-1 rounded-lg">Create route</button>
<button onClick={() => { setRouteCreate(false); setRouteError('') }}
className="text-sm text-gray-400 hover:text-white px-1">Cancel</button>
{routeError && <span className="text-xs text-red-400">{routeError}</span>}
</div>
) : (
<button
onClick={() => { setRouteName(activity.name); setRouteCreate(true); setRouteError('') }}
className="text-sm text-gray-500 hover:text-blue-400 mt-1 transition-colors"
>
+ Create route from this activity
</button>
)
) : null}
</div> </div>
</div> </div>
+26 -1
View File
@@ -371,6 +371,22 @@ function FeaturedActivity({ activity, segments }) {
function RecentActivities({ activities }) { function RecentActivities({ activities }) {
const unit = useUnit() 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 ( return (
<Card title="Recent activities" viewHref="/activities"> <Card title="Recent activities" viewHref="/activities">
{activities?.length ? ( {activities?.length ? (
@@ -380,7 +396,7 @@ function RecentActivities({ activities }) {
<div className="h-full flex flex-col overflow-hidden"> <div className="h-full flex flex-col overflow-hidden">
{activities.slice(0, 6).map(activity => ( {activities.slice(0, 6).map(activity => (
<Link key={activity.id} to={`/activities/${activity.id}`} <Link key={activity.id} to={`/activities/${activity.id}`}
className="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"> 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">
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} className="shrink-0" /> <SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} className="shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{activity.name}</p> <p className="text-sm font-medium text-white truncate">{activity.name}</p>
@@ -389,6 +405,15 @@ function RecentActivities({ activities }) {
)} )}
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p> <p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
</div> </div>
{!activity.named_route_id && activity.polyline && activity.distance_m > 0 && (
<button
onClick={e => createRoute(activity, e)}
title="Create route from this activity"
className="shrink-0 text-gray-600 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity text-sm px-1"
>
📍+
</button>
)}
<div className="text-right text-sm shrink-0"> <div className="text-right text-sm shrink-0">
<p className="text-gray-200">{formatDistance(activity.distance_m, unit)}</p> <p className="text-gray-200">{formatDistance(activity.distance_m, unit)}</p>
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p> <p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>