Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,13 @@ def uses_tessie_odometer(self):
self.tessie_vin and
TessieService.is_configured())

def uses_tessie_battery(self):
"""Check if this vehicle uses Tessie for battery tracking"""
from app.services.tessie import TessieService
return (self.tessie_enabled and
self.tessie_vin and
TessieService.is_configured())

def get_last_odometer(self, distance_unit=None):
"""Get the most recent odometer reading.

Expand Down Expand Up @@ -459,6 +466,29 @@ def get_last_odometer(self, distance_unit=None):

return max(fuel_odo, trip_odo, charge_odo)

def get_last_fuel_level(self) -> float | None:
"""Get the most recent fuel level.

Returns the latest from trips or charging sessions.
"""
# If Tessie is enabled, use Tessie battery level exclusively
if self.uses_tessie_battery() and self.tessie_battery_level is not None:
return round(self.tessie_battery_level)
Comment thread
chaptergy marked this conversation as resolved.

last_trip = self.trips.filter(Trip.end_fuel_level.isnot(None)).order_by(Trip.date.desc()).first()

last_charge = self.charging_sessions.filter(ChargingSession.end_soc.isnot(None)).order_by(
ChargingSession.date.desc()).first()

if not last_trip and not last_charge:
return None
if not last_trip:
return last_charge.end_soc
if not last_charge:
return last_trip.end_fuel_level

return last_trip.end_fuel_level if last_trip.date > last_charge.date else last_charge.end_soc

def get_total_charging_cost(self):
"""Get total cost of all charging sessions"""
return sum(session.total_cost for session in self.charging_sessions.all() if session.total_cost) or 0
Expand Down Expand Up @@ -1393,6 +1423,8 @@ class Trip(db.Model):
date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
start_odometer = db.Column(db.Float, nullable=False)
end_odometer = db.Column(db.Float, nullable=True)
start_fuel_level = db.Column(db.Float, nullable=True)
end_fuel_level = db.Column(db.Float, nullable=True)

purpose = db.Column(db.String(20), nullable=False) # business, personal, commute, etc.
description = db.Column(db.String(200))
Expand All @@ -1412,6 +1444,28 @@ def distance(self):
return 0
return self.end_odometer - self.start_odometer

@property
def fuel_consumption(self) -> float | None:
"""Calculate trip fuel consumption"""
if self.end_fuel_level is None or self.start_fuel_level is None:
return None
return self.start_fuel_level - self.end_fuel_level

@property
def fuel_consumption_human_readable(self) -> str | None:
"""Calculate trip fuel consumption with a plus sign instead of negative for negative fuel consumption"""
consumption = self.fuel_consumption
if consumption is None:
return None
if self.vehicle.tank_capacity:
abs_consumption = self.vehicle.tank_capacity * consumption / 100
if abs_consumption < 0:
return "~+{:.1f} L".format(abs(abs_consumption))
return "~{:.1f} L".format(abs_consumption)
if consumption < 0:
return '+' + "~{:.1f} L".format(abs(consumption)) + ' %'
return "~{:.1f} L".format(consumption) + ' %'

def to_dict(self):
"""Serialize trip to dictionary for API"""
return {
Expand All @@ -1420,6 +1474,8 @@ def to_dict(self):
'date': self.date.isoformat() if self.date else None,
'start_odometer': self.start_odometer,
'end_odometer': self.end_odometer,
'start_fuel_level': self.start_fuel_level,
'end_fuel_level': self.end_fuel_level,
'distance': self.distance,
'purpose': self.purpose,
'description': self.description,
Expand Down
24 changes: 21 additions & 3 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1284,8 +1284,8 @@ def export_csv():
trips_csv = io.StringIO()
writer = csv.writer(trips_csv)
writer.writerow([
'id', 'vehicle_id', 'vehicle_name', 'date', 'start_odometer', 'end_odometer',
'distance', 'distance_unit', 'purpose', 'description', 'start_location', 'end_location',
'id', 'vehicle_id', 'vehicle_name', 'date', 'start_odometer', 'end_odometer', 'start_fuel_level',
'end_fuel_level', 'distance', 'distance_unit', 'purpose', 'description', 'start_location', 'end_location',
'notes', 'created_at'
])
for vehicle in current_user.get_all_vehicles():
Expand All @@ -1294,7 +1294,9 @@ def export_csv():
writer.writerow([
trip.id, vehicle.id, vehicle.name,
trip.date.isoformat() if trip.date else '',
trip.start_odometer, trip.end_odometer, trip.distance, odometer_unit,
trip.start_odometer, trip.end_odometer,
trip.start_fuel_level, trip.end_fuel_level,
trip.distance, odometer_unit,
trip.purpose, trip.description,
trip.start_location, trip.end_location,
trip.notes,
Expand Down Expand Up @@ -1576,6 +1578,8 @@ def export_json():
'date': trip.date.isoformat() if trip.date else None,
'start_odometer': trip.start_odometer,
'end_odometer': trip.end_odometer,
'start_fuel_level': trip.start_fuel_level,
'end_fuel_level': trip.end_fuel_level,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'distance': trip.distance,
'purpose': trip.purpose,
'description': trip.description,
Expand Down Expand Up @@ -1901,6 +1905,8 @@ def export_full_backup():
'date': trip.date.isoformat() if trip.date else None,
'start_odometer': trip.start_odometer,
'end_odometer': trip.end_odometer,
'start_fuel_level': trip.start_fuel_level,
'end_fuel_level': trip.end_fuel_level,
'distance': trip.distance,
'purpose': trip.purpose,
'description': trip.description,
Expand Down Expand Up @@ -2705,6 +2711,8 @@ def get_import_fields(data_type):
{'name': 'date', 'label': 'Date', 'required': True, 'type': 'date'},
{'name': 'start_odometer', 'label': 'Start Odometer', 'required': True, 'type': 'float'},
{'name': 'end_odometer', 'label': 'End Odometer', 'required': True, 'type': 'float'},
{'name': 'start_fuel_level', 'label': 'Start Fuel Level', 'required': False, 'type': 'float'},
{'name': 'end_fuel_level', 'label': 'End Fuel Level', 'required': False, 'type': 'float'},
{'name': 'purpose', 'label': 'Purpose', 'required': True, 'type': 'str'},
{'name': 'description', 'label': 'Description', 'required': False, 'type': 'str'},
{'name': 'start_location', 'label': 'Start Location', 'required': False, 'type': 'str'},
Expand Down Expand Up @@ -2746,6 +2754,8 @@ def get_import_fields(data_type):
'cost': ['cost', 'amount', 'total', 'price', 'expense'],
'vendor': ['vendor', 'shop', 'store', 'supplier', 'merchant', 'provider'],
'start_odometer': ['start odometer', 'start odo', 'start miles', 'start km', 'odometer start'],
'end_fuel_level': ['end fuel level', 'end fuel', 'end gas', 'end battery'],
'start_fuel_level': ['start fuel level', 'start fuel', 'start gas', 'start battery'],
'end_odometer': ['end odometer', 'end odo', 'end miles', 'end km', 'odometer end'],
'purpose': ['purpose', 'trip purpose', 'reason', 'trip type'],
'start_location': ['start location', 'from', 'origin', 'departure'],
Expand Down Expand Up @@ -2958,6 +2968,12 @@ def create_record(data_type, mapped_row, vehicle_id, user_id, date_format, user_
raise ValueError('Missing or invalid start odometer')
if end_odo is None:
raise ValueError('Missing or invalid end odometer')
start_fuel_level = parse_float_value(mapped_row.get('start_fuel_level'))
end_fuel_level = parse_float_value(mapped_row.get('end_fuel_level'))
if start_fuel_level is not None and (start_fuel_level < 0. or start_fuel_level > 100.):
raise ValueError('Start fuel level is outside of allowed 0 - 100 range')
if end_fuel_level is not None and (end_fuel_level < 0. or end_fuel_level > 100.):
raise ValueError('End fuel level is outside of allowed 0 - 100 range')
purpose = mapped_row.get('purpose', '').strip().lower()
valid_purposes = [p[0] for p in TRIP_PURPOSES]
if purpose not in valid_purposes:
Expand All @@ -2968,6 +2984,8 @@ def create_record(data_type, mapped_row, vehicle_id, user_id, date_format, user_
date=date_val,
start_odometer=start_odo,
end_odometer=end_odo,
start_fuel_level=start_fuel_level,
end_fuel_level=end_fuel_level,
purpose=purpose,
description=mapped_row.get('description', '').strip() or None,
start_location=mapped_row.get('start_location', '').strip() or None,
Expand Down
25 changes: 25 additions & 0 deletions app/routes/trips.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from app.utils import parse_decimal
from flask import jsonify
from app.models import Vehicle, Trip, TripTemplate, TRIP_PURPOSES
import math

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

Expand Down Expand Up @@ -78,12 +79,24 @@ def new():
date_str = request.form.get('date')
date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else datetime.now().date()

start_fuel_level = parse_decimal(request.form.get('start_fuel_level')) if request.form.get(
'start_fuel_level') else None
end_fuel_level = parse_decimal(request.form.get('end_fuel_level')) if request.form.get(
'end_fuel_level') else None

if start_fuel_level is not None and (not math.isfinite(start_fuel_level) or start_fuel_level < 0. or start_fuel_level > 100.):
flash(_('Start fuel level is outside of allowed 0 - 100 range'), 'error') #
if end_fuel_level is not None and (not math.isfinite(end_fuel_level) or end_fuel_level < 0. or end_fuel_level > 100.):
flash(_('End fuel level is outside of allowed 0 - 100 range'), 'error')

trip = Trip(
vehicle_id=vehicle_id,
user_id=current_user.id,
date=date,
start_odometer=parse_decimal(request.form.get('start_odometer')),
end_odometer=parse_decimal(request.form.get('end_odometer')) if request.form.get('end_odometer') else None,
start_fuel_level=start_fuel_level,
end_fuel_level=end_fuel_level,
purpose=request.form.get('purpose'),
description=request.form.get('description'),
start_location=request.form.get('start_location'),
Expand Down Expand Up @@ -140,10 +153,22 @@ def edit(trip_id):
return redirect(url_for('trips.index'))

if request.method == 'POST':
start_fuel_level = parse_decimal(request.form.get('start_fuel_level')) if request.form.get(
'start_fuel_level') else None
end_fuel_level = parse_decimal(request.form.get('end_fuel_level')) if request.form.get(
'end_fuel_level') else None

if start_fuel_level is not None and (start_fuel_level < 0. or start_fuel_level > 100.):
flash(_('Start fuel level is outside of allowed 0 - 100 range'), 'error')#
if end_fuel_level is not None and (end_fuel_level < 0. or end_fuel_level > 100.):
flash(_('End fuel level is outside of allowed 0 - 100 range'), 'error')

date_str = request.form.get('date')
trip.date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else trip.date
trip.start_odometer = parse_decimal(request.form.get('start_odometer'))
trip.end_odometer = parse_decimal(request.form.get('end_odometer')) if request.form.get('end_odometer') else None
trip.start_fuel_level = start_fuel_level
trip.end_fuel_level = end_fuel_level
trip.purpose = request.form.get('purpose')
trip.description = request.form.get('description')
trip.start_location = request.form.get('start_location')
Expand Down
30 changes: 30 additions & 0 deletions app/templates/trips/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">{% if trip %}{
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _('Distance') }}: <span id="trip-distance">0</span> {{ current_user.distance_unit }}</p>
</div>

<div>
<label for="start_fuel_level" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Start Fuel Level') }} (%) </label>
<input type="number" name="start_fuel_level" id="start_fuel_level" step="0.1" min="0" max="100"
value="{{ trip.start_fuel_level if trip else '' }}"
onchange="calculateFuelConsumption()"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
</div>

<div>
<label for="end_fuel_level" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('End Fuel Level') }} (%)</label>
<input type="number" name="end_fuel_level" id="end_fuel_level" step="0.1" min="0" max="100"
value="{{ trip.end_fuel_level if trip else '' }}"
onchange="calculateFuelConsumption()"
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _('Consumption') }}: <span id="trip-fuel-consumption">0</span> %</p>
</div>

<div>
<label for="start_location" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Start Location') }}</label>
<input type="text" name="start_location" id="start_location"
Expand Down Expand Up @@ -187,6 +204,18 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">{% if trip %}{
document.getElementById('trip-distance').textContent = distance > 0 ? distance.toFixed(1) : '0';
}

function calculateFuelConsumption() {
const start = parseFloat(document.getElementById('start_fuel_level').value);
const end = parseFloat(document.getElementById('end_fuel_level').value);
const consumption = start - end;
let textContent = '0';
if (Number.isFinite(start) && Number.isFinite(end)) {
if (consumption > 0) textContent = consumption.toFixed(1);
else if (consumption < 0) textContent = '+' + Math.abs(consumption).toFixed(1);
}
document.getElementById('trip-fuel-consumption').textContent = textContent
Comment thread
chaptergy marked this conversation as resolved.
}

// Pre-select the vehicle's default trip type on new trips (#272)
{% if not trip %}
function applyDefaultPurpose() {
Expand All @@ -200,6 +229,7 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">{% if trip %}{

document.addEventListener('DOMContentLoaded', function() {
calculateDistance();
calculateFuelConsumption();
const vehicleSelect = document.getElementById('vehicle_id');
updateVehicleOdometer(vehicleSelect.value);
{% if not trip %}
Expand Down
10 changes: 8 additions & 2 deletions app/templates/trips/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,14 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _('Trips') }}</h
</div>
<div class="ml-4 flex items-center gap-4">
<div class="text-right">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ "%.1f"|format(trip.distance) }} {{ current_user.distance_unit }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ "%.0f"|format(trip.start_odometer) }} &rarr; {{ "%.0f"|format(trip.end_odometer or 0) }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ "%.1f"|format(trip.distance) }} {{ current_user.distance_unit }}
{% if trip.fuel_consumption_human_readable is not none %}&middot; {{ trip.fuel_consumption_human_readable }}{% endif %}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ "%.0f"|format(trip.start_odometer) }} &rarr; {{ "%.0f"|format(trip.end_odometer or 0) }}
{% if trip.start_fuel_level is not none and trip.end_fuel_level is not none %}&middot; {{ trip.start_fuel_level }} % &rarr; {{ trip.end_fuel_level }} %{% endif %}
</p>
Comment thread
chaptergy marked this conversation as resolved.
</div>
<div class="flex items-center gap-2">
<a href="{{ url_for('trips.edit', trip_id=trip.id) }}"
Expand Down
6 changes: 5 additions & 1 deletion app/templates/vehicles/view.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ vehicle.name }}<
</div>
</div>

<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-5">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Type') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ vehicle.vehicle_type_label }}</dd>
Expand All @@ -63,6 +63,10 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ vehicle.name }}<
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Last Odometer') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ "%.0f"|format(vehicle.get_last_odometer(vehicle.get_effective_odometer_unit())) }} {{ vehicle.get_effective_odometer_unit() }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Last Fuel Level') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{% if vehicle.get_last_fuel_level() is not none %}{{ "%.0f"|format(vehicle.get_last_fuel_level())}} %{% else %}–{% endif %}</dd>
</div>
Comment thread
chaptergy marked this conversation as resolved.
</div>
</div>
</div>
Expand Down
30 changes: 30 additions & 0 deletions migrations/versions/cc6e159f098a_adds_fuel_level_to_trip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""adds fuel level to trip and fuel logs

Revision ID: cc6e159f098a
Revises: e5f6a7b8c9d0
Create Date: 2026-08-03 21:46:57.015964

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'cc6e159f098a'
down_revision = 'e5f6a7b8c9d0'
branch_labels = None
depends_on = None


def upgrade():
""" Adds start and end fuel level fields to trips table """
with op.batch_alter_table('trips', schema=None) as batch_op:
batch_op.add_column(sa.Column('start_fuel_level', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('end_fuel_level', sa.Float(), nullable=True))


def downgrade():
""" Removes start and end fuel level fields from the trips table """
with op.batch_alter_table('trips', schema=None) as batch_op:
batch_op.drop_column('start_fuel_level')
batch_op.drop_column('end_fuel_level')
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ def sample_vehicle(app, test_user):
year=2023,
fuel_type='petrol',
odometer_unit='km',
tank_capacity=50,
)
_db_ext.session.add(vehicle)
_db_ext.session.commit()
Expand Down
Loading