diff --git a/ai/__pycache__/__init__.cpython-311.pyc b/ai/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b9450fa Binary files /dev/null and b/ai/__pycache__/__init__.cpython-311.pyc differ diff --git a/ai/__pycache__/local_ai_api.cpython-311.pyc b/ai/__pycache__/local_ai_api.cpython-311.pyc new file mode 100644 index 0000000..e906db1 Binary files /dev/null and b/ai/__pycache__/local_ai_api.cpython-311.pyc differ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a7..1d243eb 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index 291d043..3fa0a10 100644 --- a/config/settings.py +++ b/config/settings.py @@ -150,9 +150,11 @@ STATIC_URL = 'static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [ - BASE_DIR / 'static', - BASE_DIR / 'assets', - BASE_DIR / 'node_modules', + path for path in [ + BASE_DIR / 'static', + BASE_DIR / 'assets', + BASE_DIR / 'node_modules', + ] if path.exists() ] # Email diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a..b3a467b 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..04c46f8 Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5f..ada034d 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..7159898 Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988..10c39b1 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/utils.cpython-311.pyc b/core/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000..c9d0f02 Binary files /dev/null and b/core/__pycache__/utils.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c..bdd96cb 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..9e75f94 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,11 @@ from django.contrib import admin -# Register your models here. +from .models import Trip + + +@admin.register(Trip) +class TripAdmin(admin.ModelAdmin): + list_display = ("date", "trip_type", "start_location", "end_location", "distance_miles", "distance_source", "updated_at") + list_filter = ("trip_type", "distance_source", "date") + search_fields = ("start_location", "end_location", "business_purpose", "notes") + ordering = ("-date", "-start_time", "-created_at") diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..6c1978c --- /dev/null +++ b/core/forms.py @@ -0,0 +1,142 @@ +from decimal import Decimal + +from django import forms +from django.utils import timezone + +from .models import Trip + + +DATETIME_LOCAL_FORMAT = "%Y-%m-%dT%H:%M" + + +class TripForm(forms.ModelForm): + created_at_override = forms.DateTimeField( + required=False, + label="Custom created timestamp", + input_formats=[DATETIME_LOCAL_FORMAT], + widget=forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control", "placeholder": "Leave blank to use now"}, format=DATETIME_LOCAL_FORMAT), + help_text="Optional for retroactive entries. Leave blank to use the current timestamp.", + ) + updated_at_override = forms.DateTimeField( + required=False, + label="Custom updated timestamp", + input_formats=[DATETIME_LOCAL_FORMAT], + widget=forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control", "placeholder": "Leave blank to auto-update"}, format=DATETIME_LOCAL_FORMAT), + help_text="Optional. If empty while editing, the app will stamp the current time.", + ) + update_end_odometer_from_map = forms.BooleanField( + required=False, + label="Use Google Maps miles to prefill ending odometer", + help_text="If a starting odometer and route miles are available, this suggests an ending odometer.", + ) + + class Meta: + model = Trip + fields = ["date", "start_time", "end_time", "start_location", "end_location", "business_purpose", "trip_type", "start_odometer", "end_odometer", "distance_miles", "notes"] + widgets = { + "date": forms.DateInput(attrs={"type": "date", "class": "form-control"}), + "start_time": forms.TimeInput(attrs={"type": "time", "class": "form-control"}), + "end_time": forms.TimeInput(attrs={"type": "time", "class": "form-control"}), + "start_location": forms.TextInput(attrs={"class": "form-control", "placeholder": "123 Market St, San Francisco"}), + "end_location": forms.TextInput(attrs={"class": "form-control", "placeholder": "Client office, warehouse, property, etc."}), + "business_purpose": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Meeting with Client A at Property X"}), + "trip_type": forms.Select(attrs={"class": "form-select"}), + "start_odometer": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Optional"}), + "end_odometer": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Optional"}), + "distance_miles": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Auto-filled from Google Maps or manual"}), + "notes": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Client, project, repair details, or extra notes"}), + } + help_texts = { + "business_purpose": "Be specific so the trip is audit-friendly.", + "distance_miles": "Preferred source is odometer when both readings are present; otherwise Google Maps mileage is used.", + } + + def __init__(self, *args, latest_end_odometer=None, **kwargs): + super().__init__(*args, **kwargs) + if not self.instance.pk and latest_end_odometer is not None and self.initial.get("start_odometer") in (None, ""): + self.initial["start_odometer"] = latest_end_odometer + + + def clean_business_purpose(self): + purpose = (self.cleaned_data.get("business_purpose") or "").strip() + if len(purpose) < 8: + raise forms.ValidationError("Please add a more specific business purpose for this trip.") + return purpose + + def clean(self): + cleaned_data = super().clean() + start_odometer = cleaned_data.get("start_odometer") + end_odometer = cleaned_data.get("end_odometer") + distance_miles = cleaned_data.get("distance_miles") + should_prefill_end = cleaned_data.get("update_end_odometer_from_map") + + if start_odometer is not None and end_odometer is not None and end_odometer < start_odometer: + self.add_error("end_odometer", "Ending odometer must be greater than or equal to starting odometer.") + + if should_prefill_end and start_odometer is not None and distance_miles is not None and not end_odometer: + cleaned_data["end_odometer"] = Decimal(start_odometer) + Decimal(distance_miles) + self.cleaned_data["end_odometer"] = cleaned_data["end_odometer"] + + if start_odometer is None and end_odometer is not None: + self.add_error("start_odometer", "Add a starting odometer before storing an ending odometer.") + + if start_odometer is None and end_odometer is None and distance_miles is None: + self.add_error("distance_miles", "Calculate Google Maps mileage or enter odometer readings.") + + return cleaned_data + + def save(self, commit=True): + trip = super().save(commit=False) + now = timezone.now() + created_override = self.cleaned_data.get("created_at_override") + updated_override = self.cleaned_data.get("updated_at_override") + + if trip.pk: + trip.created_at = created_override or trip.created_at + trip.updated_at = updated_override or now + else: + trip.created_at = created_override or now + trip.updated_at = updated_override or trip.created_at + + if commit: + trip.save() + return trip + + +class TripFilterForm(forms.Form): + start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + month = forms.IntegerField(required=False, min_value=1, max_value=12, widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Month"})) + year = forms.IntegerField(required=False, min_value=2000, max_value=2100, widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Year"})) + trip_type = forms.ChoiceField(required=False, choices=[("", "All trip types"), *Trip.TripType.choices], widget=forms.Select(attrs={"class": "form-select"})) + + +class ReportFilterForm(forms.Form): + REPORT_CHOICES = [("month", "Monthly report"), ("range", "Custom date range"), ("year", "Annual summary")] + + report_type = forms.ChoiceField(choices=REPORT_CHOICES, required=False, initial="month", widget=forms.Select(attrs={"class": "form-select"})) + month = forms.IntegerField(required=False, min_value=1, max_value=12, widget=forms.NumberInput(attrs={"class": "form-control"})) + year = forms.IntegerField(required=False, min_value=2000, max_value=2100, widget=forms.NumberInput(attrs={"class": "form-control"})) + start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + + def clean(self): + cleaned = super().clean() + report_type = cleaned.get("report_type") or "month" + year = cleaned.get("year") + month = cleaned.get("month") + start_date = cleaned.get("start_date") + end_date = cleaned.get("end_date") + + if report_type == "month": + if not month or not year: + raise forms.ValidationError("Choose both month and year for the monthly report.") + elif report_type == "range": + if not start_date or not end_date: + raise forms.ValidationError("Choose both start and end dates for a custom range.") + if end_date < start_date: + raise forms.ValidationError("End date must be on or after the start date.") + elif report_type == "year": + if not year: + raise forms.ValidationError("Choose a year for the annual summary.") + return cleaned diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..b73a998 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.7 on 2026-03-24 11:31 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Trip', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('start_location', models.CharField(max_length=255)), + ('end_location', models.CharField(max_length=255)), + ('business_purpose', models.TextField()), + ('trip_type', models.CharField(choices=[('business', 'Business'), ('personal', 'Personal'), ('commuting', 'Commuting'), ('repair', 'Repair / Maintenance')], max_length=20)), + ('start_odometer', models.DecimalField(blank=True, decimal_places=1, max_digits=10, null=True)), + ('end_odometer', models.DecimalField(blank=True, decimal_places=1, max_digits=10, null=True)), + ('distance_miles', models.DecimalField(blank=True, decimal_places=1, max_digits=8, null=True)), + ('distance_source', models.CharField(choices=[('odometer', 'Odometer'), ('map', 'Google Maps')], default='map', max_length=20)), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(default=django.utils.timezone.now)), + ], + options={ + 'ordering': ['-date', '-start_time', '-created_at'], + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..bb347da Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..04d561d 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,72 @@ -from django.db import models +from decimal import Decimal -# Create your models here. +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils import timezone + + +class Trip(models.Model): + class TripType(models.TextChoices): + BUSINESS = "business", "Business" + PERSONAL = "personal", "Personal" + COMMUTING = "commuting", "Commuting" + REPAIR = "repair", "Repair / Maintenance" + + class DistanceSource(models.TextChoices): + ODOMETER = "odometer", "Odometer" + MAP = "map", "Google Maps" + + date = models.DateField() + start_time = models.TimeField() + end_time = models.TimeField() + start_location = models.CharField(max_length=255) + end_location = models.CharField(max_length=255) + business_purpose = models.TextField() + trip_type = models.CharField(max_length=20, choices=TripType.choices) + start_odometer = models.DecimalField(max_digits=10, decimal_places=1, null=True, blank=True) + end_odometer = models.DecimalField(max_digits=10, decimal_places=1, null=True, blank=True) + distance_miles = models.DecimalField(max_digits=8, decimal_places=1, null=True, blank=True) + distance_source = models.CharField(max_length=20, choices=DistanceSource.choices, default=DistanceSource.MAP) + notes = models.TextField(blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(default=timezone.now) + + class Meta: + ordering = ["-date", "-start_time", "-created_at"] + + def __str__(self): + return f"{self.date:%Y-%m-%d} · {self.start_location} → {self.end_location}" + + def get_absolute_url(self): + return reverse("trip_detail", args=[self.pk]) + + @property + def miles_display(self): + return self.distance_miles or Decimal("0.0") + + def clean(self): + errors = {} + if self.end_time and self.start_time and self.end_time < self.start_time: + errors["end_time"] = "End time must be after the start time." + + if self.start_odometer is not None and self.end_odometer is not None: + if self.end_odometer < self.start_odometer: + errors["end_odometer"] = "Ending odometer must be greater than or equal to starting odometer." + else: + self.distance_miles = self.end_odometer - self.start_odometer + self.distance_source = self.DistanceSource.ODOMETER + elif self.distance_miles is not None: + if self.distance_miles < 0: + errors["distance_miles"] = "Distance must be zero or greater." + else: + self.distance_source = self.DistanceSource.MAP + else: + errors["distance_miles"] = "Add odometer readings or calculate mileage from Google Maps." + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..22a2804 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,66 @@ +{% load static %} - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} + + {% block title %}{{ meta_title|default:"MileLedger" }}{% endblock %} + {% if project_image_url %} {% endif %} - {% load static %} + + + + {% block head %}{% endblock %} - - {% block content %}{% endblock %} - +
+
+
+
+ +
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
+
+ + + {% block scripts %}{% endblock %} + diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..9b32db5 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,142 @@ {% extends "base.html" %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}{{ meta_title }}{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+
Secure mobile mileage journal
+

Mileage logging that feels ready for tax season, even from your iPhone.

+

Capture retroactive business trips, reuse the last odometer reading, and keep an IRS-style log that is easy to review, export, and print.

+ +
Default next odometer{% if latest_end_odometer %}: {{ latest_end_odometer }} mi{% else %}: not set yet{% endif %}
+
+
+
+
+ Current month + {{ report_month }}/{{ report_year }} +
+
+
+

Business miles

+

{{ month_business|floatformat:1 }}

+ Captured this month +
+
+

Business miles YTD

+

{{ ytd_business|floatformat:1 }}

+ Ready for annual review +
+
+

Business trips this month

+

{{ business_trip_count }}

+ {% if last_trip %}Last trip {{ last_trip.date|date:"M j" }}{% else %}Add your first trip to start tracking{% endif %} +
+
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
- -{% endblock %} \ No newline at end of file + + +
+
+
+
+
+
+
+

Workflow

+

Fast entry, odometer continuity, and report-ready detail.

+
+
+
+
+
01
+

Trip input built for thumbs

+

Large fields, stacked layout, clear inline validation, and past-date support for retroactive mileage.

+
+
+
02
+

Google Maps assist

+

Estimate driving miles from your start and destination, then keep the distance editable if the real trip differed.

+
+
+
03
+

IRS-style reporting

+

Review recent trips, generate report totals, and export CSV for your CPA or year-end archive.

+
+
+
+
+ +
+
+
+ +
+
+
+
+

Recent entries

+

Your latest mileage activity

+
+ View all trips +
+
+ {% if empty_state %} +
+
+

No trips logged yet

+

Start with a business trip and MileLedger will begin carrying your odometer history forward.

+ Log your first trip +
+ {% else %} +
+ {% for trip in recent_trips %} +
+
+ {{ trip.get_trip_type_display }} + {{ trip.date|date:"M j, Y" }} · {{ trip.start_time|time:"g:i A" }}–{{ trip.end_time|time:"g:i A" }} +
+
+ {{ trip.start_location }} + + {{ trip.end_location }} +
+ +
+ {% endfor %} +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/core/templates/core/report.html b/core/templates/core/report.html new file mode 100644 index 0000000..61e7a7e --- /dev/null +++ b/core/templates/core/report.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+
+
+

IRS-style mileage log

+

Reports

+

Monthly, custom-range, and annual summaries with CSV export and print-friendly tables.

+
+
+ Log trip + +
+
+
+
+
+
+
+
{{ form.report_type }}
+
{{ form.month }}
+
{{ form.year }}
+
{{ form.start_date }}
+
{{ form.end_date }}
+
+ {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + +
+
+
+

Total business miles

{{ summary.business_miles|floatformat:1 }}

Primary number for deduction review
+

Personal / commuting / repair miles

{{ summary.non_business_miles|floatformat:1 }}

Useful context for year-end records
+

Total trips

{{ summary.trip_count }}

Entries in this report period
+
+
+ {% if trips %} +
+ + + + {% for trip in trips %} + + + + + + + + + + + + + {% endfor %} + +
DateStartEndFromToPurposeTypeStart odoEnd odoMiles
{{ trip.date|date:"Y-m-d" }}{{ trip.start_time|time:"H:i" }}{{ trip.end_time|time:"H:i" }}{{ trip.start_location }}{{ trip.end_location }}{{ trip.business_purpose }}{{ trip.get_trip_type_display }}{{ trip.start_odometer|default:"" }}{{ trip.end_odometer|default:"" }}{{ trip.distance_miles|floatformat:1 }}
+
+ {% else %} +

No trips in this report period

Run a different date range or add a new mileage entry.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/core/templates/core/trip_detail.html b/core/templates/core/trip_detail.html new file mode 100644 index 0000000..a824f0a --- /dev/null +++ b/core/templates/core/trip_detail.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+
+
+

Trip detail

+

{{ trip.date|date:"F j, Y" }}

+

{{ trip.start_location }} → {{ trip.end_location }}

+
+ +
+
+
+
+
+
+
+
+
Time{{ trip.start_time|time:"g:i A" }} – {{ trip.end_time|time:"g:i A" }}
+
Trip type{{ trip.get_trip_type_display }}
+
Miles{{ trip.distance_miles|floatformat:1 }} ({{ trip.get_distance_source_display }})
+
Odometer{% if trip.start_odometer or trip.end_odometer %}{{ trip.start_odometer|default:'—' }} → {{ trip.end_odometer|default:'—' }}{% else %}Not recorded{% endif %}
+
+
+

Business purpose

+

{{ trip.business_purpose }}

+

Notes

+

{{ trip.notes|default:"No additional notes recorded." }}

+
+
+
+
+
+

Audit trail

+
+

Created

{{ trip.created_at|date:"M j, Y g:i A" }}

+

Updated

{{ trip.updated_at|date:"M j, Y g:i A" }}

+
+ {% if confirm_delete %} +
+

Delete this trip?

+

This removes the mileage entry from your log.

+
{% csrf_token %}
Cancel
+
+ {% else %} + Delete trip + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/trip_form.html b/core/templates/core/trip_form.html new file mode 100644 index 0000000..adc9244 --- /dev/null +++ b/core/templates/core/trip_form.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+
+
+

Trip workflow

+

{{ page_heading }}

+

Capture the route, odometer readings, and audit-ready timestamps in one place.

+
+
+
+ +
+
+
+
+
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} +
+
+ + {{ form.date }} +
Past dates are supported for retroactive entries.
+ {% for error in form.date.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.start_time }} + {% for error in form.start_time.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.end_time }} + {% for error in form.end_time.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.start_location }} + {% for error in form.start_location.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.end_location }} + {% for error in form.end_location.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.business_purpose }} +
{{ form.business_purpose.help_text }}
+ {% for error in form.business_purpose.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.trip_type }} + {% for error in form.trip_type.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.start_odometer }} +
{% if latest_end_odometer %}Suggested from your most recent ending odometer: {{ latest_end_odometer }} miles.{% else %}Optional, but recommended for audit-ready logs.{% endif %}
+ {% for error in form.start_odometer.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.end_odometer }} + {% for error in form.end_odometer.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.distance_miles }} +
{{ form.distance_miles.help_text }}
+ {% for error in form.distance_miles.errors %}
{{ error }}
{% endfor %} +
+
+
+ +
Enter both locations to calculate route mileage.
+
+
+
+
+ {{ form.update_end_odometer_from_map }} + +
+
{{ form.update_end_odometer_from_map.help_text }}
+
+
+ + {{ form.notes }} + {% for error in form.notes.errors %}
{{ error }}
{% endfor %} +
+
+
+
+
+

