From 676eb2b52b28d67877bf93577e66cfd691df9d49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:12:32 +0000 Subject: [PATCH 1/9] Redirect to dashboard after finalizing a live drive Finalizing a live drive previously landed on the new drive's detail page; send the user to the main dashboard instead. --- driving_log/web.py | 4 ++-- tests/test_web.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/driving_log/web.py b/driving_log/web.py index 6ecab0e..b98dcbd 100644 --- a/driving_log/web.py +++ b/driving_log/web.py @@ -823,7 +823,7 @@ async def live_finalize(request: Request, live_id: str) -> RedirectResponse: if end_text == _local_input_value(current["provisional_ended_at_utc"]) else _parse_local(end_text, str(form.get("end_fold", ""))) ) - drive = live.finalize( + live.finalize( live_id, request_id=str(form["request_id"]), road_type=str(form.get("road_type", "unknown")), @@ -835,7 +835,7 @@ async def live_finalize(request: Request, live_id: str) -> RedirectResponse: corrected_end_utc=corrected_end, actor_identity=_actor(request), ) - return redirect(f"/drives/{drive['id']}") + return redirect("/") @app.get("/imports", response_class=HTMLResponse) async def imports_page(request: Request) -> HTMLResponse: diff --git a/tests/test_web.py b/tests/test_web.py index 8f135a4..c88662c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -700,9 +700,8 @@ async def scenario() -> None: ) self.assertEqual(replay.status_code, 303) self.assertEqual(replay.headers["location"], finalized.headers["location"]) - detail = await newest.get(finalized.headers["location"]) - self.assertIn("Home", detail.text) - dashboard = await newest.get("/") + self.assertEqual(finalized.headers["location"], "/") + dashboard = await newest.get(finalized.headers["location"]) self.assertIn("Recorded drives", dashboard.text) self.run_async(scenario) From 782547c9024636dad709c620beef56c986ed5c05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:47:56 +0000 Subject: [PATCH 2/9] Show plain error messages for async form submissions Async forms (end/finalize/cancel/etc.) were rendering the failure response's full HTML page (including inlined script contents and nav links) through a tag-stripping regex before alerting it, so a simple validation failure like a too-short drive surfaced as a wall of noise instead of the actual message. The client now asks for a JSON error body, and the server returns just the message when it does. --- driving_log/static/app.js | 1 + driving_log/web.py | 17 +++++++++++++---- tests/test_web.py | 26 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/driving_log/static/app.js b/driving_log/static/app.js index 724e71f..50259de 100644 --- a/driving_log/static/app.js +++ b/driving_log/static/app.js @@ -192,6 +192,7 @@ method: form.method || "POST", body: new FormData(form), cache: "no-store", + headers: { Accept: "application/json" }, }); if (response.redirected) { window.location.assign(response.url); diff --git a/driving_log/web.py b/driving_log/web.py index b98dcbd..639519f 100644 --- a/driving_log/web.py +++ b/driving_log/web.py @@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.datastructures import FormData, UploadFile @@ -275,8 +275,13 @@ async def read_form(request: Request) -> FormData: def redirect(path: str) -> RedirectResponse: return RedirectResponse(path, status_code=303) + def wants_json(request: Request) -> bool: + return "application/json" in request.headers.get("accept", "") + @app.exception_handler(ConflictError) - async def conflict_handler(request: Request, exc: ConflictError) -> HTMLResponse: + async def conflict_handler(request: Request, exc: ConflictError) -> Response: + if wants_json(request): + return JSONResponse({"detail": str(exc)}, status_code=409) return templates.TemplateResponse( request, "error.html", @@ -285,7 +290,9 @@ async def conflict_handler(request: Request, exc: ConflictError) -> HTMLResponse ) @app.exception_handler(NotFoundError) - async def not_found_handler(request: Request, exc: NotFoundError) -> HTMLResponse: + async def not_found_handler(request: Request, exc: NotFoundError) -> Response: + if wants_json(request): + return JSONResponse({"detail": str(exc)}, status_code=404) return templates.TemplateResponse( request, "error.html", @@ -295,10 +302,12 @@ async def not_found_handler(request: Request, exc: NotFoundError) -> HTMLRespons @app.exception_handler(ValueError) @app.exception_handler(KeyError) - async def invalid_form_handler(request: Request, exc: ValueError | KeyError) -> HTMLResponse: + async def invalid_form_handler(request: Request, exc: ValueError | KeyError) -> Response: message = ( f"Missing required field: {exc.args[0]}" if isinstance(exc, KeyError) else str(exc) ) + if wants_json(request): + return JSONResponse({"detail": message}, status_code=400) return templates.TemplateResponse( request, "error.html", diff --git a/tests/test_web.py b/tests/test_web.py index c88662c..1d46f83 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -257,6 +257,32 @@ async def scenario() -> None: self.run_async(scenario) + def test_invalid_form_returns_plain_json_detail_for_async_submits(self) -> None: + async def scenario() -> None: + async with ( + self.app.router.lifespan_context(self.app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.app), + base_url="http://testserver", + ) as client, + ): + malformed = await client.post( + "/drives", + headers={"Accept": "application/json"}, + data={ + "request_id": str(uuid.uuid4()), + "started_at_local": "not-a-date", + "ended_at_local": "2026-07-20T12:30", + }, + ) + self.assertEqual(malformed.status_code, 400) + self.assertEqual(malformed.headers["content-type"], "application/json") + body = malformed.json() + self.assertNotIn("<", body["detail"]) + self.assertNotIn("DRIVING_LOG_THEME", body["detail"]) + + self.run_async(scenario) + def test_drive_history_filters_totals_and_groups(self) -> None: async def scenario() -> None: async with ( From a2a0fb2e6efedec1e021702105909d394e13250a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:04:10 +0000 Subject: [PATCH 3/9] Stop the time editor from silently blocking short-drive saves datetime-local inputs only carry minute precision, so a drive shorter than a minute renders identical start/end field values even though the server holds the true, more precise timestamps. The editor's client-side check treated that as "end before start" and quietly set a validity error on page load, so tapping Save did nothing with no visible feedback. Only block on an end that's genuinely before the start, and let the server's full-precision minimum-duration check (now reporting plainly, per the prior fix) be the real authority. --- driving_log/static/app.js | 10 ++++-- tests/test_browser.py | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/driving_log/static/app.js b/driving_log/static/app.js index 50259de..a186d29 100644 --- a/driving_log/static/app.js +++ b/driving_log/static/app.js @@ -120,10 +120,16 @@ const updateDuration = () => { const start = localInputMilliseconds(startInput.value); const end = localInputMilliseconds(endInput.value); - const totalMinutes = Math.round((end - start) / 60000); - const valid = Number.isFinite(totalMinutes) && totalMinutes > 0; + const diffMs = end - start; + // The datetime-local inputs only carry minute precision, so a drive + // shorter than a minute renders identical start/end values here even + // though the server holds the true, more precise timestamps. Only + // block on an end that's visibly before the start; let the server's + // full-precision minimum-duration check be the real authority. + const valid = Number.isFinite(diffMs) && diffMs >= 0; endInput.setCustomValidity(valid ? "" : "End time must be after start time."); if (!valid) return; + const totalMinutes = Math.round(diffMs / 60000); hoursInput.value = String(Math.floor(totalMinutes / 60)); minutesInput.value = String(totalMinutes % 60); hoursInput.setCustomValidity(""); diff --git a/tests/test_browser.py b/tests/test_browser.py index b0b0f75..071c581 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -18,6 +18,81 @@ def _available_port() -> int: return int(listener.getsockname()[1]) +@pytest.mark.browser +def test_quick_live_drive_save_reports_short_duration_without_hanging() -> None: + port = _available_port() + with tempfile.TemporaryDirectory() as temporary: + environment = { + **os.environ, + "DRIVING_LOG_STATE_DIR": temporary, + "DRIVING_LOG_PORT": str(port), + "DRIVING_LOG_PUBLIC_HOST": f"127.0.0.1:{port}", + } + server = subprocess.Popen( + [ + sys.executable, + "-m", + "uvicorn", + "driving_log.app:create_app", + "--factory", + "--host", + "127.0.0.1", + "--port", + str(port), + ], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + url = f"http://127.0.0.1:{port}" + for _ in range(100): + if server.poll() is not None: + output = server.stdout.read() if server.stdout else "" + raise AssertionError(f"test server exited early:\n{output}") + try: + if urllib.request.urlopen(f"{url}/health/ready", timeout=0.2).status == 200: + break + except Exception: + time.sleep(0.05) + else: + output = "" + if server.poll() is not None and server.stdout: + output = server.stdout.read() + raise AssertionError(f"test server did not become ready\n{output}") + + with sync_playwright() as playwright: + local_chrome = os.environ.get("DRIVING_LOG_BROWSER") == "chromium-local" + browser = ( + playwright.chromium.launch( + executable_path="/usr/bin/google-chrome", headless=True + ) + if local_chrome + else playwright.webkit.launch(headless=True) + ) + page = browser.new_page() + dialogs: list[str] = [] + page.on("dialog", lambda dialog: (dialogs.append(dialog.message), dialog.accept())) + page.goto(url) + page.get_by_role("button", name="Start a drive").click() + page.wait_for_url("**/live") + page.get_by_role("button", name="End drive").click() + page.get_by_text("Drive ended.").wait_for() + page.get_by_role("button", name="Save completed drive").click() + page.wait_for_timeout(500) + assert dialogs == ["drive must be at least 30 seconds"] + assert page.url == f"{url}/live" + browser.close() + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=5) + + @pytest.mark.browser def test_mobile_webkit_live_drive_recovery() -> None: port = _available_port() From efb477e5b29f7715977d725e4185d9a4e53fecaf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:13:50 +0000 Subject: [PATCH 4/9] Warn about too-short drives before save, and confirm saves on the dashboard The finalize form now shows an inline warning as soon as the drive is (or would be) under the 30-second minimum, computed from the server's full-precision start/end timestamps rather than the minute-precision datetime-local inputs, so the driver sees the problem before tapping Save instead of only after. The 30-second threshold is now a shared constant (MINIMUM_DRIVE_SECONDS) instead of a duplicated literal. Finalizing a live drive now also flags the redirect with ?saved=1 so the dashboard can show a "Drive saved." banner; the query string is stripped from the URL bar right after render so a manual refresh doesn't keep re-showing it. --- driving_log/live.py | 7 ++- driving_log/static/app.css | 5 ++ driving_log/static/app.js | 80 ++++++++++++++++++++----- driving_log/templates/_time_editor.html | 7 ++- driving_log/templates/dashboard.html | 3 + driving_log/web.py | 8 ++- tests/test_web.py | 10 +++- 7 files changed, 100 insertions(+), 20 deletions(-) diff --git a/driving_log/live.py b/driving_log/live.py index ed7da58..546c03c 100644 --- a/driving_log/live.py +++ b/driving_log/live.py @@ -15,6 +15,9 @@ class NoOpenDriveError(RuntimeError): pass +MINIMUM_DRIVE_SECONDS = 30 + + class LiveDriveService: def __init__(self, database: Database, clock: Callable[[], datetime] | None = None): self.database = database @@ -275,8 +278,8 @@ def finalize( end = corrected_end_utc or datetime.fromisoformat( row["provisional_ended_at_utc"].replace("Z", "+00:00") ) - if (end - start).total_seconds() < 30: - raise ValueError("drive must be at least 30 seconds") + if (end - start).total_seconds() < MINIMUM_DRIVE_SECONDS: + raise ValueError(f"drive must be at least {MINIMUM_DRIVE_SECONDS} seconds") start = start.replace(second=0, microsecond=0) end = end.replace(second=0, microsecond=0) drive_id = str(uuid.uuid5(uuid.UUID(live_id), "completed-drive")) diff --git a/driving_log/static/app.css b/driving_log/static/app.css index 03862f5..686e824 100644 --- a/driving_log/static/app.css +++ b/driving_log/static/app.css @@ -169,6 +169,11 @@ input, textarea, select { .time-editor > label, .time-editor input[type="datetime-local"] { min-width: 0; max-width: 100%; } +.field-warning { + margin: 0; padding: .6rem .8rem; border-radius: .6rem; font-weight: 650; + background: var(--warning); color: var(--danger); +} +.field-warning[hidden] { display: none; } .time-duration { display: grid; gap: .4rem; } .time-label { font-weight: 650; } .duration-editor { display: flex; gap: .7rem; } diff --git a/driving_log/static/app.js b/driving_log/static/app.js index a186d29..ebd79fa 100644 --- a/driving_log/static/app.js +++ b/driving_log/static/app.js @@ -3,6 +3,9 @@ const root = document.documentElement; const metaTheme = document.querySelector('meta[name="theme-color"]'); const clock = document.querySelector(".live-clock"); + if (document.querySelector("[data-drive-saved-banner]") && window.history.replaceState) { + window.history.replaceState(null, "", window.location.pathname); + } let boundaries = (window.DRIVING_LOG_THEME || []).map(value => new Date(value)); let serverOffset = new Date(window.DRIVING_LOG_SERVER_NOW).getTime() - Date.now(); let themeTimer; @@ -116,24 +119,71 @@ const endInput = editor.querySelector("[data-time-end]"); const hoursInput = editor.querySelector("[data-duration-hours]"); const minutesInput = editor.querySelector("[data-duration-minutes]"); + const shortDriveWarning = editor.querySelector("[data-short-drive-warning]"); + const minimumSeconds = Number(editor.dataset.minimumDurationSeconds); + const preciseStart = editor.dataset.preciseStart + ? new Date(editor.dataset.preciseStart).getTime() + : null; + const preciseEnd = editor.dataset.preciseEnd + ? new Date(editor.dataset.preciseEnd).getTime() + : null; + const originalStartValue = startInput.value; + const originalEndValue = endInput.value; + + // The datetime-local inputs only carry minute precision, so a drive + // shorter than a minute renders identical start/end values here even + // though the server holds the true, more precise timestamps. Fall back + // to those precise timestamps only while neither field has been edited + // from what the server rendered; any edit makes the input values (and + // their minute precision) the real intent. + const effectiveDurationMs = () => { + const start = localInputMilliseconds(startInput.value); + const end = localInputMilliseconds(endInput.value); + const diffMs = end - start; + if ( + diffMs === 0 && + preciseStart !== null && + preciseEnd !== null && + startInput.value === originalStartValue && + endInput.value === originalEndValue + ) { + return preciseEnd - preciseStart; + } + return diffMs; + }; + + const updateShortDriveWarning = () => { + if (!shortDriveWarning || !Number.isFinite(minimumSeconds)) return; + const diffMs = effectiveDurationMs(); + const seconds = Math.round(diffMs / 1000); + const tooShort = diffMs >= 0 && seconds < minimumSeconds; + shortDriveWarning.hidden = !tooShort; + if (tooShort) { + const length = + seconds <= 0 ? "less than a second" : `${seconds} ${seconds === 1 ? "second" : "seconds"}`; + shortDriveWarning.textContent = + `This drive is only ${length} long — drives must be at least ` + + `${minimumSeconds} seconds.`; + } + }; const updateDuration = () => { const start = localInputMilliseconds(startInput.value); const end = localInputMilliseconds(endInput.value); const diffMs = end - start; - // The datetime-local inputs only carry minute precision, so a drive - // shorter than a minute renders identical start/end values here even - // though the server holds the true, more precise timestamps. Only - // block on an end that's visibly before the start; let the server's - // full-precision minimum-duration check be the real authority. + // Only block on an end that's visibly before the start; let the + // server's full-precision minimum-duration check be the real + // authority on drives that are merely too short. const valid = Number.isFinite(diffMs) && diffMs >= 0; endInput.setCustomValidity(valid ? "" : "End time must be after start time."); - if (!valid) return; - const totalMinutes = Math.round(diffMs / 60000); - hoursInput.value = String(Math.floor(totalMinutes / 60)); - minutesInput.value = String(totalMinutes % 60); - hoursInput.setCustomValidity(""); - minutesInput.setCustomValidity(""); + if (valid) { + const totalMinutes = Math.round(diffMs / 60000); + hoursInput.value = String(Math.floor(totalMinutes / 60)); + minutesInput.value = String(totalMinutes % 60); + hoursInput.setCustomValidity(""); + minutesInput.setCustomValidity(""); + } + updateShortDriveWarning(); }; const updateEnd = () => { @@ -145,9 +195,11 @@ const message = valid ? "" : "Duration must be at least one minute."; hoursInput.setCustomValidity(message); minutesInput.setCustomValidity(message); - if (!valid) return; - endInput.value = formatLocalInput(start + totalMinutes * 60000); - endInput.setCustomValidity(""); + if (valid) { + endInput.value = formatLocalInput(start + totalMinutes * 60000); + endInput.setCustomValidity(""); + } + updateShortDriveWarning(); }; startInput.addEventListener("input", updateDuration); diff --git a/driving_log/templates/_time_editor.html b/driving_log/templates/_time_editor.html index 6a52cd1..4e894dd 100644 --- a/driving_log/templates/_time_editor.html +++ b/driving_log/templates/_time_editor.html @@ -1,4 +1,9 @@ -
+
+ diff --git a/driving_log/templates/dashboard.html b/driving_log/templates/dashboard.html index a0813b3..6671a5f 100644 --- a/driving_log/templates/dashboard.html +++ b/driving_log/templates/dashboard.html @@ -6,6 +6,9 @@ {% else %} {% set total_percent = rounded_percent %} {% endif %} + {% if drive_saved %} +

Drive saved. It's now counted below.

+ {% endif %} {% if live %} {{ "Drive in progress" if live.status == "active" else "Finish saving drive" }} diff --git a/driving_log/web.py b/driving_log/web.py index 639519f..c8df60d 100644 --- a/driving_log/web.py +++ b/driving_log/web.py @@ -19,7 +19,7 @@ from driving_log.csv_backup import export_csv, import_csv from driving_log.db import Database from driving_log.dmv import DmvExportService, SupervisorProfileService, mask_license -from driving_log.live import LiveDriveService +from driving_log.live import MINIMUM_DRIVE_SECONDS, LiveDriveService from driving_log.records import ConflictError, DriveInput, NotFoundError, RecordService from driving_log.solar import apex_daylight_window, resolve_local @@ -334,6 +334,7 @@ async def dashboard(request: Request) -> HTMLResponse: totals=totals, overages=overages, live=open_drive, + drive_saved=request.query_params.get("saved") == "1", ), ) @@ -764,6 +765,9 @@ async def live_page(request: Request) -> HTMLResponse: time_values = { "start_local": _local_input_value(started), "end_local": _local_input_value(ended), + "precise_start_utc": open_drive["started_at_utc"], + "precise_end_utc": open_drive["provisional_ended_at_utc"], + "minimum_duration_seconds": MINIMUM_DRIVE_SECONDS, **_duration_parts(minutes), } return templates.TemplateResponse( @@ -844,7 +848,7 @@ async def live_finalize(request: Request, live_id: str) -> RedirectResponse: corrected_end_utc=corrected_end, actor_identity=_actor(request), ) - return redirect("/") + return redirect("/?saved=1") @app.get("/imports", response_class=HTMLResponse) async def imports_page(request: Request) -> HTMLResponse: diff --git a/tests/test_web.py b/tests/test_web.py index 1d46f83..e1c034d 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -202,11 +202,14 @@ async def scenario() -> None: ) self.assertIn(">2%", dashboard.text) self.assertNotIn("1.7%", dashboard.text) + self.assertNotIn("Drive saved.", dashboard.text) manual_form = await client.get("/drives/new") self.assertNotIn('name="supervisor_dl_number"', manual_form.text) self.assertNotIn('name="supervisor_dl_state"', manual_form.text) self.assertIn("data-time-editor", manual_form.text) self.assertIn('aria-label="Duration hours"', manual_form.text) + self.assertNotIn("data-precise-start=", manual_form.text) + self.assertNotIn("data-minimum-duration-seconds", manual_form.text) edit_form = await client.get(f"{response.headers['location']}/edit") self.assertIn("data-time-editor", edit_form.text) self.assertIn('value="2026-07-20T12:00"', edit_form.text) @@ -692,6 +695,10 @@ async def scenario() -> None: self.assertNotIn('name="supervisor_dl_number"', completion.text) self.assertNotIn('name="supervisor_dl_state"', completion.text) self.assertIn("data-time-editor", completion.text) + self.assertIn("data-precise-start=", completion.text) + self.assertIn("data-precise-end=", completion.text) + self.assertIn('data-minimum-duration-seconds="30"', completion.text) + self.assertIn("data-short-drive-warning", completion.text) self.assertIn('name="end_location"', completion.text) start_match = re.search( r'name="started_at_local" value="([^"]+)"', completion.text @@ -726,9 +733,10 @@ async def scenario() -> None: ) self.assertEqual(replay.status_code, 303) self.assertEqual(replay.headers["location"], finalized.headers["location"]) - self.assertEqual(finalized.headers["location"], "/") + self.assertEqual(finalized.headers["location"], "/?saved=1") dashboard = await newest.get(finalized.headers["location"]) self.assertIn("Recorded drives", dashboard.text) + self.assertIn("Drive saved.", dashboard.text) self.run_async(scenario) From a64a0dcf3c57d88728841b7ae11d1ce0ee807c9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:19:23 +0000 Subject: [PATCH 5/9] Fix inconsistent duration math when only one time is corrected Correcting just the end (or just the start) time on the finalize form compared that whole-minute corrected value against the *other* side's raw, sub-minute-precision timestamp still held from the live drive. That could make a drive the editor displayed as a full minute long compute to only a few seconds, wrongly tripping the 30-second minimum no matter how much time the driver added. Once either side is corrected, round the uncorrected side to the minute too, so the length check matches what the editor shows. An untouched submission still checks the true elapsed time, so a genuinely too-short live drive is still caught. --- driving_log/live.py | 21 +++++++++++++++++---- tests/test_live.py | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/driving_log/live.py b/driving_log/live.py index 546c03c..e935b87 100644 --- a/driving_log/live.py +++ b/driving_log/live.py @@ -272,12 +272,25 @@ def finalize( ) if row["status"] != "ending" or not row["provisional_ended_at_utc"]: raise ConflictError("live drive must be ending before finalization") - start = corrected_start_utc or datetime.fromisoformat( - row["started_at_utc"].replace("Z", "+00:00") - ) - end = corrected_end_utc or datetime.fromisoformat( + # Corrected values come from minute-precision inputs. If only one + # side was corrected, comparing it against the other side's raw, + # sub-minute-precision timestamp would produce a duration that + # has nothing to do with what the editor displayed (e.g. adding + # a full displayed minute could still land under the minimum). + # Once any correction is made, round both sides to the minute so + # the length check matches what's on screen; an untouched + # submission still gets checked against the true elapsed time. + edited = corrected_start_utc is not None or corrected_end_utc is not None + start_from_row = datetime.fromisoformat(row["started_at_utc"].replace("Z", "+00:00")) + end_from_row = datetime.fromisoformat( row["provisional_ended_at_utc"].replace("Z", "+00:00") ) + start = corrected_start_utc or ( + start_from_row.replace(second=0, microsecond=0) if edited else start_from_row + ) + end = corrected_end_utc or ( + end_from_row.replace(second=0, microsecond=0) if edited else end_from_row + ) if (end - start).total_seconds() < MINIMUM_DRIVE_SECONDS: raise ValueError(f"drive must be at least {MINIMUM_DRIVE_SECONDS} seconds") start = start.replace(second=0, microsecond=0) diff --git a/tests/test_live.py b/tests/test_live.py index 7163723..45267d1 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -139,6 +139,29 @@ def test_finalize_can_correct_both_start_and_end(self) -> None: self.assertEqual(drive["ended_at_utc"], "2026-07-26T12:35:00Z") self.assertEqual(drive["duration_minutes"], 40) + def test_finalize_with_only_end_corrected_uses_minute_consistent_duration(self) -> None: + self.clock.value = datetime(2026, 7, 26, 12, 0, 50, tzinfo=UTC) + live = self.service.start(request_id=str(uuid.uuid4())) + self.clock.value += timedelta(seconds=5) + self.service.end(live["id"], request_id=str(uuid.uuid4())) + + # Only the end time is corrected (as if the driver bumped the + # displayed end forward by one minute in the editor); the start is + # left untouched. Comparing that corrected, whole-minute end against + # the *raw* start (still carrying its :50-second offset) would give + # a 10-second span and wrongly reject a drive the UI shows as a full + # minute long. + corrected_end = datetime(2026, 7, 26, 12, 1, 0, tzinfo=UTC) + drive = self.service.finalize( + live["id"], + request_id=str(uuid.uuid4()), + road_type="local", + corrected_end_utc=corrected_end, + ) + self.assertEqual(drive["started_at_utc"], "2026-07-26T12:00:00Z") + self.assertEqual(drive["ended_at_utc"], "2026-07-26T12:01:00Z") + self.assertEqual(drive["duration_minutes"], 1) + def test_missing_invalid_and_completed_state_guards(self) -> None: with ( self.database.transaction() as connection, From bae6d247136cc1f99a5844283bea600d7830537d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:27:34 +0000 Subject: [PATCH 6/9] Show drive duration and an edit link in the save banner The dashboard's post-save banner now reads "Xh YYm drive saved" and carries an "Edit drive" button linking straight to that drive's edit page, instead of a generic confirmation sentence. The finalize redirect now carries the saved drive's id (?saved=) so the dashboard can look up its duration and build the edit link. --- driving_log/static/app.css | 4 ++++ driving_log/templates/dashboard.html | 7 +++++-- driving_log/web.py | 13 ++++++++++--- tests/test_web.py | 11 +++++++---- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/driving_log/static/app.css b/driving_log/static/app.css index 686e824..9180e73 100644 --- a/driving_log/static/app.css +++ b/driving_log/static/app.css @@ -162,6 +162,10 @@ input, textarea, select { margin: 0 0 1rem; padding: .7rem .9rem; border-left: .25rem solid var(--accent); border-radius: .35rem; background: color-mix(in srgb, var(--accent) 8%, var(--panel)); } +.drive-saved-banner { + display: flex; align-items: center; justify-content: space-between; gap: .8rem; +} +.drive-saved-banner .button { margin: 0; } .time-editor { display: grid; gap: .8rem; padding: 1rem; border: 1px solid var(--border); border-radius: 1rem; background: var(--panel); diff --git a/driving_log/templates/dashboard.html b/driving_log/templates/dashboard.html index 6671a5f..acdad8a 100644 --- a/driving_log/templates/dashboard.html +++ b/driving_log/templates/dashboard.html @@ -6,8 +6,11 @@ {% else %} {% set total_percent = rounded_percent %} {% endif %} - {% if drive_saved %} -

