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
20 changes: 14 additions & 6 deletions driving_log/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -269,16 +272,21 @@ def finalize(
)
if row["status"] != "ending" or not row["provisional_ended_at_utc"]:
raise ConflictError("live drive must be ending before finalization")
# 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")
)
if (end - start).total_seconds() < 30:
raise ValueError("drive must be at least 30 seconds")
start = start.replace(second=0, microsecond=0)
end = end.replace(second=0, microsecond=0)
).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")
drive_id = str(uuid.uuid5(uuid.UUID(live_id), "completed-drive"))
drive = RecordService(self.database).create(
DriveInput(
Expand Down
9 changes: 9 additions & 0 deletions driving_log/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,22 @@ 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);
}
.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; }
Expand Down
91 changes: 81 additions & 10 deletions driving_log/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,18 +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 totalMinutes = Math.round((end - start) / 60000);
const valid = Number.isFinite(totalMinutes) && totalMinutes > 0;
const diffMs = end - start;
// 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;
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 = () => {
Expand All @@ -139,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);
Expand Down Expand Up @@ -208,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;
Expand All @@ -220,6 +290,7 @@
method: form.method || "POST",
body: new FormData(form),
cache: "no-store",
headers: { Accept: "application/json" },
});
if (response.redirected) {
window.location.assign(response.url);
Expand Down
7 changes: 6 additions & 1 deletion driving_log/templates/_time_editor.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<section class="time-editor" data-time-editor>
<section class="time-editor" data-time-editor
{% if precise_start_utc is defined %}data-precise-start="{{ precise_start_utc }}"{% endif %}
{% if precise_end_utc is defined %}data-precise-end="{{ precise_end_utc }}"{% endif %}
{% if minimum_duration_seconds is defined %}data-minimum-duration-seconds="{{ minimum_duration_seconds }}"{% endif %}
>
<p class="field-warning" data-short-drive-warning hidden></p>
<label>Start time
<input required type="datetime-local" name="started_at_local" value="{{ start_local }}" data-time-start>
</label>
Expand Down
6 changes: 6 additions & 0 deletions driving_log/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
{% else %}
{% set total_percent = rounded_percent %}
{% endif %}
{% if saved_drive %}
<div class="completion-note drive-saved-banner" data-drive-saved-banner>
<strong>{{ format_minutes(saved_drive.duration_minutes) }} drive saved</strong>
<a class="button compact" href="/drives/{{ saved_drive.id }}/edit">Edit drive</a>
</div>
{% endif %}
{% if live %}
<a class="live-banner" href="/live">
{{ "Drive in progress" if live.status == "active" else "Finish saving drive" }}
Expand Down
12 changes: 11 additions & 1 deletion driving_log/templates/drive_form.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ title }}</h1>
<form class="stacked-form" method="post" action="{{ action }}" data-async-submit>
<form id="drive-form" class="stacked-form" method="post" action="{{ action }}" data-async-submit>
<input type="hidden" name="request_id" value="{{ '10000000-0000-4000-8000-' ~ range(100000000000,999999999999)|random }}">
{% if drive %}<input type="hidden" name="version" value="{{ drive.version }}">{% endif %}
{% include "_time_editor.html" %}
Expand All @@ -27,4 +27,14 @@ <h1>{{ title }}</h1>
</label>
<button class="primary" type="submit">Save drive</button>
</form>
{% if drive %}
<div class="actions">
<a class="button" href="/drives/{{ drive.id }}" data-cancel-form="drive-form">Cancel</a>
<form method="post" action="/drives/{{ drive.id }}/delete" data-async-submit onsubmit="return confirm('Delete this drive? It remains in audit history.')">
<input type="hidden" name="request_id" value="{{ '90000000-0000-4000-8000-' ~ range(100000000000,999999999999)|random }}">
<input type="hidden" name="version" value="{{ drive.version }}">
<button class="danger" type="submit">Delete drive</button>
</form>
</div>
{% endif %}
{% endblock %}
32 changes: 26 additions & 6 deletions driving_log/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -316,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",
Expand All @@ -325,6 +341,7 @@ async def dashboard(request: Request) -> HTMLResponse:
totals=totals,
overages=overages,
live=open_drive,
saved_drive=saved_drive,
),
)

Expand Down Expand Up @@ -755,6 +772,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(
Expand Down Expand Up @@ -835,7 +855,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(f"/?saved={drive['id']}")

@app.get("/imports", response_class=HTMLResponse)
async def imports_page(request: Request) -> HTMLResponse:
Expand Down
Loading
Loading