Audit trail

+

Timestamp controls

+
+
+
+
+ + {{ form.created_at_override }} +
{{ form.created_at_override.help_text }}
+ {% for error in form.created_at_override.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.updated_at_override }} +
{{ form.updated_at_override.help_text }}
+ {% for error in form.updated_at_override.errors %}
{{ error }}
{% endfor %} +
+
+ {% if trip %}

Current stored timestamps: created {{ trip.created_at|date:"M j, Y g:i A" }} · updated {{ trip.updated_at|date:"M j, Y g:i A" }}

{% endif %} +
+
+ + Back to trip list +
+
+
+
+
+
+

How mileage is chosen

+
+

1. Odometer first

If both start and end odometer readings are present, MileLedger saves that difference as the primary trip distance.

+

2. Google Maps fallback

If odometer readings are incomplete, the distance field stores the Google Maps estimate instead.

+

3. Human review stays in control

You can edit route miles, adjust timestamps, and confirm the ending odometer before saving.

+
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/core/templates/core/trip_list.html b/core/templates/core/trip_list.html new file mode 100644 index 0000000..74b2593 --- /dev/null +++ b/core/templates/core/trip_list.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+
+
+

History

+

Trip log

+

Filter past mileage entries by date range, tax period, or trip type.

+
+ Add another trip +
+
+
+
+
+
+
{{ form.start_date }}
+
{{ form.end_date }}
+
{{ form.month }}
+
{{ form.year }}
+
{{ form.trip_type }}
+
Reset
+
+
+
+