Drive saved. It's now counted below.

+ {% if saved_drive %} +
{% endif %} {% if live %} diff --git a/driving_log/web.py b/driving_log/web.py index c8df60d..e51119d 100644 --- a/driving_log/web.py +++ b/driving_log/web.py @@ -325,6 +325,13 @@ async def dashboard(request: Request) -> HTMLResponse: for week in weeks if isinstance(week["overage_minutes"], int) and week["overage_minutes"] > 0 ] + saved_drive_id = request.query_params.get("saved") + saved_drive = None + if saved_drive_id: + try: + saved_drive = records.get(saved_drive_id) + except NotFoundError: + saved_drive = None return templates.TemplateResponse( request, "dashboard.html", @@ -334,7 +341,7 @@ async def dashboard(request: Request) -> HTMLResponse: totals=totals, overages=overages, live=open_drive, - drive_saved=request.query_params.get("saved") == "1", + saved_drive=saved_drive, ), ) @@ -836,7 +843,7 @@ async def live_finalize(request: Request, live_id: str) -> RedirectResponse: if end_text == _local_input_value(current["provisional_ended_at_utc"]) else _parse_local(end_text, str(form.get("end_fold", ""))) ) - live.finalize( + drive = live.finalize( live_id, request_id=str(form["request_id"]), road_type=str(form.get("road_type", "unknown")), @@ -848,7 +855,7 @@ async def live_finalize(request: Request, live_id: str) -> RedirectResponse: corrected_end_utc=corrected_end, actor_identity=_actor(request), ) - return redirect("/?saved=1") + return redirect(f"/?saved={drive['id']}") @app.get("/imports", response_class=HTMLResponse) async def imports_page(request: Request) -> HTMLResponse: diff --git a/tests/test_web.py b/tests/test_web.py index e1c034d..57710d5 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -202,7 +202,7 @@ async def scenario() -> None: ) self.assertIn(">2%", dashboard.text) self.assertNotIn("1.7%", dashboard.text) - self.assertNotIn("Drive saved.", dashboard.text) + self.assertNotIn("data-drive-saved-banner", dashboard.text) manual_form = await client.get("/drives/new") self.assertNotIn('name="supervisor_dl_number"', manual_form.text) self.assertNotIn('name="supervisor_dl_state"', manual_form.text) @@ -733,10 +733,13 @@ async def scenario() -> None: ) self.assertEqual(replay.status_code, 303) self.assertEqual(replay.headers["location"], finalized.headers["location"]) - self.assertEqual(finalized.headers["location"], "/?saved=1") - dashboard = await newest.get(finalized.headers["location"]) + saved_location = finalized.headers["location"] + self.assertTrue(saved_location.startswith("/?saved=")) + saved_drive_id = saved_location.removeprefix("/?saved=") + dashboard = await newest.get(saved_location) self.assertIn("Recorded drives", dashboard.text) - self.assertIn("Drive saved.", dashboard.text) + self.assertIn("0h 05m drive saved", dashboard.text) + self.assertIn(f'href="/drives/{saved_drive_id}/edit"', dashboard.text) self.run_async(scenario) From 04832cf364b35847f4409655c9897d224fe7b92b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:31:51 +0000 Subject: [PATCH 7/9] Stop letting a corrected time change how the other side is read Rounding the untouched endpoint to the minute only when its sibling had been corrected meant editing one field could silently change how the other, untouched field's value was interpreted for the length check -- surprising, and not something the user asked for. Each side now always reads as its correction if given, otherwise its raw recorded time rounded to the minute, independent of what happened to the other side. This also means the check runs against the same values that get stored, so it can no longer be fooled into passing a duration that then fails to persist. --- driving_log/live.py | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/driving_log/live.py b/driving_log/live.py index e935b87..54c7196 100644 --- a/driving_log/live.py +++ b/driving_log/live.py @@ -272,29 +272,21 @@ def finalize( ) if row["status"] != "ending" or not row["provisional_ended_at_utc"]: raise ConflictError("live drive must be ending before finalization") - # Corrected values come from minute-precision inputs. If only one - # side was corrected, comparing it against the other side's raw, - # sub-minute-precision timestamp would produce a duration that - # has nothing to do with what the editor displayed (e.g. adding - # a full displayed minute could still land under the minimum). - # Once any correction is made, round both sides to the minute so - # the length check matches what's on screen; an untouched - # submission still gets checked against the true elapsed time. - edited = corrected_start_utc is not None or corrected_end_utc is not None - start_from_row = datetime.fromisoformat(row["started_at_utc"].replace("Z", "+00:00")) - end_from_row = datetime.fromisoformat( + # Both endpoints are stored rounded to the minute regardless of + # correction, so the length check compares them the same way: + # each side is its correction if given, otherwise its raw + # recorded time rounded to the minute. That keeps the check + # independent per side (correcting one endpoint never changes + # how the other, untouched endpoint is read) while still + # matching what actually ends up on the saved record. + start = corrected_start_utc or datetime.fromisoformat( + row["started_at_utc"].replace("Z", "+00:00") + ).replace(second=0, microsecond=0) + end = corrected_end_utc or datetime.fromisoformat( row["provisional_ended_at_utc"].replace("Z", "+00:00") - ) - start = corrected_start_utc or ( - start_from_row.replace(second=0, microsecond=0) if edited else start_from_row - ) - end = corrected_end_utc or ( - end_from_row.replace(second=0, microsecond=0) if edited else end_from_row - ) + ).replace(second=0, microsecond=0) if (end - start).total_seconds() < MINIMUM_DRIVE_SECONDS: raise ValueError(f"drive must be at least {MINIMUM_DRIVE_SECONDS} seconds") - start = start.replace(second=0, microsecond=0) - end = end.replace(second=0, microsecond=0) drive_id = str(uuid.uuid5(uuid.UUID(live_id), "completed-drive")) drive = RecordService(self.database).create( DriveInput( From 8af1fd07c5f86d6f55f4985891eb81c4415a35bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:34:30 +0000 Subject: [PATCH 8/9] Lock in that short-but-real drives get the plain error message Add a regression test for the case that used to surface as "drive must have a positive duration": a raw span (e.g. :15 to :59) that clears the 30-second minimum but rounds down to a zero-minute record because it never crosses a minute boundary. The minimum-duration check now runs against the same rounded values that get stored, so this is caught with the same plain message as any other too-short drive, before it ever reaches record creation. --- tests/test_live.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_live.py b/tests/test_live.py index 45267d1..1549a01 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -120,6 +120,26 @@ def test_invalid_finalize_rolls_back_completed_identity(self) -> None: self.assertEqual(recovered["status"], "ending") # type: ignore[index] self.assertEqual(RecordService(self.database).list_drives(), []) + def test_finalize_reports_short_duration_even_when_raw_span_clears_it(self) -> None: + # Stored drives are always rounded to the minute, so a raw span that + # clears the 30-second minimum but never crosses a minute boundary + # (here: :15 to :59, the same wall-clock minute) would otherwise + # round down to a zero-minute record and blow up with a confusing + # "positive duration" error deep in record creation. The minimum- + # duration check compares the same rounded values that get stored, + # so it catches this first with the same plain message every other + # too-short drive gets. + live = self.service.start(request_id=str(uuid.uuid4())) + self.clock.value += timedelta(seconds=44) + self.service.end(live["id"], request_id=str(uuid.uuid4())) + with self.assertRaisesRegex(ValueError, "30 seconds"): + self.service.finalize( + live["id"], + request_id=str(uuid.uuid4()), + road_type="local", + ) + self.assertEqual(RecordService(self.database).list_drives(), []) + def test_finalize_can_correct_both_start_and_end(self) -> None: live = self.service.start(request_id=str(uuid.uuid4())) self.clock.value += timedelta(minutes=30) From b6de059c892f31bd60a637176392c97d1c34d157 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:41:14 +0000 Subject: [PATCH 9/9] Add Cancel and Delete to the drive edit form Editing a drive (e.g. from the post-save dashboard banner) only had a Save button, with no way back out and no way to delete. Add a Cancel link back to the drive's detail page and a Delete button matching the detail page's existing confirmation, both scoped to edit mode only (the new-drive form has neither, since there's nothing yet to discard or delete). Cancel only prompts ("Discard your changes to this drive?") if the form actually differs from what was rendered; an untouched edit just navigates straight back. --- driving_log/static/app.js | 12 ++++++++++++ driving_log/templates/drive_form.html | 12 +++++++++++- tests/test_web.py | 12 ++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/driving_log/static/app.js b/driving_log/static/app.js index 657420c..22387cc 100644 --- a/driving_log/static/app.js +++ b/driving_log/static/app.js @@ -266,6 +266,18 @@ }); }); + document.querySelectorAll("[data-cancel-form]").forEach(link => { + const form = document.getElementById(link.dataset.cancelForm); + if (!form) return; + const serialize = () => new URLSearchParams(new FormData(form)).toString(); + const initialState = serialize(); + link.addEventListener("click", event => { + if (serialize() !== initialState && !confirm("Discard your changes to this drive?")) { + event.preventDefault(); + } + }); + }); + document.querySelectorAll("form[data-async-submit]").forEach(form => { form.addEventListener("submit", async event => { if (event.defaultPrevented || form.dataset.submitting === "yes") return; diff --git a/driving_log/templates/drive_form.html b/driving_log/templates/drive_form.html index a12c0b8..c07f2be 100644 --- a/driving_log/templates/drive_form.html +++ b/driving_log/templates/drive_form.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %}

