Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
uses: actions/checkout@v6

- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,17 @@ The main dashboard shows an overview of all your vehicles with key statistics:
- Total fuel costs and consumption averages
- Recent fuel logs and expenses
- Upcoming reminders and overdue alerts
- Vehicle photo cards showing make/model/year and fuel type at a glance

### Vehicles
Add and manage your vehicles with detailed information:
- Make, model, year, and registration
- Fuel type and tank capacity
- Custom specifications and notes
- Photo upload support
- **Vehicle Sharing**: Mark a vehicle as "Shared" to make it visible and loggable by all users on the instance
- **Upcoming Maintenance**: Vehicle detail pages show a live panel of scheduled maintenance tasks, with overdue and due-soon alerts
- **Parts & Consumables**: Collapsible section on the vehicle page remembers your expand/collapse preference per vehicle

### Fuel Logs
Track every fill-up with:
Expand All @@ -169,12 +173,15 @@ Track every fill-up with:
### Expenses
Categorize all vehicle-related costs:
- Maintenance & Repairs
- Inspection (MOT, roadworthy checks)
- Insurance
- Tax & Registration
- Parking & Tolls
- Accessories
- Other expenses

Record odometer readings alongside costs, and expand any expense row to see vendor and notes details inline.

### Reminders
Never miss important dates:
- MOT/Inspection due dates
Expand Down Expand Up @@ -202,9 +209,9 @@ Track regular payments:
Store important vehicle documents:
- Insurance certificates
- Registration documents
- Service manuals
- Service manuals and instruction booklets (up to 300MB)
- MOT certificates
- Any file type with expiry date tracking
- Any file type (PDF, images, Word, Excel, text, ePub) with expiry date tracking

