This commit is contained in:
Flatlogic Bot 2026-04-01 16:28:08 +00:00
parent 6ff85c8af9
commit 46ee143ab1
30 changed files with 1479 additions and 192 deletions

Binary file not shown.

Binary file not shown.

View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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")

142
core/forms.py Normal file
View File

@ -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

View File

@ -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'],
},
),
]

View File

@ -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)

View File

@ -1,25 +1,66 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title>
{% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ meta_title|default:"MileLedger" }}{% endblock %}</title>
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'IRS-friendly mileage tracking and reporting.' }}{% endblock %}">
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}">
{% endif %}
{% load static %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
</body>
<div class="site-shell">
<div class="hero-orb orb-1"></div>
<div class="hero-orb orb-2"></div>
<div class="hero-grid"></div>
<header class="site-header sticky-top">
<nav class="navbar navbar-expand-lg app-navbar">
<div class="container-xxl px-3 px-lg-4">
<a class="navbar-brand brand-lockup" href="{% url 'home' %}">
<span class="brand-badge">ML</span>
<span>
<span class="brand-name">MileLedger</span>
<span class="brand-tag">IRS-friendly vehicle log</span>
</span>
</a>
<button class="navbar-toggler nav-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'trip_list' %}">Trips</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'report_view' %}">Reports</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
<li class="nav-item ms-lg-2"><a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Log trip</a></li>
</ul>
</div>
</div>
</nav>
</header>
<main>
{% if messages %}
<div class="container-xxl px-3 px-lg-4 pt-4">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} app-alert mb-3" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -1,145 +1,142 @@
{% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
: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);
}
* {
box-sizing: border-box;
}
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;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block title %}{{ meta_title }}{% endblock %}
{% block content %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<section class="hero-section">
<div class="container-xxl px-3 px-lg-4">
<div class="row align-items-center g-4 g-xl-5">
<div class="col-lg-7">
<div class="eyebrow">Secure mobile mileage journal</div>
<h1 class="hero-title">Mileage logging that feels ready for tax season, even from your iPhone.</h1>
<p class="hero-copy">Capture retroactive business trips, reuse the last odometer reading, and keep an IRS-style log that is easy to review, export, and print.</p>
<div class="hero-actions d-flex flex-wrap gap-3">
<a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Log a trip</a>
<a class="btn btn-outline-light app-btn-secondary" href="{% url 'report_view' %}">Open reports</a>
</div>
<div class="hero-note">Default next odometer{% if latest_end_odometer %}: <strong>{{ latest_end_odometer }} mi</strong>{% else %}: <strong>not set yet</strong>{% endif %}</div>
</div>
<div class="col-lg-5">
<div class="glass-panel showcase-card">
<div class="showcase-top">
<span class="status-pill">Current month</span>
<span class="text-body-secondary small">{{ report_month }}/{{ report_year }}</span>
</div>
<div class="metric-stack">
<article class="metric-card">
<p>Business miles</p>
<h2>{{ month_business|floatformat:1 }}</h2>
<span>Captured this month</span>
</article>
<article class="metric-card accent-card">
<p>Business miles YTD</p>
<h2>{{ ytd_business|floatformat:1 }}</h2>
<span>Ready for annual review</span>
</article>
<article class="metric-card compact-card">
<p>Business trips this month</p>
<h2>{{ business_trip_count }}</h2>
<span>{% if last_trip %}Last trip {{ last_trip.date|date:"M j" }}{% else %}Add your first trip to start tracking{% endif %}</span>
</article>
</div>
</div>
</div>
</div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}
</section>
<section class="section-shell section-tight">
<div class="container-xxl px-3 px-lg-4">
<div class="row g-4">
<div class="col-lg-8">
<div class="glass-panel info-panel h-100">
<div class="section-heading-wrap">
<div>
<p class="section-kicker">Workflow</p>
<h2 class="section-title">Fast entry, odometer continuity, and report-ready detail.</h2>
</div>
</div>
<div class="feature-grid">
<article class="feature-card">
<div class="feature-icon">01</div>
<h3>Trip input built for thumbs</h3>
<p>Large fields, stacked layout, clear inline validation, and past-date support for retroactive mileage.</p>
</article>
<article class="feature-card">
<div class="feature-icon">02</div>
<h3>Google Maps assist</h3>
<p>Estimate driving miles from your start and destination, then keep the distance editable if the real trip differed.</p>
</article>
<article class="feature-card">
<div class="feature-icon">03</div>
<h3>IRS-style reporting</h3>
<p>Review recent trips, generate report totals, and export CSV for your CPA or year-end archive.</p>
</article>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="glass-panel quick-action-panel h-100">
<p class="section-kicker">Quick actions</p>
<div class="action-list">
<a href="{% url 'trip_create' %}" class="action-card">
<strong>Start a new trip log</strong>
<span>Prefills the next starting odometer when possible.</span>
</a>
<a href="{% url 'trip_list' %}" class="action-card">
<strong>Review trip history</strong>
<span>Filter by month, year, trip type, or custom date range.</span>
</a>
<a href="{% url 'report_view' %}" class="action-card">
<strong>Generate a mileage report</strong>
<span>Monthly, annual, printable, and CSV export included.</span>
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section-shell">
<div class="container-xxl px-3 px-lg-4">
<div class="section-heading-wrap mb-4">
<div>
<p class="section-kicker">Recent entries</p>
<h2 class="section-title">Your latest mileage activity</h2>
</div>
<a class="section-link" href="{% url 'trip_list' %}">View all trips</a>
</div>
<div class="glass-panel trip-stream-panel">
{% if empty_state %}
<div class="empty-state-card">
<div class="empty-orb"></div>
<h3>No trips logged yet</h3>
<p>Start with a business trip and MileLedger will begin carrying your odometer history forward.</p>
<a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Log your first trip</a>
</div>
{% else %}
<div class="trip-stream">
{% for trip in recent_trips %}
<article class="trip-stream-item">
<div class="trip-stream-meta">
<span class="trip-badge">{{ trip.get_trip_type_display }}</span>
<span>{{ trip.date|date:"M j, Y" }} · {{ trip.start_time|time:"g:i A" }}{{ trip.end_time|time:"g:i A" }}</span>
</div>
<div class="trip-stream-route">
<strong>{{ trip.start_location }}</strong>
<span class="route-arrow"></span>
<strong>{{ trip.end_location }}</strong>
</div>
<div class="trip-stream-footer">
<span>{{ trip.distance_miles|floatformat:1 }} miles · {{ trip.get_distance_source_display }}</span>
<a href="{% url 'trip_detail' trip.pk %}">Open details</a>
</div>
</article>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}{{ meta_title }}{% endblock %}
{% block content %}
<section class="page-hero-sm report-print-header">
<div class="container-xxl px-3 px-lg-4 d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
<div>
<p class="section-kicker">IRS-style mileage log</p>
<h1 class="section-title mb-2">Reports</h1>
<p class="section-subtitle">Monthly, custom-range, and annual summaries with CSV export and print-friendly tables.</p>
</div>
<div class="d-flex gap-2 flex-wrap no-print">
<a class="btn btn-outline-light app-btn-secondary" href="{% url 'trip_create' %}">Log trip</a>
<button class="btn btn-primary app-btn-primary" onclick="window.print()" type="button">Print / Save PDF</button>
</div>
</div>
</section>
<section class="section-shell section-tight pt-0">
<div class="container-xxl px-3 px-lg-4">
<div class="glass-panel mb-4 no-print">
<form method="get" class="row g-3 align-items-end">
<div class="col-md-3"><label class="form-label" for="{{ form.report_type.id_for_label }}">Report type</label>{{ form.report_type }}</div>
<div class="col-md-2"><label class="form-label" for="{{ form.month.id_for_label }}">Month</label>{{ form.month }}</div>
<div class="col-md-2"><label class="form-label" for="{{ form.year.id_for_label }}">Year</label>{{ form.year }}</div>
<div class="col-md-2"><label class="form-label" for="{{ form.start_date.id_for_label }}">Start date</label>{{ form.start_date }}</div>
<div class="col-md-2"><label class="form-label" for="{{ form.end_date.id_for_label }}">End date</label>{{ form.end_date }}</div>
<div class="col-md-1 d-grid"><button class="btn btn-primary app-btn-primary" type="submit">Run</button></div>
{% if form.non_field_errors %}<div class="col-12"><div class="alert alert-danger app-alert mb-0">{{ form.non_field_errors }}</div></div>{% endif %}
<div class="col-12 d-flex flex-wrap gap-2"><a class="btn btn-outline-light app-btn-secondary" href="{% url 'report_export_csv' %}?{{ request.GET.urlencode }}">Export CSV</a></div>
</form>
</div>
<div class="row g-4 mb-4">
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Total business miles</p><h2>{{ summary.business_miles|floatformat:1 }}</h2><span>Primary number for deduction review</span></div></div>
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Personal / commuting / repair miles</p><h2>{{ summary.non_business_miles|floatformat:1 }}</h2><span>Useful context for year-end records</span></div></div>
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Total trips</p><h2>{{ summary.trip_count }}</h2><span>Entries in this report period</span></div></div>
</div>
<div class="glass-panel table-panel printable-panel">
{% if trips %}
<div class="table-responsive">
<table class="table app-table report-table align-middle mb-0">
<thead><tr><th>Date</th><th>Start</th><th>End</th><th>From</th><th>To</th><th>Purpose</th><th>Type</th><th>Start odo</th><th>End odo</th><th>Miles</th></tr></thead>
<tbody>
{% for trip in trips %}
<tr>
<td>{{ trip.date|date:"Y-m-d" }}</td>
<td>{{ trip.start_time|time:"H:i" }}</td>
<td>{{ trip.end_time|time:"H:i" }}</td>
<td>{{ trip.start_location }}</td>
<td>{{ trip.end_location }}</td>
<td>{{ trip.business_purpose }}</td>
<td>{{ trip.get_trip_type_display }}</td>
<td>{{ trip.start_odometer|default:"" }}</td>
<td>{{ trip.end_odometer|default:"" }}</td>
<td>{{ trip.distance_miles|floatformat:1 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state-card compact-empty"><h3>No trips in this report period</h3><p>Run a different date range or add a new mileage entry.</p></div>
{% endif %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}{{ meta_title }}{% endblock %}
{% block content %}
<section class="page-hero-sm">
<div class="container-xxl px-3 px-lg-4 d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
<div>
<p class="section-kicker">Trip detail</p>
<h1 class="section-title mb-2">{{ trip.date|date:"F j, Y" }}</h1>
<p class="section-subtitle">{{ trip.start_location }} → {{ trip.end_location }}</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-outline-light app-btn-secondary" href="{% url 'trip_list' %}">Back to list</a>
<a class="btn btn-primary app-btn-primary" href="{% url 'trip_update' trip.pk %}">Edit trip</a>
</div>
</div>
</section>
<section class="section-shell section-tight pt-0">
<div class="container-xxl px-3 px-lg-4">
<div class="row g-4">
<div class="col-lg-8">
<div class="glass-panel detail-panel h-100">
<div class="detail-grid">
<article><span>Time</span><strong>{{ trip.start_time|time:"g:i A" }} {{ trip.end_time|time:"g:i A" }}</strong></article>
<article><span>Trip type</span><strong>{{ trip.get_trip_type_display }}</strong></article>
<article><span>Miles</span><strong>{{ trip.distance_miles|floatformat:1 }} ({{ trip.get_distance_source_display }})</strong></article>
<article><span>Odometer</span><strong>{% if trip.start_odometer or trip.end_odometer %}{{ trip.start_odometer|default:'—' }} → {{ trip.end_odometer|default:'—' }}{% else %}Not recorded{% endif %}</strong></article>
</div>
<div class="detail-copy mt-4">
<h2 class="h5">Business purpose</h2>
<p>{{ trip.business_purpose }}</p>
<h2 class="h5 mt-4">Notes</h2>
<p>{{ trip.notes|default:"No additional notes recorded." }}</p>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="glass-panel side-panel h-100">
<p class="section-kicker">Audit trail</p>
<div class="side-stack mb-4">
<article class="side-card"><h3>Created</h3><p>{{ trip.created_at|date:"M j, Y g:i A" }}</p></article>
<article class="side-card"><h3>Updated</h3><p>{{ trip.updated_at|date:"M j, Y g:i A" }}</p></article>
</div>
{% if confirm_delete %}
<div class="delete-box">
<h3>Delete this trip?</h3>
<p>This removes the mileage entry from your log.</p>
<form method="post" action="{% url 'trip_delete' trip.pk %}">{% csrf_token %}<div class="d-flex flex-wrap gap-2"><button class="btn btn-danger" type="submit">Confirm delete</button><a class="btn btn-outline-light app-btn-secondary" href="{% url 'trip_detail' trip.pk %}">Cancel</a></div></form>
</div>
{% else %}
<a class="btn btn-outline-danger w-100" href="{% url 'trip_delete' trip.pk %}">Delete trip</a>
{% endif %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,148 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ meta_title }}{% endblock %}
{% block content %}
<section class="page-hero-sm">
<div class="container-xxl px-3 px-lg-4">
<div class="page-hero-copy">
<p class="section-kicker">Trip workflow</p>
<h1 class="section-title mb-2">{{ page_heading }}</h1>
<p class="section-subtitle">Capture the route, odometer readings, and audit-ready timestamps in one place.</p>
</div>
</div>
</section>
<section class="section-shell section-tight pt-0">
<div class="container-xxl px-3 px-lg-4">
<div class="row g-4">
<div class="col-xl-8">
<div class="glass-panel form-shell">
<form method="post" novalidate id="trip-form" data-distance-endpoint="{% url 'distance_estimate' %}">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger app-alert">{{ form.non_field_errors }}</div>
{% endif %}
<div class="row g-3">
<div class="col-sm-4">
<label class="form-label" for="{{ form.date.id_for_label }}">Date of trip</label>
{{ form.date }}
<div class="field-help">Past dates are supported for retroactive entries.</div>
{% for error in form.date.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-sm-4">
<label class="form-label" for="{{ form.start_time.id_for_label }}">Start time</label>
{{ form.start_time }}
{% for error in form.start_time.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-sm-4">
<label class="form-label" for="{{ form.end_time.id_for_label }}">End time</label>
{{ form.end_time }}
{% for error in form.end_time.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-12">
<label class="form-label" for="{{ form.start_location.id_for_label }}">Starting location</label>
{{ form.start_location }}
{% for error in form.start_location.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-12">
<label class="form-label" for="{{ form.end_location.id_for_label }}">Destination / ending location</label>
{{ form.end_location }}
{% for error in form.end_location.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-lg-8">
<label class="form-label" for="{{ form.business_purpose.id_for_label }}">Business purpose</label>
{{ form.business_purpose }}
<div class="field-help">{{ form.business_purpose.help_text }}</div>
{% for error in form.business_purpose.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-lg-4">
<label class="form-label" for="{{ form.trip_type.id_for_label }}">Trip type</label>
{{ form.trip_type }}
{% for error in form.trip_type.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-sm-4">
<label class="form-label" for="{{ form.start_odometer.id_for_label }}">Starting odometer</label>
{{ form.start_odometer }}
<div class="field-help">{% if latest_end_odometer %}Suggested from your most recent ending odometer: {{ latest_end_odometer }} miles.{% else %}Optional, but recommended for audit-ready logs.{% endif %}</div>
{% for error in form.start_odometer.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-sm-4">
<label class="form-label" for="{{ form.end_odometer.id_for_label }}">Ending odometer</label>
{{ form.end_odometer }}
{% for error in form.end_odometer.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-sm-4">
<label class="form-label" for="{{ form.distance_miles.id_for_label }}">Distance miles</label>
{{ form.distance_miles }}
<div class="field-help">{{ form.distance_miles.help_text }}</div>
{% for error in form.distance_miles.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-12">
<div class="distance-action-bar">
<button type="button" class="btn btn-outline-light app-btn-secondary" id="calculate-distance-btn">Calculate with Google Maps</button>
<div id="distance-status" class="distance-status">Enter both locations to calculate route mileage.</div>
</div>
</div>
<div class="col-12">
<div class="form-check app-check">
{{ form.update_end_odometer_from_map }}
<label class="form-check-label" for="{{ form.update_end_odometer_from_map.id_for_label }}">{{ form.update_end_odometer_from_map.label }}</label>
</div>
<div class="field-help">{{ form.update_end_odometer_from_map.help_text }}</div>
</div>
<div class="col-12">
<label class="form-label" for="{{ form.notes.id_for_label }}">Notes</label>
{{ form.notes }}
{% for error in form.notes.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
</div>
<div class="timestamp-panel mt-4">
<div class="section-heading-wrap mb-3">
<div>
<p class="section-kicker">Audit trail</p>
<h2 class="section-title h4 mb-0">Timestamp controls</h2>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="{{ form.created_at_override.id_for_label }}">{{ form.created_at_override.label }}</label>
{{ form.created_at_override }}
<div class="field-help">{{ form.created_at_override.help_text }}</div>
{% for error in form.created_at_override.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.updated_at_override.id_for_label }}">{{ form.updated_at_override.label }}</label>
{{ form.updated_at_override }}
<div class="field-help">{{ form.updated_at_override.help_text }}</div>
{% for error in form.updated_at_override.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
</div>
{% if trip %}<p class="audit-caption mt-3 mb-0">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" }}</p>{% endif %}
</div>
<div class="form-actions d-flex flex-wrap gap-3 mt-4">
<button type="submit" class="btn btn-primary app-btn-primary">{{ submit_label }}</button>
<a href="{% url 'trip_list' %}" class="btn btn-outline-light app-btn-secondary">Back to trip list</a>
</div>
</form>
</div>
</div>
<div class="col-xl-4">
<div class="glass-panel side-panel h-100">
<p class="section-kicker">How mileage is chosen</p>
<div class="side-stack">
<article class="side-card"><h3>1. Odometer first</h3><p>If both start and end odometer readings are present, MileLedger saves that difference as the primary trip distance.</p></article>
<article class="side-card"><h3>2. Google Maps fallback</h3><p>If odometer readings are incomplete, the distance field stores the Google Maps estimate instead.</p></article>
<article class="side-card"><h3>3. Human review stays in control</h3><p>You can edit route miles, adjust timestamps, and confirm the ending odometer before saving.</p></article>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{% static 'js/mileage_app.js' %}?v={{ deployment_timestamp }}"></script>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ meta_title }}{% endblock %}
{% block content %}
<section class="page-hero-sm">
<div class="container-xxl px-3 px-lg-4 d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
<div>
<p class="section-kicker">History</p>
<h1 class="section-title mb-2">Trip log</h1>
<p class="section-subtitle">Filter past mileage entries by date range, tax period, or trip type.</p>
</div>
<a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Add another trip</a>
</div>
</section>
<section class="section-shell section-tight pt-0">
<div class="container-xxl px-3 px-lg-4">
<div class="glass-panel mb-4">
<form method="get" class="row g-3 align-items-end">
<div class="col-md-3"><label class="form-label" for="{{ form.start_date.id_for_label }}">Start date</label>{{ form.start_date }}</div>
<div class="col-md-3"><label class="form-label" for="{{ form.end_date.id_for_label }}">End date</label>{{ form.end_date }}</div>
<div class="col-6 col-md-2"><label class="form-label" for="{{ form.month.id_for_label }}">Month</label>{{ form.month }}</div>
<div class="col-6 col-md-2"><label class="form-label" for="{{ form.year.id_for_label }}">Year</label>{{ form.year }}</div>
<div class="col-md-2"><label class="form-label" for="{{ form.trip_type.id_for_label }}">Trip type</label>{{ form.trip_type }}</div>
<div class="col-12 d-flex flex-wrap gap-2"><button class="btn btn-primary app-btn-primary" type="submit">Apply filters</button><a class="btn btn-outline-light app-btn-secondary" href="{% url 'trip_list' %}">Reset</a></div>
</form>
</div>
<div class="row g-4 mb-4">
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Total business miles</p><h2>{{ summary.business_miles|floatformat:1 }}</h2><span>Within the filtered period</span></div></div>
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Personal + commuting + repair miles</p><h2>{{ summary.non_business_miles|floatformat:1 }}</h2><span>Included for full context</span></div></div>
<div class="col-md-4"><div class="metric-card panel-solid h-100"><p>Total trips</p><h2>{{ summary.trip_count }}</h2><span>Entries matched by the current filters</span></div></div>
</div>
<div class="glass-panel table-panel">
{% if trips %}
<div class="table-responsive">
<table class="table app-table align-middle mb-0">
<thead><tr><th>Date</th><th>Route</th><th>Type</th><th>Miles</th><th>Source</th><th></th></tr></thead>
<tbody>
{% for trip in trips %}
<tr>
<td><strong>{{ trip.date|date:"M j, Y" }}</strong><br><span class="table-muted">{{ trip.start_time|time:"g:i A" }}{{ trip.end_time|time:"g:i A" }}</span></td>
<td><strong>{{ trip.start_location }}</strong><div class="table-muted">to {{ trip.end_location }}</div></td>
<td>{{ trip.get_trip_type_display }}</td>
<td>{{ trip.distance_miles|floatformat:1 }}</td>
<td>{{ trip.get_distance_source_display }}</td>
<td class="text-end"><a href="{% url 'trip_detail' trip.pk %}">Details</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state-card compact-empty"><h3>No trips match those filters</h3><p>Adjust the date range or log a new trip to populate your history.</p><a class="btn btn-primary app-btn-primary" href="{% url 'trip_create' %}">Log a trip</a></div>
{% endif %}
</div>
</div>
</section>
{% endblock %}

View File

@ -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"))

View File

@ -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/<int:pk>/", trip_detail, name="trip_detail"),
path("trips/<int:pk>/edit/", trip_update, name="trip_update"),
path("trips/<int:pk>/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"),
]

97
core/utils.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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; }
}

81
static/js/mileage_app.js Normal file
View File

@ -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);
});

View File

@ -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; }
}

View File

@ -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);
});