feat: rename segments + create routes from an activity (detail page & dashboard recent activities)
This commit is contained in:
@@ -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=<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/`)
|
||||
|
||||
- `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/
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
|
||||
className="text-left text-gray-300 hover:text-white"
|
||||
@@ -113,6 +142,7 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
{seg.name}
|
||||
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m, unit)}</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||
{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-700">--</span>}
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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() {
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Card title="Recent activities" viewHref="/activities">
|
||||
{activities?.length ? (
|
||||
@@ -380,7 +396,7 @@ function RecentActivities({ activities }) {
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{activities.slice(0, 6).map(activity => (
|
||||
<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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
</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">
|
||||
<p className="text-gray-200">{formatDistance(activity.distance_m, unit)}</p>
|
||||
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||
|
||||
Reference in New Issue
Block a user