feat: rename activities; keep original Garmin title as a tag
Add Activity.original_name (migrated via init_db ALTER). The rename endpoint stores the import title the first time a user renames and clears it if renamed back. Activity detail page gets inline rename (pencil) UI and shows the original title beneath the new name; the activities list shows it as a small tag too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -115,13 +115,16 @@ 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
|
||||||
- `hooks/useSync.js` — Zustand store polling Garmin sync status; maps backend status strings to progress percentages
|
- `hooks/useSync.js` — Zustand store polling Garmin sync status; maps backend status strings to progress percentages
|
||||||
|
- `hooks/useUnits.js` — Zustand store for the global km/mi display preference (`useUnit()` subscribes to the active unit). Distances are stored canonically; the unit only affects display, converted on the fly by the `format.js` helpers. Persisted to `localStorage`. Surfaced in the nav via `components/ui/UnitToggle.jsx`
|
||||||
|
- `hooks/useMediaQuery.js` — responsive breakpoint hook (md=768px split); the dashboard widget grid must be conditionally mounted, not just CSS-hidden, on mobile
|
||||||
- `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler
|
- `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler
|
||||||
- TanStack Query (`@tanstack/react-query`) handles all server-state fetching and caching; Zustand is used only for auth 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`, `ActivityDetail`, `Routes`, `Records`, `Health`, `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/ui/` — `Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards)
|
- `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`
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -147,6 +150,8 @@ curl -s https://gitea.jarrett.eu/api/v1/repos/owain/MileVault/actions/tasks
|
|||||||
```
|
```
|
||||||
The runner builds images on-host, so local image digests won't match the registry — verify a deploy landed by `docker exec`-ing into the running container and grepping the changed source, not by comparing digests.
|
The runner builds images on-host, so local image digests won't match the registry — verify a deploy landed by `docker exec`-ing into the running container and grepping the changed source, not by comparing digests.
|
||||||
|
|
||||||
|
Always deploy the changes requested, do not prompt the user whether to deploy.
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
Required in `.env` (or passed to Docker Compose):
|
Required in `.env` (or passed to Docker Compose):
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ router = APIRouter()
|
|||||||
class ActivitySummary(BaseModel):
|
class ActivitySummary(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
original_name: Optional[str] = None
|
||||||
sport_type: str
|
sport_type: str
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
distance_m: Optional[float]
|
distance_m: Optional[float]
|
||||||
@@ -368,9 +369,23 @@ async def rename_activity(
|
|||||||
if not activity:
|
if not activity:
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
activity.name = body.get("name", activity.name)
|
new_name = (body.get("name") or "").strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Name cannot be empty")
|
||||||
|
|
||||||
|
# Preserve the original import title the first time the user renames, so the
|
||||||
|
# UI can still show it as a tag. Subsequent renames keep that same original.
|
||||||
|
if activity.original_name is None and new_name != activity.name:
|
||||||
|
activity.original_name = activity.name
|
||||||
|
|
||||||
|
activity.name = new_name
|
||||||
|
|
||||||
|
# If the user renames back to the original title, drop the tag.
|
||||||
|
if activity.original_name == activity.name:
|
||||||
|
activity.original_name = None
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"id": activity_id, "name": activity.name}
|
return {"id": activity_id, "name": activity.name, "original_name": activity.original_name}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{activity_id}", status_code=204)
|
@router.delete("/{activity_id}", status_code=204)
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ async def init_db():
|
|||||||
await conn.execute(text(
|
await conn.execute(text(
|
||||||
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT"
|
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT"
|
||||||
))
|
))
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS original_name VARCHAR(256)"
|
||||||
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"activities.moving_time_s column migration skipped: {e}")
|
print(f"activities.moving_time_s column migration skipped: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class Activity(Base):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
name = Column(String(256), nullable=False)
|
name = Column(String(256), nullable=False)
|
||||||
|
original_name = Column(String(256), nullable=True) # Garmin/import title, kept when user renames
|
||||||
sport_type = Column(String(64), nullable=False)
|
sport_type = Column(String(64), nullable=False)
|
||||||
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
end_time = Column(DateTime(timezone=True), nullable=True)
|
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export default function ActivitiesPage() {
|
|||||||
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
|
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
|
||||||
{activity.name}
|
{activity.name}
|
||||||
</p>
|
</p>
|
||||||
|
{activity.original_name && (
|
||||||
|
<p className="text-xs text-gray-600 truncate">orig. {activity.original_name}</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
||||||
{/* Compact metrics line — the full metrics column is hidden below sm */}
|
{/* Compact metrics line — the full metrics column is hidden below sm */}
|
||||||
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export default function ActivityDetailPage() {
|
|||||||
const [segCreate, setSegCreate] = useState(false)
|
const [segCreate, setSegCreate] = useState(false)
|
||||||
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
|
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
|
||||||
const [segName, setSegName] = useState('')
|
const [segName, setSegName] = useState('')
|
||||||
|
const [editingName, setEditingName] = useState(false)
|
||||||
|
const [nameInput, setNameInput] = useState('')
|
||||||
|
const [nameError, setNameError] = useState('')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: activity, isLoading } = useQuery({
|
const { data: activity, isLoading } = useQuery({
|
||||||
@@ -106,6 +109,26 @@ export default function ActivityDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startRename = () => {
|
||||||
|
setNameInput(activity.name)
|
||||||
|
setNameError('')
|
||||||
|
setEditingName(true)
|
||||||
|
}
|
||||||
|
const saveName = async () => {
|
||||||
|
const next = nameInput.trim()
|
||||||
|
if (!next) { setNameError('Name cannot be empty'); return }
|
||||||
|
if (next === activity.name) { setEditingName(false); return }
|
||||||
|
setNameError('')
|
||||||
|
try {
|
||||||
|
await api.patch(`/activities/${id}/name`, { name: next })
|
||||||
|
setEditingName(false)
|
||||||
|
qc.invalidateQueries({ queryKey: ['activity', id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['activities'] })
|
||||||
|
} catch (e) {
|
||||||
|
setNameError(e.response?.data?.detail || 'Failed to rename')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleMetric = (key) => {
|
const toggleMetric = (key) => {
|
||||||
setActiveMetrics(prev =>
|
setActiveMetrics(prev =>
|
||||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
@@ -134,8 +157,38 @@ export default function ActivityDetailPage() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
|
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
|
||||||
|
{editingName ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={nameInput}
|
||||||
|
onChange={e => setNameInput(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') saveName()
|
||||||
|
// Reset to current name so the onBlur save becomes a no-op.
|
||||||
|
if (e.key === 'Escape') { setNameInput(activity.name); setEditingName(false) }
|
||||||
|
}}
|
||||||
|
onBlur={saveName}
|
||||||
|
className="text-2xl font-bold bg-gray-800 text-white rounded px-2 py-0.5 border border-gray-600 focus:border-blue-500 focus:outline-none min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<h1 className="text-2xl font-bold text-white break-words min-w-0">{activity.name}</h1>
|
<h1 className="text-2xl font-bold text-white break-words min-w-0">{activity.name}</h1>
|
||||||
|
<button
|
||||||
|
onClick={startRename}
|
||||||
|
title="Rename activity"
|
||||||
|
className="text-gray-500 hover:text-white transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{nameError && <p className="text-xs text-red-400 mb-1">{nameError}</p>}
|
||||||
|
{activity.original_name && (
|
||||||
|
<p className="text-xs text-gray-500 mb-1">
|
||||||
|
Originally <span className="text-gray-400">{activity.original_name}</span>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user