diff --git a/app/models.py b/app/models.py index 9c78306..23b22d4 100644 --- a/app/models.py +++ b/app/models.py @@ -285,18 +285,29 @@ def get_total_distance(self, distance_unit=None): return logs[-1].odometer - logs[0].odometer def get_average_consumption(self, consumption_unit=None, volume_unit='L'): - """Calculate average fuel consumption. + """Calculate average fuel consumption between the first and last + full-tank fill-ups. - Args: - consumption_unit: 'L/100km', 'mpg', or 'mpg_us'. If None, returns L/100km. - volume_unit: 'L', 'gal' (UK), or 'us_gal'. Used to convert volume for MPG. + Sums every litre poured between those two anchors so partial fills + in the middle are counted (issue #169). Returns None when any log + in the range is flagged ``is_missed`` — we have no way to make the + figure honest in that case. """ - logs = self.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.odometer).all() - if len(logs) < 2: + full_logs = self.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.odometer).all() + if len(full_logs) < 2: + return None + + first_odo = full_logs[0].odometer + last_odo = full_logs[-1].odometer + range_logs = self.fuel_logs.filter( + FuelLog.odometer > first_odo, + FuelLog.odometer <= last_odo, + ).all() + if any(log.is_missed for log in range_logs): return None - total_fuel = sum(log.volume for log in logs[1:] if log.volume) - total_distance = logs[-1].odometer - logs[0].odometer + total_fuel = sum(log.volume for log in range_logs if log.volume) + total_distance = last_odo - first_odo if total_distance > 0 and total_fuel > 0: if consumption_unit == 'mpg': @@ -533,41 +544,55 @@ class FuelLog(db.Model): def get_consumption(self, consumption_unit=None, volume_unit='L'): """Calculate consumption for this fill-up. - For full-tank fill-ups, uses the previous full-tank as the baseline so - the figure accounts for a full tank-to-tank cycle. For partial fills, - uses the immediately preceding log (any type) as the baseline to give - an instantaneous estimate for that segment. + For full-tank fill-ups, sum every litre poured between the previous + full tank and this one (inclusive) and divide by the distance covered + — the "fill-to-fill" method. Partial fills between two full tanks are + therefore counted (issue #169). If any of the intervening logs is + flagged ``is_missed``, the figure is unknowable and we return None. - Args: - consumption_unit: 'L/100km', 'mpg', or 'mpg_us'. If None, returns L/100km. - volume_unit: 'L', 'gal' (UK), or 'us_gal'. Used to convert volume for MPG. + For partial fills, compare against the immediately preceding log of + any type to give an instantaneous "fuel added per km" estimate; this + is a rough indicator, not a true consumption figure. """ if not self.volume: return None if self.is_full_tank: - prev_log = FuelLog.query.filter( + prev_full = FuelLog.query.filter( FuelLog.vehicle_id == self.vehicle_id, FuelLog.odometer < self.odometer, - FuelLog.is_full_tank == True + FuelLog.is_full_tank == True, ).order_by(FuelLog.odometer.desc()).first() + if not prev_full: + return None + distance = self.odometer - prev_full.odometer + between = FuelLog.query.filter( + FuelLog.vehicle_id == self.vehicle_id, + FuelLog.odometer > prev_full.odometer, + FuelLog.odometer <= self.odometer, + ).all() + if any(log.is_missed for log in between): + return None + volume_native = sum(log.volume for log in between if log.volume) else: prev_log = FuelLog.query.filter( FuelLog.vehicle_id == self.vehicle_id, FuelLog.odometer < self.odometer, ).order_by(FuelLog.odometer.desc()).first() - - if prev_log: + if not prev_log: + return None distance = self.odometer - prev_log.odometer - if distance > 0 and self.volume > 0: - if consumption_unit == 'mpg': - gallons = _to_uk_gallons(self.volume, volume_unit) - return distance / gallons if gallons > 0 else None - if consumption_unit == 'mpg_us': - gallons = _to_us_gallons(self.volume, volume_unit) - return distance / gallons if gallons > 0 else None - litres = _to_litres(self.volume, volume_unit) - return (litres / distance) * 100 # L/100km + volume_native = self.volume + + if distance > 0 and volume_native > 0: + if consumption_unit == 'mpg': + gallons = _to_uk_gallons(volume_native, volume_unit) + return distance / gallons if gallons > 0 else None + if consumption_unit == 'mpg_us': + gallons = _to_us_gallons(volume_native, volume_unit) + return distance / gallons if gallons > 0 else None + litres = _to_litres(volume_native, volume_unit) + return (litres / distance) * 100 # L/100km return None def to_dict(self, consumption_unit=None, volume_unit='L'): diff --git a/app/routes/calendar.py b/app/routes/calendar.py index 43db750..fc7161c 100644 --- a/app/routes/calendar.py +++ b/app/routes/calendar.py @@ -155,8 +155,9 @@ def calendar_feed(user): summary = f"🔧 {schedule.name} - {vehicle_name}" description = f"Maintenance due for {vehicle_name}" - if schedule.next_due_mileage: - description += f"\\nDue at: {schedule.next_due_mileage} {vehicle.unit_distance if vehicle else 'km'}" + if schedule.next_due_odometer: + unit = vehicle.get_effective_odometer_unit() if vehicle else 'km' + description += f"\\nDue at: {schedule.next_due_odometer:.0f} {unit}" if schedule.notes: description += f"\\nNotes: {schedule.notes}" diff --git a/app/routes/fuel.py b/app/routes/fuel.py index 742ac45..5c91118 100644 --- a/app/routes/fuel.py +++ b/app/routes/fuel.py @@ -188,32 +188,41 @@ def edit(log_id): if log.volume and log.price_per_unit and not log.total_cost: log.total_cost = round(log.volume * log.price_per_unit, 2) - # Keep fuel price history in sync with the edited log + # Reconcile fuel price history with the edited log. + # Issue #170: linking a station to an existing log via edit must + # both create the price-history row (so it shows in "cheapest fuel") + # and bump the station's `times_used` counter. + station_id = request.form.get('station_id', type=int) + existing_entry = None if old_price and old_date: - history_entry = FuelPriceHistory.query.filter_by( + existing_entry = FuelPriceHistory.query.filter_by( user_id=current_user.id, date=old_date, - price_per_unit=old_price + price_per_unit=old_price, ).first() - if history_entry: - if log.price_per_unit: - history_entry.price_per_unit = log.price_per_unit - history_entry.date = log.date - else: - db.session.delete(history_entry) + + if existing_entry: + if not log.price_per_unit: + db.session.delete(existing_entry) else: - # No existing history entry — create one if a station is now selected - station_id = request.form.get('station_id', type=int) - if station_id and log.price_per_unit: - station = FuelStation.query.get(station_id) - if station: - db.session.add(FuelPriceHistory( - station_id=station_id, - user_id=current_user.id, - date=log.date, - fuel_type=log.fuel_type or log.vehicle.fuel_type or 'petrol', - price_per_unit=log.price_per_unit - )) + existing_entry.price_per_unit = log.price_per_unit + existing_entry.date = log.date + if station_id and existing_entry.station_id != station_id: + new_station = FuelStation.query.get(station_id) + if new_station: + existing_entry.station_id = station_id + new_station.increment_usage() + elif station_id and log.price_per_unit: + new_station = FuelStation.query.get(station_id) + if new_station: + db.session.add(FuelPriceHistory( + station_id=station_id, + user_id=current_user.id, + date=log.date, + fuel_type=log.fuel_type or log.vehicle.fuel_type or 'petrol', + price_per_unit=log.price_per_unit, + )) + new_station.increment_usage() # Handle attachment upload if 'attachment' in request.files: diff --git a/app/routes/homeassistant.py b/app/routes/homeassistant.py index 758c971..c3846fb 100644 --- a/app/routes/homeassistant.py +++ b/app/routes/homeassistant.py @@ -247,7 +247,7 @@ def alerts(user): 'vehicle': schedule.vehicle.name, 'title': schedule.name, 'status': 'overdue' if schedule.is_overdue() else 'due', - 'due_mileage': schedule.next_due_mileage, + 'due_odometer': schedule.next_due_odometer, 'due_date': schedule.next_due_date.isoformat() if schedule.next_due_date else None }) diff --git a/config.py b/config.py index 4d99753..248be88 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,7 @@ basedir = Path(__file__).parent.absolute() -APP_VERSION = '0.22.1' +APP_VERSION = '0.22.2' RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable') GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA GITHUB_REPO = 'dannymcc/may' diff --git a/tests/test_fuel.py b/tests/test_fuel.py index daacaed..4a24ff8 100644 --- a/tests/test_fuel.py +++ b/tests/test_fuel.py @@ -150,6 +150,67 @@ def test_partial_fill_uses_any_previous_log(self, app, test_user, sample_vehicle assert consumption is not None assert abs(consumption - 10.0) < 0.01 + def test_full_tank_sums_intervening_partials(self, app, test_user, sample_vehicle): + """#169 — full tank consumption must sum partial fills since the previous full.""" + # Astrmn's reported scenario: full → partial → partial → full, + # 1371 km between fulls, 19.67 + 12.71 + 53.80 = 86.18 L total. + log_first_full = FuelLog( + vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 21), odometer=10000, volume=50, is_full_tank=True, + ) + partial_a = FuelLog( + vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 24), odometer=10500, volume=19.67, is_full_tank=False, + ) + partial_b = FuelLog( + vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 27), odometer=10900, volume=12.71, is_full_tank=False, + ) + log_last_full = FuelLog( + vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 29), odometer=11371, volume=53.80, is_full_tank=True, + ) + db.session.add_all([log_first_full, partial_a, partial_b, log_last_full]) + db.session.commit() + # Fill-to-fill: (19.67 + 12.71 + 53.80) / 1371 * 100 = 6.286 L/100km + consumption = log_last_full.get_consumption() + assert consumption is not None + assert abs(consumption - 6.286) < 0.01 + + def test_full_tank_returns_none_when_intervening_log_is_missed( + self, app, test_user, sample_vehicle): + """A missed fill in the range invalidates the consumption figure.""" + log1 = FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 1), odometer=10000, volume=40, is_full_tank=True) + missed = FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 5), odometer=10300, volume=20, + is_full_tank=False, is_missed=True) + log3 = FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 10), odometer=10500, volume=42, + is_full_tank=True) + db.session.add_all([log1, missed, log3]) + db.session.commit() + assert log3.get_consumption() is None + + def test_average_consumption_includes_partial_fills( + self, app, test_user, sample_vehicle): + """#169 — vehicle average must count partial fills between two fulls.""" + logs = [ + FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 21), odometer=10000, volume=50, is_full_tank=True), + FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 24), odometer=10500, volume=19.67, is_full_tank=False), + FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 27), odometer=10900, volume=12.71, is_full_tank=False), + FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 29), odometer=11371, volume=53.80, is_full_tank=True), + ] + db.session.add_all(logs) + db.session.commit() + avg = sample_vehicle.get_average_consumption() + assert avg is not None + assert abs(avg - 6.286) < 0.01 + @pytest.fixture def sample_station(app, test_user): @@ -252,6 +313,45 @@ def test_stale_price_not_shown_after_edit(self, auth_client, fuel_log_with_price db.session.refresh(history) assert history.price_per_unit == 2.547 + def test_edit_links_station_to_existing_log( + self, auth_client, test_user, sample_vehicle, sample_station): + """#170 — adding a station to a previously stationless log must + create the price-history row and bump the station's times_used.""" + log = FuelLog( + vehicle_id=sample_vehicle.id, + user_id=test_user.id, + date=date(2026, 4, 15), + odometer=20000, + volume=40, + price_per_unit=1.50, + total_cost=60.0, + is_full_tank=True, + ) + db.session.add(log) + db.session.commit() + log_id = log.id + starting_uses = sample_station.times_used or 0 + + auth_client.post(f'/fuel/{log_id}/edit', data={ + 'date': '2026-04-15', + 'odometer': '20000', + 'volume': '40', + 'price_per_unit': '1.50', + 'total_cost': '60.0', + 'station_id': str(sample_station.id), + 'is_full_tank': 'on', + }, follow_redirects=True) + + db.session.refresh(sample_station) + assert sample_station.times_used == starting_uses + 1 + + history = FuelPriceHistory.query.filter_by( + station_id=sample_station.id, + price_per_unit=1.50, + ).first() + assert history is not None + assert history.date == date(2026, 4, 15) + class TestFuelQuick: def test_quick_requires_auth(self, client):