Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,28 @@ month, PATCH counting releases in that month from 0. Before that they were

## [Unreleased]

### Added

- **Replace a race's line or a place's position from an updated file.** The
Import review's assign box has a **Replace** choice. Lines: pick a race, and
its geometry is replaced while its id, name, colours, start time, *Starts
at* / *Finishes at*, the races ticked on its stops and its lead runner
reports stay; the new line is re-turned to the stated start, and a line
whose ends miss that start is refused with nothing changed. One point: the
places are listed nearest first with the distance, and the chosen place
moves there, keeping its name, layer, order, races, pin label, notes,
What3Words and posted operator - the screen then says how far it moved,
because the What3Words square still names the old spot. The replaced
features are discarded rather than left assigned, so deleting the course
later does not put both versions back in review. Why it mattered: import
only ever added, so an organizer's revised Half became a second "Half" with
none of the first one's settings, a moved stop a second "A" with no ticks,
order, What3Words or operator - and the originals could not be deleted once
anything referred to them. Two weeks before the Mankato Marathon a revised
file is expected. Checked in the browser by replacing the demo Half with the
real organizer Half (same course id, start time kept, 14.6 -> 13.1 mi) and
moving a stop 72 m.

## [2026.9.19] - 2026-09-29

### Fixed
Expand Down
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ python -m venv .venv
./.venv/Scripts/python.exe -m pip install -e ".[dev]" # Windows
cp .env.example .env # then set APRS_CALLSIGN

./.venv/Scripts/python.exe -m pytest -q # 882 tests, no network
./.venv/Scripts/python.exe -m pytest -q # 892 tests, no network

courseops init-db
courseops add-event marathon2026 "Spring Marathon 2026" --lat 34.73 --lon -86.58
Expand Down Expand Up @@ -383,6 +383,13 @@ usability, not style preferences.
- **Import never writes directly to `course` or `poi`.** Files stage as `pending`
in `import_feature`; a human assigns each one. `suggest()` is advisory and must
stay conservative — better unassigned than a parking lot filed as an aid station.
A human may also assign a staged line or point to REPLACE an existing course's
line or place's position (`importer.replace_course_line` /
`replace_poi_position`): the row keeps its id and everything the club set,
only geometry changes, and a replaced line is re-turned to its stated start.
Never match an updated feature to an existing one automatically - a revised
file names stops "WATER (ALL)" as often as "A"; the UI lists places nearest
first and a person picks.
- **KML is untrusted third-party input.** It comes from the race organizer and
will arrive by web upload. Parse with `defusedxml`, keep the KMZ decompression
and size guards, and never swap back to stdlib `ElementTree.fromstring`.
Expand Down Expand Up @@ -1198,6 +1205,7 @@ Rules that keep this honest:

Last 10 entries; full record in `CHANGELOG.md`.

