diff --git a/server/app.py b/server/app.py index b051bfe..91cc8ac 100644 --- a/server/app.py +++ b/server/app.py @@ -571,10 +571,11 @@ def geocode(): @app.post("/api/v1/portal/recompute-trips") @require_login def recompute_trips(): - """Manually trigger trip recomputation. Called after upload batch for freshness.""" + """Recompute trips from scratch, clearing all deletion markers.""" device_id = _portal_device_id() if device_id is None: return jsonify(error="device not configured"), 404 + db.clear_deleted_trip_markers(device_id) n = db.compute_trips_for_device(device_id) return jsonify(status="ok", trips_computed=n) @@ -591,6 +592,20 @@ def portal_delete_trip(trip_id): return jsonify(status="ok") +@app.post("/api/v1/portal/trips/delete") +@require_login +def portal_delete_trips_batch(): + device_id = _portal_device_id() + if device_id is None: + return jsonify(error="device not configured"), 404 + body = request.get_json(silent=True) or {} + ids = body.get("ids") + if not isinstance(ids, list) or not ids: + return jsonify(error="expected {ids: [...]}"), 400 + deleted = db.delete_trips_batch(device_id, ids) + return jsonify(status="ok", deleted=deleted) + + @app.delete("/api/v1/portal/dashcam/") @require_login def portal_delete_dashcam(file_id): diff --git a/server/docker-compose.yml b/server/docker-compose.yml index 3f6442f..9b7d106 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -13,6 +13,7 @@ services: - ./sql/migrate_phase24.sql:/docker-entrypoint-initdb.d/migrate_phase24.sql:ro - ./sql/migrate_phase25_5a.sql:/docker-entrypoint-initdb.d/migrate_phase25_5a.sql:ro - ./sql/migrate_phase25_5c.sql:/docker-entrypoint-initdb.d/migrate_phase25_5c.sql:ro + - ./sql/migrate_phase26_deleted_trips.sql:/docker-entrypoint-initdb.d/migrate_phase26_deleted_trips.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"] interval: 10s diff --git a/server/modules/db.py b/server/modules/db.py index 34e41e6..187a256 100644 --- a/server/modules/db.py +++ b/server/modules/db.py @@ -398,6 +398,15 @@ def compute_trips_for_device(device_id): "point_count": len(seg_rows), }) + # Skip trips that were manually deleted (recompute won't re-insert them). + with get_cursor() as cur: + cur.execute( + "SELECT start_time FROM deleted_trip_markers WHERE device_id = %s", + (device_id,), + ) + deleted_starts = {row[0] for row in cur.fetchall()} + trips = [t for t in trips if t["start_time"] not in deleted_starts] + with get_cursor(commit=True) as cur: for t in trips: cur.execute( @@ -540,13 +549,45 @@ def get_dashcam_file(device_id, file_id): def delete_trip(device_id, trip_id): - """Delete a computed trip record. Raw tracking_points are NOT removed.""" + """Delete a computed trip record and mark its time range as deleted + so auto-recompute doesn't re-insert it. Raw tracking_points are kept.""" with get_cursor(commit=True) as cur: + # Record the start_time before deleting so recompute skips this trip. cur.execute( - "DELETE FROM trips WHERE id = %s AND device_id = %s RETURNING id", + "SELECT start_time FROM trips WHERE id = %s AND device_id = %s", (trip_id, device_id), ) - return cur.fetchone() is not None + row = cur.fetchone() + if row is None: + return False + cur.execute( + """INSERT INTO deleted_trip_markers (device_id, start_time) + VALUES (%s, %s) ON CONFLICT DO NOTHING""", + (device_id, row[0]), + ) + cur.execute( + "DELETE FROM trips WHERE id = %s AND device_id = %s", + (trip_id, device_id), + ) + return True + + +def delete_trips_batch(device_id, trip_ids): + """Delete multiple trips in one transaction.""" + deleted = 0 + for tid in trip_ids: + if delete_trip(device_id, tid): + deleted += 1 + return deleted + + +def clear_deleted_trip_markers(device_id): + """Remove all deleted trip markers so recompute rebuilds everything.""" + with get_cursor(commit=True) as cur: + cur.execute( + "DELETE FROM deleted_trip_markers WHERE device_id = %s", + (device_id,), + ) def delete_dashcam_file(device_id, file_id): diff --git a/server/sql/migrate_phase26_deleted_trips.sql b/server/sql/migrate_phase26_deleted_trips.sql new file mode 100644 index 0000000..3530c06 --- /dev/null +++ b/server/sql/migrate_phase26_deleted_trips.sql @@ -0,0 +1,10 @@ +-- Migration: deleted trip markers to prevent recompute from re-inserting +-- manually deleted trips. Each row records a trip the user explicitly +-- removed. compute_trips_for_device() skips time ranges matching any marker. +-- The "Recompute trips" button clears all markers before rebuilding. +CREATE TABLE IF NOT EXISTS deleted_trip_markers ( + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + start_time TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (device_id, start_time) +); diff --git a/server/templates/history.html b/server/templates/history.html index b4491b8..fce020e 100644 --- a/server/templates/history.html +++ b/server/templates/history.html @@ -14,8 +14,11 @@ {# ── Trip list ──────────────────────────────────────────────────── #}
-
+
Recorded trips
+
@@ -38,6 +41,7 @@ true ) }} +
diff --git a/static/js/history.js b/static/js/history.js index e692b4d..100778e 100644 --- a/static/js/history.js +++ b/static/js/history.js @@ -232,6 +232,53 @@ const tripList = document.getElementById("trip-list"); const deletable = tripList && tripList.dataset.deletable === "1"; const deleteUrl = (tripList && tripList.dataset.deleteUrl) || "/api/trip/"; + const batchDeleteUrl = tripList && tripList.dataset.batchDeleteUrl; + const deleteSelectedBtn = document.getElementById("delete-selected-btn"); + + function getCheckedIds() { + return Array.from(body.querySelectorAll("input.trip-checkbox:checked")) + .map(cb => parseInt(cb.value)); + } + + function updateDeleteSelectedBtn() { + if (!deleteSelectedBtn) return; + const count = getCheckedIds().length; + deleteSelectedBtn.hidden = count === 0; + deleteSelectedBtn.textContent = `Delete selected (${count})`; + } + + function selectAllTrips() { + const cbs = body.querySelectorAll("input.trip-checkbox"); + const allChecked = cbs.length > 0 && Array.from(cbs).every(cb => cb.checked); + cbs.forEach(cb => { cb.checked = !allChecked; }); + updateDeleteSelectedBtn(); + } + + async function deleteSelected() { + const ids = getCheckedIds(); + if (!ids.length || !batchDeleteUrl) return; + const confirmed = await window.confirmDelete( + `Delete ${ids.length} selected trip(s)?\n\nRaw GPS data is kept — only computed trip records are removed.` + ); + if (!confirmed) return; + try { + const res = await fetch(batchDeleteUrl, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ids: ids}), + }); + if (!res.ok) throw new Error("Delete failed"); + ids.forEach(id => { + const row = body.querySelector(`tr[data-trip-id="${id}"]`); + if (row) row.remove(); + }); + updateDeleteSelectedBtn(); + } catch(e) { showError(e.message); } + } + + if (deleteSelectedBtn) { + deleteSelectedBtn.addEventListener("click", deleteSelected); + } function renderTrips(trips) { body.replaceChildren(); @@ -248,7 +295,11 @@ ` : ""; + const checkboxCol = batchDeleteUrl + ? `` + : ""; row.innerHTML = ` + ${checkboxCol} ${formatDate(trip.start_datetime)} ${formatDistance(trip.distance_km)} ${formatDuration(trip.duration_seconds)} @@ -261,6 +312,12 @@ `; body.appendChild(row); }); + + // Wire checkbox listeners + body.querySelectorAll("input.trip-checkbox").forEach(cb => { + cb.addEventListener("change", updateDeleteSelectedBtn); + }); + updateDeleteSelectedBtn(); } body.addEventListener("click", function (event) {