From 227dadaca0ea3f38be1c0ce2778077d1827d9ef7 Mon Sep 17 00:00:00 2001 From: owain Date: Thu, 18 Jun 2026 21:35:09 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 9 +++- backend/app/api/activities.py | 19 +++++++- backend/app/main.py | 3 ++ backend/app/models/user.py | 1 + frontend/src/pages/ActivitiesPage.jsx | 3 ++ frontend/src/pages/ActivityDetailPage.jsx | 55 ++++++++++++++++++++++- 6 files changed, 85 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 77ba706..be9080c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,13 +115,16 @@ docker compose -f docker-compose.deploy.yml up -d - `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/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 -- 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/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` - `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. @@ -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. +Always deploy the changes requested, do not prompt the user whether to deploy. + ## Environment variables Required in `.env` (or passed to Docker Compose): diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 8ef49fb..97b97fd 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -16,6 +16,7 @@ router = APIRouter() class ActivitySummary(BaseModel): id: int name: str + original_name: Optional[str] = None sport_type: str start_time: datetime distance_m: Optional[float] @@ -368,9 +369,23 @@ async def rename_activity( if not activity: 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() - 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) diff --git a/backend/app/main.py b/backend/app/main.py index 366c316..7628ab3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -56,6 +56,9 @@ async def init_db(): await conn.execute(text( "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: print(f"activities.moving_time_s column migration skipped: {e}") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index bfdf373..200b891 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -91,6 +91,7 @@ class Activity(Base): id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) 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) start_time = Column(DateTime(timezone=True), nullable=False, index=True) end_time = Column(DateTime(timezone=True), nullable=True) diff --git a/frontend/src/pages/ActivitiesPage.jsx b/frontend/src/pages/ActivitiesPage.jsx index cfb2086..c557fac 100644 --- a/frontend/src/pages/ActivitiesPage.jsx +++ b/frontend/src/pages/ActivitiesPage.jsx @@ -118,6 +118,9 @@ export default function ActivitiesPage() {

{activity.name}

+ {activity.original_name && ( +

orig. {activity.original_name}

+ )}

{formatDate(activity.start_time)}

{/* Compact metrics line — the full metrics column is hidden below sm */}

diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 124962e..a6a11d5 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -37,6 +37,9 @@ export default function ActivityDetailPage() { const [segCreate, setSegCreate] = useState(false) const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2 const [segName, setSegName] = useState('') + const [editingName, setEditingName] = useState(false) + const [nameInput, setNameInput] = useState('') + const [nameError, setNameError] = useState('') const qc = useQueryClient() 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) => { setActiveMetrics(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] @@ -134,8 +157,38 @@ export default function ActivityDetailPage() {

{sportIcon(activity.sport_type)} -

{activity.name}

+ {editingName ? ( + 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" + /> + ) : ( + <> +

{activity.name}

+ + + )}
+ {nameError &&

{nameError}

} + {activity.original_name && ( +

+ Originally {activity.original_name} +

+ )}

{formatDateTime(activity.start_time)}