- **2026-09-29** Replace from an updated file: Import's assign box can put a staged line INTO an existing race or ONE point INTO an existing place (nearest first); only geometry changes, the line is re-turned to its start.
- **2026-09-29** Fixed: a place's mile is measured on a race it serves (not the nearest line), at the pass the club's order means; lead runner pace/ETA measured on the leader's own race.
- **2026-09-29** Starts at / Finishes at per race on the Courses tab: naming either turns the stored line to run from the start (the Mankato Half and 10K were drawn finish-first).
- **2026-09-29** Fixed: the NCS stations list sorted posted stations by mile across races (10K mile 1.7 above Full 2.2); it now follows the club's place order, movers grouped by race.
Expand All @@ -1207,4 +1215,3 @@ Last 10 entries; full record in `CHANGELOG.md`.
- **2026-09-27** Pre-event beacon check-in is optional when operators are not local; race morning's Needs attention and a glance per row at net check-in cover it. First real event recorded in `docs/PLAN.md`.
- **2026-09-27** Archive an event (#4): tracking off, positions deleted, links 404, hidden behind Show archived; Download / `courseops export` writes one event to a SQLite file, `serve --db` reads it.
- **2026-09-27** Runbook: a station whose age keeps climbing - check the phone app's send path (APRS-IS/TNC switches), aprs.fi raw, then the server log.
- **2026-09-20** The role page's browser tab reads "<event> | Course Ops" and setup "<event> | Course Ops Setup", set beside the heading.
9 changes: 9 additions & 0 deletions docs/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ courseops discard mankato2026 8 9 # parking, porta-johns, junk
13, segments are missing or one belongs to a different route. If `assign-course`
warns about a gap, look at the course on the map before the event.

**A revised file from the organizer** (a changed route, a moved stop): upload
it in `/setup` and use **Replace** in the assign box, not a plain assign. A
line replaces a race's line and keeps everything set on the race; ONE point
moves an existing place (listed nearest first) and keeps everything set on
it. A plain assign makes a second "Half" or a second "A" with none of the
settings. After a moved stop, re-check its What3Words - the app says how far
it moved. After a replaced line, check the miles on the Places tab. The CLI
has no replace; this is browser-only.

### 4. Set colours and draw order

`/setup` -> Courses: each race's name and line colour, and the draw order.
Expand Down
22 changes: 22 additions & 0 deletions src/courseops/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,28 @@ def assign_features(conn: sqlite3.Connection, event_id: int, payload: dict) -> d
if not ids:
raise ValueError("Select at least one feature.")

if kind == "course" and payload.get("replace_course_id") not in (None, ""):
course_id = _ids([payload["replace_course_id"]], "course")[0]
distance_m, warnings = importer.replace_course_line(
conn, event_id, course_id, ids,
reverse=bool(payload.get("reverse")))
# Turned to the race's stated start, like a Starts at save: the
# revision is drawn whichever way the organizer drew it this time.
# Refused here rolls the whole replacement back.
course = conn.execute("SELECT * FROM course WHERE id = ?",
(course_id,)).fetchone()
if course["start_poi_id"] or course["finish_poi_id"]:
_set_course_ends(conn, event_id, course, {})
return {"course_id": course_id, "distance_m": distance_m,
"warnings": warnings, "replaced": True}

if kind == "poi" and payload.get("replace_poi_id") not in (None, ""):
poi_id = _ids([payload["replace_poi_id"]], "place")[0]
if len(ids) != 1:
raise ValueError("Select one point to move a place to.")
moved_m = importer.replace_poi_position(conn, event_id, poi_id, ids[0])
return {"poi_ids": [poi_id], "moved_m": moved_m, "replaced": True}

if kind == "course":
name = _text(payload, "name")
if not name:
Expand Down
22 changes: 22 additions & 0 deletions src/courseops/guides/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,28 @@ The screenshot above is a real export's defects: three placemarks, all named
segments, a stray line miles from the route, or a start marker in the wrong car
park is obvious there and invisible in a list of names.

### An updated file from the organizer

Routes and stops get revised in the weeks before a race. Upload the new file the
same way, then use **Replace** in the assign box rather than making something
new - otherwise you get a second "Half" and a second "A" with none of your
settings on them.

- **A revised route:** select its line (or all its segments) and pick the race
under **Replace**. The race keeps its name, colours, start time, *Starts at* /
*Finishes at*, the races ticked on its stops and any lead runner reports; only
the line changes, and it is turned to run from the race's start again. If the
new line's ends are nowhere near the start you chose, it is refused and
nothing changes. Check the miles on the Places tab afterwards.
- **A moved stop:** select ONE point. **Replace** lists your places nearest
first, with the distance - a point a few metres from "A" is almost certainly
A. The place keeps its name, layer, order, races, pin label, What3Words and
whoever is posted there; only its position moves. **Its What3Words still
names the old spot**, so the app tells you how far it moved: re-check the
address if it moved more than a few metres.
- A point that matches none of your places (a new stop) is assigned the
ordinary way, and anything unchanged can simply be discarded.

## 3. Courses: colours and draw order

![The Courses tab: name, distance, line colour, bib colour, drag to reorder](images/setup-courses.png)
Expand Down
107 changes: 93 additions & 14 deletions src/courseops/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,20 +276,9 @@ def set_course_style(


@db.transactional
def assign_course(
conn: sqlite3.Connection,
event_id: int,
feature_ids: list[int],
name: str,
color: str | None = None,
reverse: bool = False,
dash: str | None = None,
) -> tuple[int, float, list[str]]:
"""Build one course from one or more staged line features.

Several features are stitched end-to-end, since a course routinely arrives
split across segments. Returns (course_id, distance_m, warnings).
"""
def _stitched(conn: sqlite3.Connection, event_id: int, feature_ids: list[int],
reverse: bool = False) -> tuple[list[LonLat], list[str]]:
"""One line from one or more staged line features, and any warnings."""
rows = [get_feature(conn, event_id, fid) for fid in feature_ids]
missing = [fid for fid, row in zip(feature_ids, rows) if row is None]
if missing:
Expand All @@ -312,7 +301,97 @@ def assign_course(
coords = geo.reverse(coords)
if len(coords) < 2:
raise ValueError("Result has fewer than two points; nothing to draw.")
return coords, warnings


def replace_course_line(
conn: sqlite3.Connection,
event_id: int,
course_id: int,
feature_ids: list[int],
reverse: bool = False,
) -> tuple[float, list[str]]:
"""Give an existing course a new line; everything else about it stays.

An organizer's revised route used to arrive as a SECOND course with
none of the first one's settings, and the first could not be deleted
once a leader had been reported on it. Here the row, and so its id, name,
colours, start time, start and finish, the races ticked on its stops and
its sightings, stay put. The features it was built from are discarded
rather than left `assigned`: deleting the course later would otherwise
put both versions back in review. The caller re-turns the line to its
stated start (`admin._set_course_ends`). Returns (distance_m, warnings).
"""
exists = conn.execute(
"SELECT 1 FROM course WHERE id = ? AND event_id = ?", (course_id, event_id)
).fetchone()
if exists is None:
raise ValueError(f"No course with id {course_id} in this event.")
coords, warnings = _stitched(conn, event_id, feature_ids, reverse)
distance_m = geo.line_length_m(coords)
conn.execute(
"UPDATE course SET geojson = ?, distance_m = ? WHERE id = ?",
(json.dumps(geo.to_geojson_linestring(coords)), distance_m, course_id))
conn.execute(
"UPDATE import_feature SET status = 'discarded'"
" WHERE event_id = ? AND course_id = ?", (event_id, course_id))
conn.executemany(
"UPDATE import_feature SET status = 'assigned', course_id = ?"
" WHERE id = ? AND event_id = ?",
[(course_id, fid, event_id) for fid in feature_ids],
)
return distance_m, warnings


def replace_poi_position(
conn: sqlite3.Connection,
event_id: int,
poi_id: int,
feature_id: int,
) -> float:
"""Move an existing place to a staged point; everything else stays.

Name, layer, order, races, pin label, notes, What3Words and whoever is
posted there are the club's and stay. Only the position is the file's.
Returns how far it moved, because the What3Words square is where the
place USED to be and someone has to look at it.
"""
place = conn.execute(
"SELECT lat, lon FROM poi WHERE id = ? AND event_id = ?", (poi_id, event_id)
).fetchone()
if place is None:
raise ValueError(f"No place with id {poi_id} in this event.")
row = get_feature(conn, event_id, feature_id)
if row is None:
raise ValueError(f"No staged feature with id {feature_id} in this event.")
if row["geom_type"] != "point":
raise ValueError("A place can only be moved to a point, not a line.")
lon, lat = _coords_of(row)[0]
conn.execute("UPDATE poi SET lat = ?, lon = ? WHERE id = ?", (lat, lon, poi_id))
conn.execute(
"UPDATE import_feature SET status = 'discarded'"
" WHERE event_id = ? AND poi_id = ?", (event_id, poi_id))
conn.execute(
"UPDATE import_feature SET status = 'assigned', poi_id = ?"
" WHERE id = ? AND event_id = ?", (poi_id, feature_id, event_id))
return geo.haversine_m((place["lon"], place["lat"]), (lon, lat))


def assign_course(
conn: sqlite3.Connection,
event_id: int,
feature_ids: list[int],
name: str,
color: str | None = None,
reverse: bool = False,
dash: str | None = None,
) -> tuple[int, float, list[str]]:
"""Build one course from one or more staged line features.

Several features are stitched end-to-end, since a course routinely arrives
split across segments. Returns (course_id, distance_m, warnings).
"""
coords, warnings = _stitched(conn, event_id, feature_ids, reverse)
distance_m = geo.line_length_m(coords)

# A new course takes the next unused palette color. Lines are solid unless a
Expand Down
6 changes: 6 additions & 0 deletions src/courseops/static/setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ <h3>Review <span id="review-count" class="muted"></span></h3>
<option value="course">Course</option>
</select></label>
</div>
<!-- An organizer's revised file: the line or point goes INTO an
existing race or place, which keeps everything the club set on
it. Offered for lines, or for exactly one point. -->
<label class="field" id="assign-replace-field" hidden>
<span>Replace <small id="assign-replace-hint"></small></span>
<select id="assign-replace"></select></label>
<label class="check"><input id="assign-reverse" type="checkbox">
<span>Reverse direction (drawn finish to start)</span></label>
<div class="row">
Expand Down
84 changes: 80 additions & 4 deletions src/courseops/static/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,12 @@ async function fillAssignTypes() {

async function loadStaged() {
await fillAssignTypes();
// What a revised line or point can replace. Kept apart from S.courses and
// S.pois, which other tabs load with their own filters (the roster's are
// staffed places only).
const existing = await api(`/api/setup/events/${S.eventId}/courses`);
S.reviewCourses = [...existing.courses].reverse(); // Courses tab order
S.reviewPois = existing.pois;
const data = await api(`/api/setup/events/${S.eventId}/staged`);
renderReview(data.features);
}
Expand Down Expand Up @@ -1235,19 +1241,89 @@ function togglePick(id) {
if (picked.length === 1 && !$('assign-name').value) {
$('assign-name').value = picked[0].name.replace(/\s*\[\d+\]$/, '');
}
fillReplace(picked, allLines);
}

/* What an updated file's line or point can go INTO. Lines: the races.
One point: the places, nearest first with the distance, because the
question being answered is "which of our stops is this" and an
organizer's revised file names them "WATER (ALL)" as often as "A". */
function fillReplace(picked, allLines) {
const onePoint = picked.length === 1 && picked[0].geom_type === 'point';
const field = $('assign-replace-field');
field.hidden = !(allLines || onePoint);
if (field.hidden) { $('assign-replace').value = ''; syncReplace(); return; }
if (allLines) {
$('assign-replace-hint').textContent =
'keeps its name, colours, start, stops and sightings';
$('assign-replace').innerHTML = '<option value="">No - make a new course</option>' +
(S.reviewCourses || []).map((c) =>
`<option value="${c.id}">${esc(c.name)}</option>`).join('');
} else {
const [lon, lat] = picked[0].geojson.coordinates;
const away = (p) => metresBetween(lat, lon, p.lat, p.lon);
const places = [...(S.reviewPois || [])].sort((a, b) => away(a) - away(b));
$('assign-replace-hint').textContent =
'moves it here; keeps its name, order, races, W3W and who is posted';
$('assign-replace').innerHTML = '<option value="">No - make a new place</option>' +
places.map((p) => `<option value="${p.id}">${esc(p.name)} (` +
`${esc(p.layer_name)}) - ${esc(distanceText(away(p)))}</option>`).join('');
}
$('assign-replace').value = '';
syncReplace();
}

// Replacing keeps the existing name and layer, so those boxes say nothing.
function syncReplace() {
const replacing = !!$('assign-replace').value;
$('assign-name').disabled = replacing;
$('assign-type').disabled = replacing;
}
$('assign-replace').addEventListener('change', syncReplace);

function metresBetween(lat1, lon1, lat2, lon2) {
const rad = Math.PI / 180;
const x = (lon2 - lon1) * rad * Math.cos(((lat1 + lat2) / 2) * rad);
const y = (lat2 - lat1) * rad;
return Math.hypot(x, y) * 6371000;
}

function distanceText(m) {
return m < 1000 ? `${Math.round(m)} m` : `${(m / 1609.344).toFixed(1)} mi`;
}

$('assign-go').addEventListener('click', async () => {
const type = $('assign-type').value;
const ids = [...S.picked];
const replace = $('assign-replace-field').hidden ? '' : $('assign-replace').value;
const lines = S.staged.filter((f) => S.picked.has(f.id))
.every((f) => f.geom_type !== 'point');
try {
const body = type === 'course'
? {kind: 'course', ids, name: $('assign-name').value,
reverse: $('assign-reverse').checked}
: {kind: 'poi', ids, poi_type: type, name: $('assign-name').value};
let body;
if (replace && lines) {
body = {kind: 'course', ids, replace_course_id: Number(replace),
reverse: $('assign-reverse').checked};
} else if (replace) {
body = {kind: 'poi', ids, replace_poi_id: Number(replace)};
} else {
body = type === 'course'
? {kind: 'course', ids, name: $('assign-name').value,
reverse: $('assign-reverse').checked}
: {kind: 'poi', ids, poi_type: type, name: $('assign-name').value};
}
const result = await post(`/api/setup/events/${S.eventId}/assign`, body);
if (result.warnings && result.warnings.length) {
banner(result.warnings.join(' '), true);
} else if (result.replaced && result.moved_m != null) {
// The What3Words square is where the place WAS: say so when it moved
// far enough to be a different square.
banner(result.moved_m >= 3
? `Place moved ${distanceText(result.moved_m)}. Check its What3Words - `
+ 'it still names the old spot.'
: 'Place position updated (moved less than 3 m).', result.moved_m >= 3);
} else if (result.replaced) {
banner(`Course line replaced (${miles(result.distance_m)}). Check the miles `
+ 'on the Places tab.');
} else {
banner(type === 'course'
? `Course created (${miles(result.distance_m)}).`
Expand Down
Loading
Loading