diff --git a/README.md b/README.md
index 17bc9cc..add41f0 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Local web app for exploring cycling data from any source (Strava, TCX, GPX, JSON
- **Backend**: FastAPI + SQLite (metadata) + Parquet (per-ride streams)
- **Frontend**: Svelte 5 (Vite + TypeScript) with synchronized uPlot charts and MapLibre GL maps
-- **Data sources**: Strava bulk export, individual activity files (TCX/GPX/JSON), or SQLite database import/export
+- **Data sources**: Strava bulk export, one or more activity files (TCX/GPX/JSON), or SQLite database import/export
## Quick start
@@ -16,7 +16,7 @@ pip install -e backend
# 2. Ingest data (pick one)
python -m app.ingest "path/to/strava/export" # Strava bulk export
-# Or upload individual files via the UI at /import
+# Or upload one or more files via the UI
# 3. Run API
uvicorn app.main:app --reload --port 8000
@@ -37,7 +37,8 @@ docker compose up --build
## Features
-- **Multi-source ingestion** — Strava exports, individual TCX/GPX/JSON files, or full database import/export
+- **Multi-source ingestion** — Strava exports, TCX/GPX/JSON files, or full database import/export
+- **Multi-file upload** — Drag and drop multiple GPX/FIT/TCX files at once; edit each file's name and bike before uploading
- **Bike management** — Track multiple bikes with per-bike stats and ride assignment
- **Rich charts** — Synchronized time-series views (power, HR, speed, cadence, elevation, distance)
- **Interactive route map** — Color-coded routes by metric with hover synchronization
diff --git a/docs/plan-multi-upload.md b/docs/plan-multi-upload.md
new file mode 100644
index 0000000..d3460a8
--- /dev/null
+++ b/docs/plan-multi-upload.md
@@ -0,0 +1,75 @@
+# Plan — Multi-file drag-and-drop upload
+
+Feature: upload multiple ride files (`.fit/.tcx/.gpx/.gz`) at once via drag-and-drop,
+while keeping per-file **name** and **bike** editable.
+
+## Core decision (lazy-but-correct)
+**Frontend-only.** The backend `POST /api/uploads` already accepts a single file +
+`name`/`bike_id` metadata, and `recompute_all(force=False)` is incremental (it skips
+activities that already have bests), so calling the endpoint once per file in a loop is
+cheap. **No new backend endpoint.** Only `UploadDialog.svelte`, `api.ts` (tiny),
+`i18n.ts`, one new backend test, and the README change.
+
+## Open design questions
+1. **Per-row editable fields** — default: **name + bike only** (exactly the ask).
+ Type + description stay editable *after* upload via the existing per-ride edit (no regression).
+ *Alt:* add type/description per row (more clutter).
+2. **Sequential vs parallel uploads** — default: **sequential** (`for…await`).
+ Parallel would hit SQLite single-writer locks in the per-file recompute.
+ *Alt:* parallel only if a batch endpoint is added.
+3. **New batch endpoint?** — default: **none.** Only add `POST /api/uploads/batch`
+ (single recompute, atomicity) if 50+ file uploads become routine.
+
+---
+
+## Milestones
+
+### M0 — Branch setup
+- `git checkout -b feat/multi-upload` from `master` (clean tree).
+
+### M1 — Frontend: multi-file queue + drag-and-drop (core)
+- Rework `UploadDialog.svelte` into a multi-file flow:
+ - **Drop zone**: `on:dragover` (preventDefault) + `on:drop` (read `e.dataTransfer.files`);
+ hidden `` for click-to-browse.
+ - **Queue** of `{ file, name, bikeId }`; each row: filename (read-only) + **name**
+ (pre-filled from filename) + **bike** (select) + remove button.
+ - **Dedupe** by `name+size` so the same file isn't queued twice.
+ - Accept filter `.fit,.tcx,.gpx,.gz`. A single drop = a one-row queue (single upload keeps working).
+- Reuses `api.uploadRide` unchanged.
+
+### M2 — Frontend: batch submit + per-file status
+- Submit loops the queue **sequentially** → `api.uploadRide(row.file, { name, bike_id })`.
+- Per-row status (`idle/uploading/done/error`) + overall progress; summary on finish
+ (N uploaded, M failed).
+- After completion → dispatch `uploaded` → `RidesList` reloads (existing).
+- Optional: "clear all" + keep-failed affordances.
+
+### M3 — i18n + polish
+- New de + en keys: drop hint, "N files", per-row labels, statuses, errors, remove,
+ clear-all, progress.
+- Keyboard/focus: Escape closes, focus management, `aria` on drop zone + rows.
+
+### M4 — Tests
+- **Backend:** new `tests/backend/routers/test_admin.py` — upload a tiny GPX with
+ `name` + `bike_id` via `POST /api/uploads`, assert `activity_id` returned and
+ name + bike persisted (validates the exact call the frontend makes in a loop).
+ *(Currently `/api/uploads` has no test coverage.)*
+- **Frontend:** **no new test framework** (none exists; YAGNI) — the queue + loop is
+ simple and covered by manual QA. *Alt:* add vitest if wanted.
+
+### M5 — Documentation
+- **README.md**: extend the upload/ingestion feature section to state multi-file
+ drag-and-drop upload with per-file name + bike.
+- Note any new manual-QA steps in the README.
+
+### M6 — Commit strategy + handoff
+Logical commits on `feat/multi-upload` (each a coherent unit):
+1. `feat(upload): multi-file drag-and-drop queue with per-file name + bike` *(M1)*
+2. `feat(upload): sequential batch submit with per-file status + progress` *(M2)*
+3. `i18n(upload): de + en strings for multi-upload` *(M3)*
+4. `test(upload): cover name + bike metadata on POST /api/uploads` *(M4)*
+5. `docs: document multi-file upload` *(M5)*
+- Then open a PR to `master` (or merge directly).
+
+**Upgrade path (not built unless requested):** `POST /api/uploads/batch` endpoint for
+atomicity + single recompute, if large batch uploads become common.
\ No newline at end of file
diff --git a/frontend/src/lib/UploadDialog.svelte b/frontend/src/lib/UploadDialog.svelte
index 88a7396..8b98e64 100644
--- a/frontend/src/lib/UploadDialog.svelte
+++ b/frontend/src/lib/UploadDialog.svelte
@@ -1,65 +1,140 @@
@@ -73,47 +148,88 @@
tabindex="-1"
>