Total business miles

{{ summary.business_miles|floatformat:1 }}

Within the filtered period
+

Personal + commuting + repair miles

{{ summary.non_business_miles|floatformat:1 }}

Included for full context
+

Total trips

{{ summary.trip_count }}

Entries matched by the current filters
+
+
+ {% if trips %} +
+ + + + {% for trip in trips %} + + + + + + + + + {% endfor %} + +
DateRouteTypeMilesSource
{{ trip.date|date:"M j, Y" }}
{{ trip.start_time|time:"g:i A" }}–{{ trip.end_time|time:"g:i A" }}
{{ trip.start_location }}
to {{ trip.end_location }}
{{ trip.get_trip_type_display }}{{ trip.distance_miles|floatformat:1 }}{{ trip.get_distance_source_display }}Details
+
+ {% else %} +

No trips match those filters

Adjust the date range or log a new trip to populate your history.

Log a trip
+ {% endif %} +
+
+
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index 7ce503c..a820138 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,38 @@ -from django.test import TestCase +from decimal import Decimal -# Create your tests here. +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from .models import Trip + + +class TripWorkflowTests(TestCase): + def setUp(self): + Trip.objects.create( + date=timezone.localdate(), + start_time="09:00", + end_time="10:00", + start_location="Office", + end_location="Client Site", + business_purpose="Meeting with client about quarterly roadmap", + trip_type=Trip.TripType.BUSINESS, + start_odometer=Decimal("100.0"), + end_odometer=Decimal("112.5"), + ) + + def test_homepage_loads(self): + response = self.client.get(reverse("home")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Mileage logging that feels ready for tax season") + + def test_trip_list_loads(self): + response = self.client.get(reverse("trip_list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Client Site") + + def test_report_csv_exports(self): + response = self.client.get(reverse("report_export_csv"), {"report_type": "year", "year": timezone.localdate().year}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv") + self.assertIn("Miles for this trip", response.content.decode("utf-8")) diff --git a/core/urls.py b/core/urls.py index 6299e3d..2d591d9 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,15 @@ from django.urls import path -from .views import home +from .views import distance_estimate, home, report_export_csv, report_view, trip_create, trip_delete, trip_detail, trip_list, trip_update urlpatterns = [ path("", home, name="home"), + path("trips/", trip_list, name="trip_list"), + path("trips/new/", trip_create, name="trip_create"), + path("trips//", trip_detail, name="trip_detail"), + path("trips//edit/", trip_update, name="trip_update"), + path("trips//delete/", trip_delete, name="trip_delete"), + path("reports/", report_view, name="report_view"), + path("reports/export.csv", report_export_csv, name="report_export_csv"), + path("distance/estimate/", distance_estimate, name="distance_estimate"), ] diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..64f419e --- /dev/null +++ b/core/utils.py @@ -0,0 +1,97 @@ +import csv +import json +import os +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP +from io import StringIO +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import urlopen + +from django.db.models import DecimalField, Q, Sum, Value +from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear + +from .models import Trip + + +@dataclass +class DistanceResult: + ok: bool + miles: Decimal | None = None + message: str = "" + + +def calculate_google_maps_distance(start_location: str, end_location: str) -> DistanceResult: + api_key = os.getenv("GOOGLE_MAPS_API_KEY", "").strip() + if not api_key: + return DistanceResult(ok=False, message="Google Maps API key is not configured yet. Add GOOGLE_MAPS_API_KEY to enable route mileage.") + + params = urlencode({"origins": start_location, "destinations": end_location, "mode": "driving", "units": "imperial", "key": api_key}) + url = f"https://maps.googleapis.com/maps/api/distancematrix/json?{params}" + + try: + with urlopen(url, timeout=12) as response: + payload = json.loads(response.read().decode("utf-8")) + except (HTTPError, URLError, TimeoutError): + return DistanceResult(ok=False, message="We could not reach Google Maps right now. Please try again or enter miles manually.") + + if payload.get("status") != "OK": + return DistanceResult(ok=False, message="Google Maps could not validate that route. Please refine both addresses.") + + rows = payload.get("rows") or [] + elements = (rows[0].get("elements") if rows else []) or [] + element = elements[0] if elements else {} + if element.get("status") != "OK": + return DistanceResult(ok=False, message="Google Maps could not find that route. Try a fuller street address or city/state.") + + meters = element.get("distance", {}).get("value") + if meters is None: + return DistanceResult(ok=False, message="Google Maps did not return a distance for that route.") + + miles = (Decimal(str(meters)) / Decimal("1609.344")).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) + return DistanceResult(ok=True, miles=miles, message="Driving mileage calculated from Google Maps.") + + +def apply_trip_filters(queryset, data): + if data.get("start_date"): + queryset = queryset.filter(date__gte=data["start_date"]) + if data.get("end_date"): + queryset = queryset.filter(date__lte=data["end_date"]) + if data.get("month"): + queryset = queryset.annotate(filter_month=ExtractMonth("date")).filter(filter_month=data["month"]) + if data.get("year"): + queryset = queryset.annotate(filter_year=ExtractYear("date")).filter(filter_year=data["year"]) + if data.get("trip_type"): + queryset = queryset.filter(trip_type=data["trip_type"]) + return queryset + + +def report_queryset(cleaned_data): + queryset = Trip.objects.all() + report_type = cleaned_data.get("report_type") + if report_type == "month": + queryset = queryset.filter(date__month=cleaned_data["month"], date__year=cleaned_data["year"]) + elif report_type == "range": + queryset = queryset.filter(date__range=(cleaned_data["start_date"], cleaned_data["end_date"])) + elif report_type == "year": + queryset = queryset.filter(date__year=cleaned_data["year"]) + return queryset.order_by("date", "start_time", "created_at") + + +def summarize_trips(queryset): + aggregates = queryset.aggregate( + business_miles=Coalesce(Sum("distance_miles", filter=Q(trip_type=Trip.TripType.BUSINESS)), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)), + non_business_miles=Coalesce(Sum("distance_miles", filter=~Q(trip_type=Trip.TripType.BUSINESS)), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)), + total_miles=Coalesce(Sum("distance_miles"), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)), + ) + aggregates["trip_count"] = queryset.count() + return aggregates + + +def export_trips_csv(trips): + buffer = StringIO() + writer = csv.writer(buffer) + writer.writerow(["Date of trip", "Start time", "End time", "Starting location", "Destination", "Business purpose", "Trip type", "Starting odometer", "Ending odometer", "Miles for this trip", "Distance source", "Notes", "Created at", "Updated at"]) + for trip in trips: + writer.writerow([trip.date, trip.start_time, trip.end_time, trip.start_location, trip.end_location, trip.business_purpose, trip.get_trip_type_display(), trip.start_odometer or "", trip.end_odometer or "", trip.distance_miles or "", trip.get_distance_source_display(), trip.notes, trip.created_at, trip.updated_at]) + return buffer.getvalue() diff --git a/core/views.py b/core/views.py index c9aed12..eff7559 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,146 @@ -import os -import platform +from decimal import Decimal -from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.db.models import Sum +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone +from django.views.decorators.http import require_GET, require_http_methods, require_POST + +from .forms import ReportFilterForm, TripFilterForm, TripForm +from .models import Trip +from .utils import apply_trip_filters, calculate_google_maps_distance, export_trips_csv, report_queryset, summarize_trips + + +DEFAULT_META_DESCRIPTION = "Mobile-friendly mileage logging with odometer history, Google Maps assist, and IRS-style reporting." + + +def latest_trip_with_odometer(): + return Trip.objects.exclude(end_odometer__isnull=True).order_by("-date", "-end_time", "-created_at").first() def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() + today = timezone.localdate() + current_month = Trip.objects.filter(date__year=today.year, date__month=today.month) + ytd = Trip.objects.filter(date__year=today.year) + recent_trips = Trip.objects.all()[:5] + + month_business = current_month.filter(trip_type=Trip.TripType.BUSINESS).aggregate(total=Sum("distance_miles"))["total"] or Decimal("0.0") + ytd_business = ytd.filter(trip_type=Trip.TripType.BUSINESS).aggregate(total=Sum("distance_miles"))["total"] or Decimal("0.0") + business_trip_count = current_month.filter(trip_type=Trip.TripType.BUSINESS).count() + last_trip = Trip.objects.order_by("-date", "-end_time", "-created_at").first() + latest_odo = latest_trip_with_odometer() context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + "project_name": "MileLedger", + "meta_title": "MileLedger | IRS-friendly mileage tracking for mobile", + "meta_description": DEFAULT_META_DESCRIPTION, + "month_business": month_business, + "ytd_business": ytd_business, + "business_trip_count": business_trip_count, + "recent_trips": recent_trips, + "last_trip": last_trip, + "latest_end_odometer": latest_odo.end_odometer if latest_odo else None, + "report_month": today.month, + "report_year": today.year, + "empty_state": not Trip.objects.exists(), } return render(request, "core/index.html", context) + + +@require_http_methods(["GET", "POST"]) +def trip_create(request): + latest_odo = latest_trip_with_odometer() + latest_end_odometer = latest_odo.end_odometer if latest_odo else None + if request.method == "POST": + form = TripForm(request.POST, latest_end_odometer=latest_end_odometer) + if form.is_valid(): + trip = form.save() + messages.success(request, "Trip saved. Your mileage log is updated and ready for review.") + return redirect("trip_detail", pk=trip.pk) + else: + now = timezone.localtime(timezone.now()) + form = TripForm(initial={"date": timezone.localdate(), "start_time": now.strftime("%H:%M"), "end_time": now.strftime("%H:%M"), "trip_type": Trip.TripType.BUSINESS}, latest_end_odometer=latest_end_odometer) + + return render(request, "core/trip_form.html", {"form": form, "page_heading": "Add a mileage entry", "submit_label": "Save trip", "meta_title": "Log a trip | MileLedger", "meta_description": DEFAULT_META_DESCRIPTION, "latest_end_odometer": latest_end_odometer, "trip": None}) + + +@require_http_methods(["GET", "POST"]) +def trip_update(request, pk): + trip = get_object_or_404(Trip, pk=pk) + if request.method == "POST": + form = TripForm(request.POST, instance=trip) + if form.is_valid(): + trip = form.save() + messages.success(request, "Trip updated. Your mileage history now reflects the latest details.") + return redirect("trip_detail", pk=trip.pk) + else: + form = TripForm(instance=trip) + + return render(request, "core/trip_form.html", {"form": form, "page_heading": "Update trip details", "submit_label": "Save changes", "meta_title": "Edit trip | MileLedger", "meta_description": DEFAULT_META_DESCRIPTION, "trip": trip, "latest_end_odometer": None}) + + +@require_GET +def trip_list(request): + form = TripFilterForm(request.GET or None) + trips = Trip.objects.all() + if form.is_valid(): + trips = apply_trip_filters(trips, form.cleaned_data) + context = {"form": form, "trips": trips[:100], "summary": summarize_trips(trips), "meta_title": "Trip history | MileLedger", "meta_description": "Review, filter, and audit mileage entries."} + return render(request, "core/trip_list.html", context) + + +@require_GET +def trip_detail(request, pk): + trip = get_object_or_404(Trip, pk=pk) + return render(request, "core/trip_detail.html", {"trip": trip, "meta_title": f"Trip on {trip.date:%b %d, %Y} | MileLedger", "meta_description": "Trip detail and audit-ready timestamps."}) + + +@require_http_methods(["GET", "POST"]) +def trip_delete(request, pk): + trip = get_object_or_404(Trip, pk=pk) + if request.method == "POST": + trip.delete() + messages.success(request, "Trip deleted.") + return redirect("trip_list") + return render(request, "core/trip_detail.html", {"trip": trip, "confirm_delete": True, "meta_title": "Delete trip | MileLedger", "meta_description": "Confirm trip deletion."}) + + +@require_GET +def report_view(request): + today = timezone.localdate() + initial = {"report_type": "month", "month": today.month, "year": today.year} + form = ReportFilterForm(request.GET or initial) + trips = Trip.objects.none() + summary = {"business_miles": Decimal("0.0"), "non_business_miles": Decimal("0.0"), "total_miles": Decimal("0.0"), "trip_count": 0} + if form.is_valid(): + trips = report_queryset(form.cleaned_data) + summary = summarize_trips(trips) + return render(request, "core/report.html", {"form": form, "trips": trips, "summary": summary, "meta_title": "IRS-style mileage report | MileLedger", "meta_description": "Generate monthly, annual, or custom mileage reports and export them to CSV."}) + + +@require_GET +def report_export_csv(request): + today = timezone.localdate() + initial = {"report_type": "month", "month": today.month, "year": today.year} + form = ReportFilterForm(request.GET or initial) + if not form.is_valid(): + messages.error(request, "Choose a valid report filter before exporting.") + return redirect("report_view") + + trips = report_queryset(form.cleaned_data) + response = HttpResponse(export_trips_csv(trips), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="mileage-report.csv"' + return response + + +@require_POST +def distance_estimate(request): + start_location = (request.POST.get("start_location") or "").strip() + end_location = (request.POST.get("end_location") or "").strip() + if not start_location or not end_location: + return JsonResponse({"ok": False, "message": "Enter both a start and destination first."}, status=400) + + result = calculate_google_maps_distance(start_location, end_location) + status = 200 if result.ok else 422 + return JsonResponse({"ok": result.ok, "miles": float(result.miles) if result.miles is not None else None, "message": result.message}, status=status) diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..8633937 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,130 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; + +:root { + --app-bg: #f6f2ea; + --app-surface: rgba(255, 252, 248, 0.8); + --app-surface-strong: #fffaf3; + --app-border: rgba(17, 24, 39, 0.1); + --app-text: #18212b; + --app-muted: #5f6875; + --app-primary: #0e776f; + --app-primary-dark: #0a5d57; + --app-secondary: #152033; + --app-accent: #f07a56; + --app-highlight: #f2c66c; + --app-shadow: 0 28px 60px rgba(17, 24, 39, 0.12); + --app-radius-xl: 28px; + --app-radius-lg: 22px; + --app-radius-md: 16px; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + min-height: 100vh; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + color: var(--app-text); + background: radial-gradient(circle at top left, rgba(240, 122, 86, 0.15), transparent 34%), radial-gradient(circle at 85% 15%, rgba(14, 119, 111, 0.18), transparent 24%), linear-gradient(180deg, #fcfaf6 0%, var(--app-bg) 100%); +} +h1,h2,h3,h4,h5,h6,.navbar-brand,.btn { font-family: 'Manrope', 'Inter', sans-serif; } +a { color: var(--app-primary); text-decoration: none; } +a:hover { color: var(--app-primary-dark); } +.site-shell { position: relative; overflow: hidden; } +.hero-grid { + position: fixed; inset: 0; + background-image: linear-gradient(rgba(17, 24, 39, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(17, 24, 39, 0.03) 1px, transparent 1px); + background-size: 42px 42px; mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 75%); + pointer-events: none; z-index: -2; +} +.hero-orb { position: fixed; border-radius: 50%; pointer-events: none; z-index: -1; } +.orb-1 { width: 320px; height: 320px; top: -96px; right: -90px; background: radial-gradient(circle, rgba(240, 122, 86, 0.28), rgba(240, 122, 86, 0) 68%); } +.orb-2 { width: 400px; height: 400px; bottom: -160px; left: -120px; background: radial-gradient(circle, rgba(14, 119, 111, 0.18), rgba(14, 119, 111, 0) 70%); } +.site-header { padding: 1rem 0; backdrop-filter: blur(20px); background: rgba(250, 245, 238, 0.72); border-bottom: 1px solid rgba(17, 24, 39, 0.05); } +.app-navbar { padding: 0; } +.brand-lockup { display: inline-flex; align-items: center; gap: 0.85rem; } +.brand-badge { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 34px rgba(14, 119, 111, 0.22); } +.brand-name,.brand-tag { display: block; line-height: 1.05; } +.brand-name { font-size: 1rem; font-weight: 800; color: var(--app-secondary); } +.brand-tag { font-size: 0.75rem; color: var(--app-muted); letter-spacing: 0.04em; text-transform: uppercase; margin-top: 0.2rem; } +.nav-link { color: var(--app-muted); font-weight: 600; } +.nav-link:hover,.nav-link:focus { color: var(--app-secondary); } +.nav-toggle { border: 1px solid rgba(17, 24, 39, 0.08); border-radius: 14px; } +.hero-section,.page-hero-sm,.section-shell { position: relative; } +.hero-section { padding: 4.5rem 0 2rem; } +.page-hero-sm { padding: 3rem 0 1.25rem; } +.section-shell { padding: 1.5rem 0 3rem; } +.section-tight { padding-top: 0.5rem; } +.eyebrow,.section-kicker { display: inline-block; margin-bottom: 1rem; padding: 0.45rem 0.8rem; border-radius: 999px; color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } +.hero-title,.section-title { font-size: clamp(2.4rem, 4vw, 4.7rem); line-height: 0.98; letter-spacing: -0.04em; color: var(--app-secondary); margin-bottom: 1rem; } +.page-hero-sm .section-title,.h4.section-title { font-size: clamp(2rem, 3vw, 3rem); } +.hero-copy,.section-subtitle { max-width: 46rem; font-size: 1.05rem; color: var(--app-muted); line-height: 1.75; } +.hero-actions,.form-actions { margin-top: 2rem; } +.hero-note,.audit-caption,.table-muted,.field-help,.distance-status { color: var(--app-muted); font-size: 0.92rem; line-height: 1.55; } +.hero-note { margin-top: 1.15rem; } +.glass-panel,.metric-card,.feature-card,.action-card,.side-card,.trip-stream-item,.empty-state-card,.panel-solid { background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 250, 244, 0.78)); border: 1px solid var(--app-border); box-shadow: var(--app-shadow); border-radius: var(--app-radius-xl); } +.glass-panel { padding: clamp(1.25rem, 3vw, 2rem); backdrop-filter: blur(18px); } +.showcase-top,.section-heading-wrap,.trip-stream-footer,.distance-action-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.status-pill,.trip-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.8rem; border-radius: 999px; background: rgba(21, 32, 51, 0.08); color: var(--app-secondary); font-size: 0.8rem; font-weight: 700; } +.metric-stack,.side-stack,.action-list,.feature-grid,.trip-stream { display: grid; gap: 1rem; } +.metric-card,.feature-card,.side-card,.panel-solid { padding: 1.35rem; } +.metric-card h2 { font-size: clamp(2rem, 3.8vw, 3rem); line-height: 1; margin: 0.5rem 0; } +.metric-card p,.metric-card span,.feature-card p,.side-card p,.action-card span,.trip-stream-item p,.delete-box p { margin: 0; color: var(--app-muted); } +.metric-card p,.feature-card h3,.side-card h3,.action-card strong,.empty-state-card h3,.delete-box h3 { color: var(--app-secondary); } +.accent-card { background: linear-gradient(135deg, rgba(240, 122, 86, 0.16), rgba(242, 198, 108, 0.16)); } +.panel-solid { background: linear-gradient(180deg, #fffaf3, #fffdfa); } +.feature-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-top: 1.5rem; } +.feature-icon { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 1rem; font-size: 0.82rem; font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--app-secondary), #334155); } +.action-card { display: block; padding: 1.1rem 1.15rem; transition: transform 0.2s ease, border-color 0.2s ease; } +.action-card:hover { transform: translateY(-2px); border-color: rgba(14, 119, 111, 0.3); } +.trip-stream-item { padding: 1.2rem; } +.trip-stream-meta,.trip-stream-route,.trip-stream-footer { display: flex; gap: 0.85rem; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.trip-stream-route { margin: 0.9rem 0; font-size: 1rem; } +.route-arrow { color: var(--app-accent); font-weight: 700; } +.empty-state-card { position: relative; text-align: center; padding: clamp(2rem, 5vw, 3rem); } +.empty-orb { width: 92px; height: 92px; margin: 0 auto 1rem; border-radius: 28px; background: linear-gradient(135deg, rgba(14, 119, 111, 0.18), rgba(240, 122, 86, 0.2)); } +.compact-empty { text-align: left; } +.form-label { font-weight: 700; color: var(--app-secondary); margin-bottom: 0.5rem; } +.form-control,.form-select { min-height: 3.2rem; border-radius: 16px; border: 1px solid rgba(17, 24, 39, 0.1); background: rgba(255, 255, 255, 0.94); padding: 0.85rem 1rem; color: var(--app-text); } +textarea.form-control { min-height: 8.5rem; } +.form-control:focus,.form-select:focus,.form-check-input:focus,.btn:focus { border-color: rgba(14, 119, 111, 0.52); box-shadow: 0 0 0 0.22rem rgba(14, 119, 111, 0.15); } +.field-error { color: #b42318; font-size: 0.9rem; margin-top: 0.35rem; } +.app-check { display: flex; align-items: center; gap: 0.7rem; } +.form-check-input { width: 1.15rem; height: 1.15rem; margin-top: 0; } +.form-check-input:checked { background-color: var(--app-primary); border-color: var(--app-primary); } +.distance-status { padding: 0.85rem 1rem; border-radius: 16px; background: rgba(21, 32, 51, 0.06); } +.distance-status.is-success { color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); } +.distance-status.is-error { color: #b42318; background: rgba(212, 59, 48, 0.1); } +.timestamp-panel,.delete-box { padding: 1.2rem; border-radius: 22px; background: rgba(21, 32, 51, 0.04); border: 1px solid rgba(17, 24, 39, 0.06); } +.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; } +.detail-grid article,.detail-copy p { line-height: 1.65; } +.detail-grid span { display: block; color: var(--app-muted); font-size: 0.88rem; margin-bottom: 0.35rem; } +.detail-grid strong,.detail-copy h2,.delete-box h3 { color: var(--app-secondary); } +.app-btn-primary,.app-btn-secondary { min-height: 3rem; padding: 0.85rem 1.25rem; border-radius: 999px; font-weight: 800; letter-spacing: -0.01em; } +.app-btn-primary { border: none; color: #fff; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 32px rgba(14, 119, 111, 0.22); } +.app-btn-primary:hover,.app-btn-primary:focus { color: #fff; background: linear-gradient(135deg, var(--app-primary-dark), #0e8c84); } +.app-btn-secondary { color: var(--app-secondary); border-color: rgba(21, 32, 51, 0.14); background: rgba(255, 255, 255, 0.72); } +.app-btn-secondary:hover,.app-btn-secondary:focus { color: var(--app-secondary); border-color: rgba(14, 119, 111, 0.32); background: rgba(255, 255, 255, 0.9); } +.app-alert { border-radius: 18px; border: 1px solid rgba(17, 24, 39, 0.06); } +.section-link { color: var(--app-secondary); font-weight: 700; } +.table-panel { overflow: hidden; } +.app-table { --bs-table-bg: transparent; --bs-table-border-color: rgba(17, 24, 39, 0.08); } +.app-table thead th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--app-muted); border-bottom-width: 1px; padding-top: 1.1rem; padding-bottom: 1.1rem; } +.app-table tbody td { padding-top: 1rem; padding-bottom: 1rem; color: var(--app-text); } +.no-print { display: initial; } +@media (max-width: 991.98px) { + .hero-section { padding-top: 3rem; } + .hero-title,.section-title { max-width: 16ch; } + .site-header { padding: 0.85rem 0; } +} +@media (max-width: 767.98px) { + .hero-section,.page-hero-sm { padding-top: 2rem; } + .glass-panel,.metric-card,.feature-card,.side-card,.trip-stream-item,.panel-solid { border-radius: 22px; } + .distance-action-bar,.section-heading-wrap,.trip-stream-footer,.trip-stream-route,.trip-stream-meta { align-items: flex-start; flex-direction: column; } + .table-responsive { border-radius: 22px; } +} +@media print { + body { background: #fff; } + .site-header,.hero-orb,.hero-grid,.no-print,.app-alert { display: none !important; } + .glass-panel,.panel-solid,.metric-card { box-shadow: none; background: #fff; border: 1px solid #d6d6d6; } + .section-shell,.page-hero-sm { padding: 0; } + .report-table th,.report-table td { font-size: 0.78rem; } } diff --git a/static/js/mileage_app.js b/static/js/mileage_app.js new file mode 100644 index 0000000..2dbf4c9 --- /dev/null +++ b/static/js/mileage_app.js @@ -0,0 +1,81 @@ +document.addEventListener('DOMContentLoaded', () => { + const form = document.querySelector('#trip-form'); + if (!form) return; + + const startInput = form.querySelector('#id_start_location'); + const endInput = form.querySelector('#id_end_location'); + const distanceInput = form.querySelector('#id_distance_miles'); + const startOdometerInput = form.querySelector('#id_start_odometer'); + const endOdometerInput = form.querySelector('#id_end_odometer'); + const useMapCheckbox = form.querySelector('#id_update_end_odometer_from_map'); + const statusBox = document.querySelector('#distance-status'); + const button = document.querySelector('#calculate-distance-btn'); + const endpoint = form.dataset.distanceEndpoint; + const csrfToken = form.querySelector('[name=csrfmiddlewaretoken]')?.value; + + const setStatus = (message, mode = '') => { + statusBox.textContent = message; + statusBox.classList.remove('is-success', 'is-error'); + if (mode) statusBox.classList.add(mode); + }; + + const maybeUpdateEndOdometer = () => { + if (!useMapCheckbox.checked) return; + const startOdometer = parseFloat(startOdometerInput.value || ''); + const distanceMiles = parseFloat(distanceInput.value || ''); + if (!Number.isNaN(startOdometer) && !Number.isNaN(distanceMiles)) { + endOdometerInput.value = (startOdometer + distanceMiles).toFixed(1); + } + }; + + const calculateDistance = async () => { + const startLocation = startInput.value.trim(); + const endLocation = endInput.value.trim(); + if (!startLocation || !endLocation) { + setStatus('Enter both locations to calculate route mileage.'); + return; + } + + button.disabled = true; + setStatus('Calculating driving miles from Google Maps…'); + + const body = new URLSearchParams({ + start_location: startLocation, + end_location: endLocation, + csrfmiddlewaretoken: csrfToken, + }); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: body.toString(), + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + setStatus(payload.message || 'Mileage could not be calculated. Please refine the addresses.', 'is-error'); + return; + } + distanceInput.value = Number(payload.miles).toFixed(1); + maybeUpdateEndOdometer(); + setStatus(`${payload.message} You can still override the miles before saving.`, 'is-success'); + } catch (error) { + setStatus('Mileage could not be calculated right now. Please try again or enter miles manually.', 'is-error'); + } finally { + button.disabled = false; + } + }; + + button.addEventListener('click', calculateDistance); + [startInput, endInput].forEach((input) => { + input.addEventListener('blur', () => { + if (startInput.value.trim() && endInput.value.trim()) calculateDistance(); + }); + }); + useMapCheckbox.addEventListener('change', maybeUpdateEndOdometer); + startOdometerInput.addEventListener('input', maybeUpdateEndOdometer); + distanceInput.addEventListener('input', maybeUpdateEndOdometer); +}); diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..8633937 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,130 @@ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --app-bg: #f6f2ea; + --app-surface: rgba(255, 252, 248, 0.8); + --app-surface-strong: #fffaf3; + --app-border: rgba(17, 24, 39, 0.1); + --app-text: #18212b; + --app-muted: #5f6875; + --app-primary: #0e776f; + --app-primary-dark: #0a5d57; + --app-secondary: #152033; + --app-accent: #f07a56; + --app-highlight: #f2c66c; + --app-shadow: 0 28px 60px rgba(17, 24, 39, 0.12); + --app-radius-xl: 28px; + --app-radius-lg: 22px; + --app-radius-md: 16px; } +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } body { margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; min-height: 100vh; - text-align: center; - overflow: hidden; - position: relative; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + color: var(--app-text); + background: radial-gradient(circle at top left, rgba(240, 122, 86, 0.15), transparent 34%), radial-gradient(circle at 85% 15%, rgba(14, 119, 111, 0.18), transparent 24%), linear-gradient(180deg, #fcfaf6 0%, var(--app-bg) 100%); +} +h1,h2,h3,h4,h5,h6,.navbar-brand,.btn { font-family: 'Manrope', 'Inter', sans-serif; } +a { color: var(--app-primary); text-decoration: none; } +a:hover { color: var(--app-primary-dark); } +.site-shell { position: relative; overflow: hidden; } +.hero-grid { + position: fixed; inset: 0; + background-image: linear-gradient(rgba(17, 24, 39, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(17, 24, 39, 0.03) 1px, transparent 1px); + background-size: 42px 42px; mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 75%); + pointer-events: none; z-index: -2; +} +.hero-orb { position: fixed; border-radius: 50%; pointer-events: none; z-index: -1; } +.orb-1 { width: 320px; height: 320px; top: -96px; right: -90px; background: radial-gradient(circle, rgba(240, 122, 86, 0.28), rgba(240, 122, 86, 0) 68%); } +.orb-2 { width: 400px; height: 400px; bottom: -160px; left: -120px; background: radial-gradient(circle, rgba(14, 119, 111, 0.18), rgba(14, 119, 111, 0) 70%); } +.site-header { padding: 1rem 0; backdrop-filter: blur(20px); background: rgba(250, 245, 238, 0.72); border-bottom: 1px solid rgba(17, 24, 39, 0.05); } +.app-navbar { padding: 0; } +.brand-lockup { display: inline-flex; align-items: center; gap: 0.85rem; } +.brand-badge { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 34px rgba(14, 119, 111, 0.22); } +.brand-name,.brand-tag { display: block; line-height: 1.05; } +.brand-name { font-size: 1rem; font-weight: 800; color: var(--app-secondary); } +.brand-tag { font-size: 0.75rem; color: var(--app-muted); letter-spacing: 0.04em; text-transform: uppercase; margin-top: 0.2rem; } +.nav-link { color: var(--app-muted); font-weight: 600; } +.nav-link:hover,.nav-link:focus { color: var(--app-secondary); } +.nav-toggle { border: 1px solid rgba(17, 24, 39, 0.08); border-radius: 14px; } +.hero-section,.page-hero-sm,.section-shell { position: relative; } +.hero-section { padding: 4.5rem 0 2rem; } +.page-hero-sm { padding: 3rem 0 1.25rem; } +.section-shell { padding: 1.5rem 0 3rem; } +.section-tight { padding-top: 0.5rem; } +.eyebrow,.section-kicker { display: inline-block; margin-bottom: 1rem; padding: 0.45rem 0.8rem; border-radius: 999px; color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } +.hero-title,.section-title { font-size: clamp(2.4rem, 4vw, 4.7rem); line-height: 0.98; letter-spacing: -0.04em; color: var(--app-secondary); margin-bottom: 1rem; } +.page-hero-sm .section-title,.h4.section-title { font-size: clamp(2rem, 3vw, 3rem); } +.hero-copy,.section-subtitle { max-width: 46rem; font-size: 1.05rem; color: var(--app-muted); line-height: 1.75; } +.hero-actions,.form-actions { margin-top: 2rem; } +.hero-note,.audit-caption,.table-muted,.field-help,.distance-status { color: var(--app-muted); font-size: 0.92rem; line-height: 1.55; } +.hero-note { margin-top: 1.15rem; } +.glass-panel,.metric-card,.feature-card,.action-card,.side-card,.trip-stream-item,.empty-state-card,.panel-solid { background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 250, 244, 0.78)); border: 1px solid var(--app-border); box-shadow: var(--app-shadow); border-radius: var(--app-radius-xl); } +.glass-panel { padding: clamp(1.25rem, 3vw, 2rem); backdrop-filter: blur(18px); } +.showcase-top,.section-heading-wrap,.trip-stream-footer,.distance-action-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.status-pill,.trip-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.8rem; border-radius: 999px; background: rgba(21, 32, 51, 0.08); color: var(--app-secondary); font-size: 0.8rem; font-weight: 700; } +.metric-stack,.side-stack,.action-list,.feature-grid,.trip-stream { display: grid; gap: 1rem; } +.metric-card,.feature-card,.side-card,.panel-solid { padding: 1.35rem; } +.metric-card h2 { font-size: clamp(2rem, 3.8vw, 3rem); line-height: 1; margin: 0.5rem 0; } +.metric-card p,.metric-card span,.feature-card p,.side-card p,.action-card span,.trip-stream-item p,.delete-box p { margin: 0; color: var(--app-muted); } +.metric-card p,.feature-card h3,.side-card h3,.action-card strong,.empty-state-card h3,.delete-box h3 { color: var(--app-secondary); } +.accent-card { background: linear-gradient(135deg, rgba(240, 122, 86, 0.16), rgba(242, 198, 108, 0.16)); } +.panel-solid { background: linear-gradient(180deg, #fffaf3, #fffdfa); } +.feature-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-top: 1.5rem; } +.feature-icon { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 1rem; font-size: 0.82rem; font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--app-secondary), #334155); } +.action-card { display: block; padding: 1.1rem 1.15rem; transition: transform 0.2s ease, border-color 0.2s ease; } +.action-card:hover { transform: translateY(-2px); border-color: rgba(14, 119, 111, 0.3); } +.trip-stream-item { padding: 1.2rem; } +.trip-stream-meta,.trip-stream-route,.trip-stream-footer { display: flex; gap: 0.85rem; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.trip-stream-route { margin: 0.9rem 0; font-size: 1rem; } +.route-arrow { color: var(--app-accent); font-weight: 700; } +.empty-state-card { position: relative; text-align: center; padding: clamp(2rem, 5vw, 3rem); } +.empty-orb { width: 92px; height: 92px; margin: 0 auto 1rem; border-radius: 28px; background: linear-gradient(135deg, rgba(14, 119, 111, 0.18), rgba(240, 122, 86, 0.2)); } +.compact-empty { text-align: left; } +.form-label { font-weight: 700; color: var(--app-secondary); margin-bottom: 0.5rem; } +.form-control,.form-select { min-height: 3.2rem; border-radius: 16px; border: 1px solid rgba(17, 24, 39, 0.1); background: rgba(255, 255, 255, 0.94); padding: 0.85rem 1rem; color: var(--app-text); } +textarea.form-control { min-height: 8.5rem; } +.form-control:focus,.form-select:focus,.form-check-input:focus,.btn:focus { border-color: rgba(14, 119, 111, 0.52); box-shadow: 0 0 0 0.22rem rgba(14, 119, 111, 0.15); } +.field-error { color: #b42318; font-size: 0.9rem; margin-top: 0.35rem; } +.app-check { display: flex; align-items: center; gap: 0.7rem; } +.form-check-input { width: 1.15rem; height: 1.15rem; margin-top: 0; } +.form-check-input:checked { background-color: var(--app-primary); border-color: var(--app-primary); } +.distance-status { padding: 0.85rem 1rem; border-radius: 16px; background: rgba(21, 32, 51, 0.06); } +.distance-status.is-success { color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); } +.distance-status.is-error { color: #b42318; background: rgba(212, 59, 48, 0.1); } +.timestamp-panel,.delete-box { padding: 1.2rem; border-radius: 22px; background: rgba(21, 32, 51, 0.04); border: 1px solid rgba(17, 24, 39, 0.06); } +.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; } +.detail-grid article,.detail-copy p { line-height: 1.65; } +.detail-grid span { display: block; color: var(--app-muted); font-size: 0.88rem; margin-bottom: 0.35rem; } +.detail-grid strong,.detail-copy h2,.delete-box h3 { color: var(--app-secondary); } +.app-btn-primary,.app-btn-secondary { min-height: 3rem; padding: 0.85rem 1.25rem; border-radius: 999px; font-weight: 800; letter-spacing: -0.01em; } +.app-btn-primary { border: none; color: #fff; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 32px rgba(14, 119, 111, 0.22); } +.app-btn-primary:hover,.app-btn-primary:focus { color: #fff; background: linear-gradient(135deg, var(--app-primary-dark), #0e8c84); } +.app-btn-secondary { color: var(--app-secondary); border-color: rgba(21, 32, 51, 0.14); background: rgba(255, 255, 255, 0.72); } +.app-btn-secondary:hover,.app-btn-secondary:focus { color: var(--app-secondary); border-color: rgba(14, 119, 111, 0.32); background: rgba(255, 255, 255, 0.9); } +.app-alert { border-radius: 18px; border: 1px solid rgba(17, 24, 39, 0.06); } +.section-link { color: var(--app-secondary); font-weight: 700; } +.table-panel { overflow: hidden; } +.app-table { --bs-table-bg: transparent; --bs-table-border-color: rgba(17, 24, 39, 0.08); } +.app-table thead th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--app-muted); border-bottom-width: 1px; padding-top: 1.1rem; padding-bottom: 1.1rem; } +.app-table tbody td { padding-top: 1rem; padding-bottom: 1rem; color: var(--app-text); } +.no-print { display: initial; } +@media (max-width: 991.98px) { + .hero-section { padding-top: 3rem; } + .hero-title,.section-title { max-width: 16ch; } + .site-header { padding: 0.85rem 0; } +} +@media (max-width: 767.98px) { + .hero-section,.page-hero-sm { padding-top: 2rem; } + .glass-panel,.metric-card,.feature-card,.side-card,.trip-stream-item,.panel-solid { border-radius: 22px; } + .distance-action-bar,.section-heading-wrap,.trip-stream-footer,.trip-stream-route,.trip-stream-meta { align-items: flex-start; flex-direction: column; } + .table-responsive { border-radius: 22px; } +} +@media print { + body { background: #fff; } + .site-header,.hero-orb,.hero-grid,.no-print,.app-alert { display: none !important; } + .glass-panel,.panel-solid,.metric-card { box-shadow: none; background: #fff; border: 1px solid #d6d6d6; } + .section-shell,.page-hero-sm { padding: 0; } + .report-table th,.report-table td { font-size: 0.78rem; } } diff --git a/staticfiles/js/mileage_app.js b/staticfiles/js/mileage_app.js new file mode 100644 index 0000000..2dbf4c9 --- /dev/null +++ b/staticfiles/js/mileage_app.js @@ -0,0 +1,81 @@ +document.addEventListener('DOMContentLoaded', () => { + const form = document.querySelector('#trip-form'); + if (!form) return; + + const startInput = form.querySelector('#id_start_location'); + const endInput = form.querySelector('#id_end_location'); + const distanceInput = form.querySelector('#id_distance_miles'); + const startOdometerInput = form.querySelector('#id_start_odometer'); + const endOdometerInput = form.querySelector('#id_end_odometer'); + const useMapCheckbox = form.querySelector('#id_update_end_odometer_from_map'); + const statusBox = document.querySelector('#distance-status'); + const button = document.querySelector('#calculate-distance-btn'); + const endpoint = form.dataset.distanceEndpoint; + const csrfToken = form.querySelector('[name=csrfmiddlewaretoken]')?.value; + + const setStatus = (message, mode = '') => { + statusBox.textContent = message; + statusBox.classList.remove('is-success', 'is-error'); + if (mode) statusBox.classList.add(mode); + }; + + const maybeUpdateEndOdometer = () => { + if (!useMapCheckbox.checked) return; + const startOdometer = parseFloat(startOdometerInput.value || ''); + const distanceMiles = parseFloat(distanceInput.value || ''); + if (!Number.isNaN(startOdometer) && !Number.isNaN(distanceMiles)) { + endOdometerInput.value = (startOdometer + distanceMiles).toFixed(1); + } + }; + + const calculateDistance = async () => { + const startLocation = startInput.value.trim(); + const endLocation = endInput.value.trim(); + if (!startLocation || !endLocation) { + setStatus('Enter both locations to calculate route mileage.'); + return; + } + + button.disabled = true; + setStatus('Calculating driving miles from Google Maps…'); + + const body = new URLSearchParams({ + start_location: startLocation, + end_location: endLocation, + csrfmiddlewaretoken: csrfToken, + }); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: body.toString(), + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + setStatus(payload.message || 'Mileage could not be calculated. Please refine the addresses.', 'is-error'); + return; + } + distanceInput.value = Number(payload.miles).toFixed(1); + maybeUpdateEndOdometer(); + setStatus(`${payload.message} You can still override the miles before saving.`, 'is-success'); + } catch (error) { + setStatus('Mileage could not be calculated right now. Please try again or enter miles manually.', 'is-error'); + } finally { + button.disabled = false; + } + }; + + button.addEventListener('click', calculateDistance); + [startInput, endInput].forEach((input) => { + input.addEventListener('blur', () => { + if (startInput.value.trim() && endInput.value.trim()) calculateDistance(); + }); + }); + useMapCheckbox.addEventListener('change', maybeUpdateEndOdometer); + startOdometerInput.addEventListener('input', maybeUpdateEndOdometer); + distanceInput.addEventListener('input', maybeUpdateEndOdometer); +});