### Fuel Stations
Save your favorite stations:
Expand Down
2 changes: 2 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ def _run_schema_migrations(app):
('tessie_battery_range', 'FLOAT'),
('tessie_last_updated', 'DATETIME'),
('tracking_unit', "VARCHAR(20) DEFAULT 'mileage'"),
('annual_mileage_limit', 'FLOAT'),
('annual_mileage_start_date', 'DATE'),
],
'users': [
('date_format', "VARCHAR(20) DEFAULT 'DD/MM/YYYY'"),
Expand Down
103 changes: 100 additions & 3 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,13 @@ def check_password(self, password):
return check_password_hash(self.password_hash, password)

def get_all_vehicles(self):
"""Get all vehicles user has access to (owned + shared), sorted by make/model"""
"""Get all vehicles user has access to (owned + explicitly shared + instance-shared), sorted by make/model"""
owned = list(self.owned_vehicles.all())
shared = list(self.shared_vehicles)
instance_shared = Vehicle.query.filter_by(is_shared=True).all()
seen = set()
unique = []
for v in owned + shared:
for v in owned + shared + instance_shared:
if v.id not in seen:
seen.add(v.id)
unique.append(v)
Expand Down Expand Up @@ -211,6 +212,13 @@ class Vehicle(db.Model):
tessie_battery_range = db.Column(db.Float) # Last fetched range in km
tessie_last_updated = db.Column(db.DateTime) # When Tessie data was last fetched

# Annual mileage tracking
annual_mileage_limit = db.Column(db.Float, nullable=True)
annual_mileage_start_date = db.Column(db.Date, nullable=True)

# Sharing — if True, all users on this instance can view and log against this vehicle
is_shared = db.Column(db.Boolean, default=False, nullable=False)

# Relationships
fuel_logs = db.relationship('FuelLog', backref='vehicle', lazy='dynamic',
cascade='all, delete-orphan')
Expand Down Expand Up @@ -355,9 +363,97 @@ def get_cost_per_distance(self):
return None

def is_electric(self):
"""Check if vehicle is electric or plug-in hybrid"""
"""Check if vehicle uses any electric propulsion"""
return self.fuel_type in ('electric', 'plugin_hybrid', 'hybrid')

def uses_charging(self):
"""Check if vehicle can be plugged in for charging (pure EV or plug-in hybrid)"""
return self.fuel_type in ('electric', 'plugin_hybrid')

def uses_fuel(self):
"""Check if vehicle uses liquid fuel (not pure electric)"""
return self.fuel_type != 'electric'

def get_annual_mileage_stats(self):
"""Return mileage tracking stats for the current annual period, or None if not configured."""
if not self.annual_mileage_limit or not self.annual_mileage_start_date:
return None

from datetime import date as date_type

today = date_type.today()
limit = self.annual_mileage_limit
start = self.annual_mileage_start_date

# Find the most recent anniversary of start that is <= today
period_year = today.year
try:
candidate = start.replace(year=period_year)
except ValueError:
candidate = start.replace(year=period_year, day=28)
if candidate > today:
period_year -= 1
try:
candidate = start.replace(year=period_year)
except ValueError:
candidate = start.replace(year=period_year, day=28)
period_start = candidate

try:
period_end = start.replace(year=period_year + 1)
except ValueError:
period_end = start.replace(year=period_year + 1, day=28)

days_total = (period_end - period_start).days
days_elapsed = max(0, (today - period_start).days)
days_remaining = max(0, (period_end - today).days)

# Baseline: last odometer reading before this period
baseline_log = (self.fuel_logs
.filter(FuelLog.date < period_start)
.order_by(FuelLog.date.desc(), FuelLog.odometer.desc())
.first())
current_log = (self.fuel_logs
.order_by(FuelLog.date.desc(), FuelLog.odometer.desc())
.first())

if not current_log:
driven = 0.0
elif baseline_log:
driven = max(0.0, current_log.odometer - baseline_log.odometer)
else:
first_log = (self.fuel_logs
.filter(FuelLog.date >= period_start)
.order_by(FuelLog.date.asc(), FuelLog.odometer.asc())
.first())
if first_log and current_log.id != first_log.id:
driven = max(0.0, current_log.odometer - first_log.odometer)
else:
driven = 0.0

remaining = max(0.0, limit - driven)
progress_pct = min(100.0, round(driven / limit * 100, 1)) if limit > 0 else 0.0
time_pct = round(days_elapsed / days_total * 100, 1) if days_total > 0 else 0.0
expected = round(limit / days_total * days_elapsed) if days_total > 0 else 0
projected = round(driven / days_elapsed * days_total) if days_elapsed > 0 else 0

return {
'limit': limit,
'period_start': period_start,
'period_end': period_end,
'days_total': days_total,
'days_elapsed': days_elapsed,
'days_remaining': days_remaining,
'driven': round(driven),
'remaining': round(remaining),
'projected': projected,
'on_pace': projected <= limit,
'over_limit': driven >= limit,
'progress_pct': progress_pct,
'time_pct': time_pct,
'expected': expected,
}

def to_dict(self):
"""Serialize vehicle to dictionary for API"""
return {
Expand Down Expand Up @@ -698,6 +794,7 @@ def get_all_branding():
EXPENSE_CATEGORIES = [
('maintenance', _l('Maintenance')),
('repairs', _l('Repairs')),
('inspection', _l('Inspection')),
('insurance', _l('Insurance')),
('tax', _l('Road Tax')),
('registration', _l('Registration')),
Expand Down
2 changes: 1 addition & 1 deletion app/routes/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

bp = Blueprint('documents', __name__, url_prefix='/documents')

ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'doc', 'docx'}
ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'doc', 'docx', 'xlsx', 'xls', 'txt', 'csv', 'epub'}


def allowed_file(filename):
Expand Down
25 changes: 22 additions & 3 deletions app/routes/vehicles.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from flask_babel import gettext as _
from werkzeug.utils import secure_filename
from app import db
from app.models import Vehicle, VehicleSpec, VehiclePart, FuelLog, Expense, User, Reminder, VEHICLE_TYPES, FUEL_TYPES, VEHICLE_SPEC_TYPES, REMINDER_TYPES, PART_TYPES, TRACKING_UNITS, ODOMETER_UNITS, AppSettings
from app.models import Vehicle, VehicleSpec, VehiclePart, FuelLog, Expense, User, Reminder, MaintenanceSchedule, VEHICLE_TYPES, FUEL_TYPES, VEHICLE_SPEC_TYPES, REMINDER_TYPES, PART_TYPES, TRACKING_UNITS, ODOMETER_UNITS, AppSettings
from app.services.tessie import TessieService

bp = Blueprint('vehicles', __name__, url_prefix='/vehicles')
Expand Down Expand Up @@ -56,7 +56,9 @@ def new():
fuel_type=request.form.get('fuel_type'),
secondary_fuel_type=request.form.get('secondary_fuel_type') or None,
tank_capacity=float(request.form.get('tank_capacity')) if request.form.get('tank_capacity') else None,
notes=request.form.get('notes')
notes=request.form.get('notes'),
annual_mileage_limit=float(request.form.get('annual_mileage_limit')) if request.form.get('annual_mileage_limit') else None,
annual_mileage_start_date=datetime.strptime(request.form.get('annual_mileage_start_date'), '%Y-%m-%d').date() if request.form.get('annual_mileage_start_date') else None,
)

