Add a "user overlay" tool: click the map, save a note

New map-corner "Add note" control (public/js/notes.js), mirroring
measure.js's click-to-place UX. Click the map, write a short note,
pick an existing .geojson layer file or name a new one — saved via a
new GpxStore#append_feature (grows the target file in place, unlike
save()'s always-disambiguate-the-filename behavior) through a new
POST /api/gpx/notes route.

No new rendering path: a note is just another point in a .geojson
file the Tracks panel's tree already lists, checkboxes, and pops up —
visibility toggling ("layers") comes for free from that existing
machinery, per-file, with no changes needed there.

Points only for now — a polygon mode would need gpx.js's point/line
split taught a third Polygon bucket first, or a saved polygon
wouldn't render back on reload (see TODO.adoc).

Covered by new tests: GpxStore#append_feature (create/append/missing-
path/filename-safety) and the /api/gpx/notes route (auth gate, create,
append, malformed/missing-geometry rejection, 404 on a missing
target). Full suite: 97 runs, 0 failures.
This commit is contained in:
randy committed 2026-09-09 10:18:50 +02:00
1 parent 5caf865fc8
commit aa2b96ac70
11 files changed
+437 -2

No files matched your search

+49
View File
@@ -176,6 +176,47 @@ class AppTest < Minitest::Test
assert_equal 400, response.status
end
# --- Notes (hand-drawn points, notes.js) ---
def test_notes_requires_login
assert_equal 401, request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "Notes" }).status
end
def test_notes_creates_a_new_layer_file
response = request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "My Notes" }, **logged_in_env)
assert_equal 200, response.status
body = json_body(response)
assert_equal "My Notes.geojson", body["path"]
assert_equal ["My Notes.geojson"], body["tree"]["children"].map { |c| c["name"] }
end
def test_notes_appends_to_an_existing_layer_file_instead_of_disambiguating
first = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot"), name: "My Notes" }, **logged_in_env))
second = request.post("/api/gpx/notes", params: { feature: sample_note_feature("Narrow gate"), path: first["path"] }, **logged_in_env)
assert_equal 200, second.status
assert_equal first["path"], json_body(second)["path"]
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(first["path"])}"))["features"]
assert_equal ["Turning spot", "Narrow gate"], features.map { |f| f["properties"]["name"] }
end
def test_notes_rejects_malformed_feature_json
response = request.post("/api/gpx/notes", params: { feature: "not json", name: "Notes" }, **logged_in_env)
assert_equal 400, response.status
end
def test_notes_rejects_a_feature_with_no_geometry
response = request.post("/api/gpx/notes", params: { feature: '{"type":"Feature","properties":{}}', name: "Notes" }, **logged_in_env)
assert_equal 400, response.status
end
def test_notes_to_a_missing_path_is_404
response = request.post("/api/gpx/notes", params: { feature: sample_note_feature, path: "nope.geojson" }, **logged_in_env)
assert_equal 404, response.status
end
# --- LiveTrack webhook ---
def test_livetrack_webhook_requires_the_configured_secret
@@ -214,6 +255,14 @@ class AppTest < Minitest::Test
private
def sample_note_feature(name = "Turning spot")
JSON.generate(
"type" => "Feature",
"properties" => { "name" => name, "category" => "Note" },
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] },
)
end
def le32(n)
[n].pack("l<")
end
+36
View File
@@ -174,8 +174,44 @@ class GpxStoreTest < Minitest::Test
assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] }
end
def test_append_feature_creates_a_new_geojson_file
path = @store.append_feature(feature: sample_feature("Turning spot"), folder: "", name: "My Notes")
assert_equal "My Notes.geojson", path
file = @store.tree["children"].find { |c| c["name"] == "My Notes.geojson" }
assert_equal ["waypoint"], file["kinds"]
end
def test_append_feature_grows_the_same_file_instead_of_disambiguating
first_path = @store.append_feature(feature: sample_feature("Turning spot"), folder: "", name: "My Notes")
second_path = @store.append_feature(feature: sample_feature("Narrow gate"), path: first_path)
assert_equal first_path, second_path
features = JSON.parse(@store.geojson(first_path))["features"]
assert_equal ["Turning spot", "Narrow gate"], features.map { |f| f["properties"]["name"] }
end
def test_append_feature_to_a_missing_path_raises_enoent
assert_raises(Errno::ENOENT) { @store.append_feature(feature: sample_feature("x"), path: "nope.geojson") }
end
def test_append_feature_name_always_gets_a_geojson_extension
path = @store.append_feature(feature: sample_feature("x"), folder: "", name: "notes.txt")
assert_equal "notes.txt.geojson", path
end
def test_append_feature_blank_name_falls_back_to_notes
path = @store.append_feature(feature: sample_feature("x"), folder: "", name: "")
assert_equal "notes.geojson", path
end
private
def sample_feature(name)
{ "type" => "Feature", "properties" => { "name" => name, "category" => "Note" },
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] } }
end
def sample_gpx
<<~GPX
<gpx><wpt lat="48.1" lon="17.1"><name>Waypoint</name></wpt></gpx>