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
@@ -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,14 +121,28 @@ export default function SegmentsPanel({ segments, activityId }) {
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
>
<td className="py-2">
<button
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
className="text-left text-gray-300 hover:text-white"
>
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
{seg.name}
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m, unit)}</span>
</button>
{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"
>
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
{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>
+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 { 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>
+26 -1
View File
@@ -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>