# Handle image upload
Expand Down Expand Up @@ -137,6 +139,15 @@ def view(vehicle_id):
# Get parts for this vehicle
parts = vehicle.parts.order_by(VehiclePart.part_type, VehiclePart.name).all()

# Get active maintenance schedules, sorted soonest-due first
from datetime import date as date_type
today = date_type.today()
maintenance_schedules = vehicle.maintenance_schedules.filter_by(is_active=True).all()
maintenance_schedules.sort(key=lambda s: (
s.next_due_date or date_type(9999, 12, 31),
s.next_due_odometer or float('inf')
))

# Check if DVLA integration is configured
from app.services.dvla import DVLAService
dvla_configured = DVLAService.is_configured()
Expand All @@ -154,8 +165,11 @@ def view(vehicle_id):
reminder_types=REMINDER_TYPES,
parts=parts,
part_types=PART_TYPES,
maintenance_schedules=maintenance_schedules,
today=today,
dvla_configured=dvla_configured,
tessie_configured=tessie_configured)
tessie_configured=tessie_configured,
annual_mileage_stats=vehicle.get_annual_mileage_stats())


@bp.route('/<int:vehicle_id>/edit', methods=['GET', 'POST'])
Expand All @@ -182,6 +196,11 @@ def edit(vehicle_id):
vehicle.secondary_fuel_type = request.form.get('secondary_fuel_type') or None
vehicle.tank_capacity = float(request.form.get('tank_capacity')) if request.form.get('tank_capacity') else None
vehicle.notes = request.form.get('notes')
vehicle.annual_mileage_limit = float(request.form.get('annual_mileage_limit')) if request.form.get('annual_mileage_limit') else None
vehicle.annual_mileage_start_date = datetime.strptime(request.form.get('annual_mileage_start_date'), '%Y-%m-%d').date() if request.form.get('annual_mileage_start_date') else None

vehicle.is_active = request.form.get('is_active') == 'on'
vehicle.is_shared = request.form.get('is_shared') == 'on'

# Handle Tessie integration fields
vehicle.tessie_vin = request.form.get('tessie_vin') or None
Expand Down
32 changes: 16 additions & 16 deletions app/templates/api/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Authentication</
</p>

<h3 class="font-medium text-gray-900 dark:text-white mt-6 mb-2">Option 1: Authorization Header (Recommended)</h3>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<code class="text-green-400 text-sm">Authorization: Bearer may_your_api_key_here</code>
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<code class="text-green-700 dark:text-green-400 text-sm">Authorization: Bearer may_your_api_key_here</code>
</div>

<h3 class="font-medium text-gray-900 dark:text-white mt-6 mb-2">Option 2: X-API-Key Header</h3>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<code class="text-green-400 text-sm">X-API-Key: may_your_api_key_here</code>
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<code class="text-green-700 dark:text-green-400 text-sm">X-API-Key: may_your_api_key_here</code>
</div>