{{ title }}

-
+ {% if drive %}{% endif %} {% include "_time_editor.html" %} @@ -27,4 +27,14 @@

{{ title }}

+ {% if drive %} +
+ Cancel +
+ + + +
+
+ {% endif %} {% endblock %} diff --git a/tests/test_web.py b/tests/test_web.py index 57710d5..eee7ab6 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -210,10 +210,22 @@ async def scenario() -> None: self.assertIn('aria-label="Duration hours"', manual_form.text) self.assertNotIn("data-precise-start=", manual_form.text) self.assertNotIn("data-minimum-duration-seconds", manual_form.text) + self.assertNotIn("data-cancel-form", manual_form.text) + self.assertNotIn("Delete drive", manual_form.text) edit_form = await client.get(f"{response.headers['location']}/edit") self.assertIn("data-time-editor", edit_form.text) self.assertIn('value="2026-07-20T12:00"', edit_form.text) self.assertIn('value="2026-07-20T12:30"', edit_form.text) + cancel_href = response.headers["location"] + self.assertIn( + f'href="{cancel_href}" data-cancel-form="drive-form">Cancel', + edit_form.text, + ) + self.assertIn( + "confirm('Delete this drive? It remains in audit history.')", + edit_form.text, + ) + self.assertIn(">Delete drive", edit_form.text) version_match = re.search(r'name="version" value="(\d+)"', edit_form.text) self.assertIsNotNone(version_match) updated = await client.post(