<h3 class="font-medium text-gray-900 dark:text-white mt-6 mb-2">Example Request</h3>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-400 text-sm"><code>curl -X GET "{{ request.host_url }}api/v1/vehicles" \
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-700 dark:text-green-400 text-sm"><code>curl -X GET "{{ request.host_url }}api/v1/vehicles" \
-H "Authorization: Bearer may_your_api_key_here"</code></pre>
</div>
</div>
Expand Down Expand Up @@ -78,8 +78,8 @@ <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Vehicles</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">List all vehicles you have access to (owned and shared)</p>

<h4 class="font-medium text-gray-900 dark:text-white mb-2">Response</h4>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-400 text-sm"><code>{
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-700 dark:text-green-400 text-sm"><code>{
"vehicles": [
{
"id": 1,
Expand Down Expand Up @@ -155,8 +155,8 @@ <h4 class="font-medium text-gray-900 dark:text-white mb-2">Request Body</h4>
</div>

<h4 class="font-medium text-gray-900 dark:text-white mt-6 mb-2">Example</h4>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-400 text-sm"><code>curl -X POST "{{ request.host_url }}api/v1/vehicles" \
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-700 dark:text-green-400 text-sm"><code>curl -X POST "{{ request.host_url }}api/v1/vehicles" \
-H "Authorization: Bearer may_your_api_key" \
-H "Content-Type: application/json" \
-d '{
Expand Down Expand Up @@ -231,8 +231,8 @@ <h4 class="font-medium text-gray-900 dark:text-white mb-2">Query Parameters</h4>
</div>

<h4 class="font-medium text-gray-900 dark:text-white mt-6 mb-2">Response</h4>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-400 text-sm"><code>{
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-700 dark:text-green-400 text-sm"><code>{
"fuel_logs": [
{
"id": 1,
Expand Down Expand Up @@ -294,8 +294,8 @@ <h4 class="font-medium text-gray-900 dark:text-white mb-2">Request Body</h4>
</div>

<h4 class="font-medium text-gray-900 dark:text-white mt-6 mb-2">Example</h4>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-400 text-sm"><code>curl -X POST "{{ request.host_url }}api/v1/vehicles/1/fuel" \
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-700 dark:text-green-400 text-sm"><code>curl -X POST "{{ request.host_url }}api/v1/vehicles/1/fuel" \
-H "Authorization: Bearer may_your_api_key" \
-H "Content-Type: application/json" \
-d '{
Expand Down Expand Up @@ -435,8 +435,8 @@ <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Error Handling</
</div>
<div class="p-6">
<h4 class="font-medium text-gray-900 dark:text-white mb-2">Error Response Format</h4>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto mb-6">
<pre class="text-green-400 text-sm"><code>{
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto mb-6">
<pre class="text-green-700 dark:text-green-400 text-sm"><code>{
"error": "Human-readable error message",
"code": "error_code"
}</code></pre>
Expand Down
4 changes: 2 additions & 2 deletions app/templates/auth/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -947,8 +947,8 @@ <h2 class="text-lg font-medium text-gray-900 dark:text-white">{{ _('Home Assista

<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{{ _('REST Sensor Configuration') }}</h4>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm text-gray-100"><code>sensor:
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm text-gray-800 dark:text-gray-100"><code>sensor:
- platform: rest
name: "May Vehicle Stats"
resource: {{ request.url_root.rstrip('/') }}/api/ha/summary
Expand Down
24 changes: 13 additions & 11 deletions app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,7 @@
Object.entries(primaryPalette).forEach(([key, value]) => {
rootStyle.setProperty(`--primary-${key}`, value);
});
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: primaryPalette
}
}
}
};
window.tailwind.config = tailwind.config;
window.__primaryPalette = primaryPalette;
</script>
<script>
window.__loadTailwindFallback = function() {
Expand All @@ -120,6 +110,18 @@
};
</script>
<script src="{{ TAILWIND_ASSET_URL }}" onerror="window.__loadTailwindFallback()"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: window.__primaryPalette
}
}
}
};
</script>
<!-- HTMX - Non-critical, deferred, local with CDN fallback -->
<script>
window.__loadHtmxFallback = function() {
Expand Down
Loading