From bc525d1a376c9fb9eb7f4af0462e9f5a2402a412 Mon Sep 17 00:00:00 2001 From: Danny McClelland Date: Sun, 17 May 2026 16:24:58 +0100 Subject: [PATCH] feat: flexible reminder recurrence + EV cost/consumption coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #184 — Reminder recurrence is now a unit (day/week/month/year) plus an integer interval so users can pick things like "every 2 years" for a French MOT. Legacy values (quarterly, biannual) still resolve correctly on read so existing reminders keep working. Form picker is "Every | N | unit" with help text. #175 — EV vehicles were showing £0 in their overview because Vehicle.get_total_cost() only summed fuel + expenses, not charging. Bigger pass alongside the bug fix: - get_total_cost now includes charging cost - New helpers: get_total_charging_kwh, get_average_charging_consumption (kWh per 100 distance, with unit conversion), get_cost_per_kwh - Vehicle view replaces "Total Fuel Cost" with "Total Charging Cost" and "Avg. Consumption" with "Avg. Charging (kWh / 100 km)" on pure EVs; plug-in hybrids see both blocks. Fuel Consumption Trend chart also hidden on pure EVs (matches user feedback "no need for fuel consumption in EVs"). - Dashboard surfaces a "Total Charging Cost" card when there's any charging spend. - Fuel logs page now shows a "Charging History" section beneath the fuel table so EV users see their sessions in the same place. Tests: 6 new for flexible recurrence (interval, legacy values, daily / weekly intervals), 6 new for EV stats (total cost, kWh/100km in km and mi vehicles, cost per kWh, none on empty, fuel-page surfacing). 583 total passing. Bump APP_VERSION to 0.22.4. --- app/models.py | 51 ++++++++++++++++++++--- app/routes/fuel.py | 11 ++++- app/routes/reminders.py | 62 ++++++++++++--------------- app/routes/vehicles.py | 7 +++- app/templates/dashboard.html | 20 +++++++++ app/templates/fuel/index.html | 58 ++++++++++++++++++++++++++ app/templates/reminders/form.html | 29 ++++++++----- app/templates/vehicles/view.html | 20 +++++++++ config.py | 2 +- tests/test_charging.py | 69 +++++++++++++++++++++++++++++++ tests/test_reminders.py | 55 ++++++++++++++++++++++++ 11 files changed, 330 insertions(+), 54 deletions(-) diff --git a/app/models.py b/app/models.py index 89d3d05..9ae8830 100644 --- a/app/models.py +++ b/app/models.py @@ -252,7 +252,7 @@ def get_total_expense_cost(self): return sum(exp.cost for exp in self.expenses.all() if exp.cost) def get_total_cost(self): - return self.get_total_fuel_cost() + self.get_total_expense_cost() + return self.get_total_fuel_cost() + self.get_total_expense_cost() + self.get_total_charging_cost() @property def vehicle_type_label(self): @@ -370,6 +370,43 @@ 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 + def get_total_charging_kwh(self): + """Total energy delivered across all charging sessions (kWh).""" + return sum(s.kwh_added for s in self.charging_sessions.all() if s.kwh_added) or 0 + + def get_average_charging_consumption(self, distance_unit=None): + """Mean energy consumption between the first and last charging sessions + that have odometer readings. + + Returns kWh per 100 distance units in ``distance_unit`` (falls back to + the vehicle's odometer unit). Mirrors the fill-to-fill approach used + for fuel: needs at least two anchor sessions with odometers, and sums + every charge in between. + """ + sessions = (self.charging_sessions + .filter(ChargingSession.odometer.isnot(None)) + .order_by(ChargingSession.odometer) + .all()) + if len(sessions) < 2: + return None + first_odo, last_odo = sessions[0].odometer, sessions[-1].odometer + raw_distance = last_odo - first_odo + if raw_distance <= 0: + return None + total_kwh = sum(s.kwh_added for s in sessions if s.kwh_added) or 0 + if total_kwh <= 0: + return None + target = distance_unit or self.get_effective_odometer_unit() + distance = _distance_in(raw_distance, self.get_effective_odometer_unit(), target) + return (total_kwh / distance) * 100 if distance > 0 else None + + def get_cost_per_kwh(self): + """Average cost per kWh across all charging sessions with data.""" + total_kwh = self.get_total_charging_kwh() + if total_kwh <= 0: + return None + return self.get_total_charging_cost() / total_kwh + def get_total_trip_distance(self): """Get total distance from all trips""" return sum(trip.distance for trip in self.trips.all()) or 0 @@ -909,13 +946,15 @@ def get_all_branding(): ('custom', _l('Custom')) ] -# Recurrence options +# Recurrence options. The legacy values (quarterly, biannual) remain accepted on +# read so saved reminders keep working; new reminders use a unit + interval pair +# (see Reminder.recurrence_interval). RECURRENCE_OPTIONS = [ ('none', _l('No Repeat')), - ('monthly', _l('Monthly')), - ('quarterly', _l('Quarterly (3 months)')), - ('biannual', _l('Every 6 months')), - ('yearly', _l('Yearly')), + ('daily', _l('Day(s)')), + ('weekly', _l('Week(s)')), + ('monthly', _l('Month(s)')), + ('yearly', _l('Year(s)')), ] # Trip purposes for tax deductions diff --git a/app/routes/fuel.py b/app/routes/fuel.py index 5c91118..ad15ef7 100644 --- a/app/routes/fuel.py +++ b/app/routes/fuel.py @@ -31,7 +31,16 @@ def index(): FuelLog.vehicle_id.in_(vehicle_ids) ).order_by(FuelLog.date.desc()).all() - return render_template('fuel/index.html', logs=logs, vehicles=vehicles) + # #175 — also surface charging sessions on this page when the user has EVs + from app.models import ChargingSession + charging_sessions = ChargingSession.query.filter( + ChargingSession.vehicle_id.in_(vehicle_ids) + ).order_by(ChargingSession.date.desc()).all() if vehicle_ids else [] + + return render_template('fuel/index.html', + logs=logs, + vehicles=vehicles, + charging_sessions=charging_sessions) @bp.route('/new', methods=['GET', 'POST']) diff --git a/app/routes/reminders.py b/app/routes/reminders.py index d35b4e9..c39fb58 100644 --- a/app/routes/reminders.py +++ b/app/routes/reminders.py @@ -85,6 +85,7 @@ def new(vehicle_id=None): reminder_type=request.form.get('reminder_type'), due_date=due_date, recurrence=request.form.get('recurrence', 'none'), + recurrence_interval=max(int(request.form.get('recurrence_interval') or 1), 1), notify_days_before=int(request.form.get('notify_days_before', 7)) ) @@ -137,6 +138,7 @@ def edit(reminder_id): reminder.reminder_type = request.form.get('reminder_type') reminder.due_date = due_date reminder.recurrence = request.form.get('recurrence', 'none') + reminder.recurrence_interval = max(int(request.form.get('recurrence_interval') or 1), 1) reminder.notify_days_before = int(request.form.get('notify_days_before', 7)) db.session.commit() @@ -167,7 +169,7 @@ def complete(reminder_id): # If recurring, create the next occurrence if reminder.recurrence != 'none': - new_due_date = calculate_next_due_date(reminder.due_date, reminder.recurrence) + new_due_date = calculate_next_due_date(reminder.due_date, reminder.recurrence, reminder.recurrence_interval) new_reminder = Reminder( vehicle_id=reminder.vehicle_id, user_id=reminder.user_id, @@ -176,6 +178,7 @@ def complete(reminder_id): reminder_type=reminder.reminder_type, due_date=new_due_date, recurrence=reminder.recurrence, + recurrence_interval=reminder.recurrence_interval, notify_days_before=reminder.notify_days_before ) db.session.add(new_reminder) @@ -239,40 +242,29 @@ def delete(reminder_id): return redirect(url_for('reminders.index')) -def calculate_next_due_date(current_date, recurrence): - """Calculate the next due date based on recurrence""" +def calculate_next_due_date(current_date, recurrence, interval=1): + """Calculate the next due date for the given recurrence unit and interval. + + Accepts the current unit-based vocabulary (daily, weekly, monthly, yearly) + and the legacy values (quarterly = 3 months, biannual = 6 months) so old + reminders keep working until they're edited. + """ + from dateutil.relativedelta import relativedelta + + step = max(int(interval or 1), 1) + + if recurrence == 'daily': + return current_date + relativedelta(days=step) + if recurrence == 'weekly': + return current_date + relativedelta(weeks=step) if recurrence == 'monthly': - # Add one month - month = current_date.month + 1 - year = current_date.year - if month > 12: - month = 1 - year += 1 - # Handle day overflow (e.g., Jan 31 -> Feb 28) - day = min(current_date.day, 28) # Safe default - return date(year, month, day) - - elif recurrence == 'quarterly': - # Add 3 months - month = current_date.month + 3 - year = current_date.year - while month > 12: - month -= 12 - year += 1 - day = min(current_date.day, 28) - return date(year, month, day) - - elif recurrence == 'biannual': - # Add 6 months - month = current_date.month + 6 - year = current_date.year - while month > 12: - month -= 12 - year += 1 - day = min(current_date.day, 28) - return date(year, month, day) - - elif recurrence == 'yearly': - return date(current_date.year + 1, current_date.month, current_date.day) + return current_date + relativedelta(months=step) + if recurrence == 'yearly': + return current_date + relativedelta(years=step) + # Legacy fixed-interval values from pre-0.22.4 reminders. + if recurrence == 'quarterly': + return current_date + relativedelta(months=3 * step) + if recurrence == 'biannual': + return current_date + relativedelta(months=6 * step) return current_date diff --git a/app/routes/vehicles.py b/app/routes/vehicles.py index a3df7c5..5695138 100644 --- a/app/routes/vehicles.py +++ b/app/routes/vehicles.py @@ -125,12 +125,17 @@ def view(vehicle_id): stats = { 'total_fuel_cost': vehicle.get_total_fuel_cost(), 'total_expense_cost': vehicle.get_total_expense_cost(), + 'total_charging_cost': vehicle.get_total_charging_cost(), + 'total_charging_kwh': vehicle.get_total_charging_kwh(), + 'avg_charging_consumption': vehicle.get_average_charging_consumption(), + 'cost_per_kwh': vehicle.get_cost_per_kwh(), 'total_cost': vehicle.get_total_cost(), 'total_distance': vehicle.get_total_distance(vehicle.get_effective_odometer_unit()), 'avg_consumption': vehicle.get_average_consumption(current_user.consumption_unit, current_user.volume_unit), 'cost_per_distance': vehicle.get_cost_per_distance(), 'fuel_logs_count': vehicle.fuel_logs.count(), - 'expenses_count': vehicle.expenses.count() + 'expenses_count': vehicle.expenses.count(), + 'charging_sessions_count': vehicle.charging_sessions.count(), } # Get reminders for this vehicle (not completed, ordered by due date) diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 3d1e2c3..9c982e7 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -50,6 +50,26 @@

Dashboard

+ {% if total_charging_cost and total_charging_cost > 0 %} +
+
+
+
+ + + +
+
+
+
{{ _('Total Charging Cost') }}
+
{{ "%.2f"|format(total_charging_cost) }} {{ current_user.currency }}
+
+
+
+
+
+ {% endif %} +
diff --git a/app/templates/fuel/index.html b/app/templates/fuel/index.html index a7239c7..8432921 100644 --- a/app/templates/fuel/index.html +++ b/app/templates/fuel/index.html @@ -87,4 +87,62 @@

No fuel logs<

{% endif %} + +{% if charging_sessions %} +
+
+

{{ _('Charging History') }}

+

{{ _('Sessions logged for your electric and plug-in hybrid vehicles') }}

+
+ + + + + {{ _('Add Charging Session') }} + +
+
+
+ + + + + + + + + + + + + + {% for session in charging_sessions %} + + + + + + + + + + {% endfor %} + +
{{ _('Date') }}{{ _('Vehicle') }}{{ _('Odometer') }}{{ _('kWh') }}{{ _('Cost') }}{{ _('Location') }}{{ _('Actions') }}
{{ session.date|format_date }} + + {{ session.vehicle.name }} + + + {% if session.odometer %}{{ "%.0f"|format(session.odometer) }} {{ session.vehicle.get_effective_odometer_unit() }}{% else %}-{% endif %} + + {% if session.kwh_added %}{{ "%.1f"|format(session.kwh_added) }} kWh{% else %}-{% endif %} + + {% if session.total_cost %}{{ "%.2f"|format(session.total_cost) }} {{ current_user.currency }}{% else %}-{% endif %} + {{ session.location or '-' }} + {{ _('Edit') }} +
+
+
+{% endif %} {% endblock %} diff --git a/app/templates/reminders/form.html b/app/templates/reminders/form.html index 5665f62..9620bff 100644 --- a/app/templates/reminders/form.html +++ b/app/templates/reminders/form.html @@ -79,16 +79,25 @@

- - -

When marked complete, a recurring reminder will automatically create the next occurrence

+ +
+ + {{ _('Every') }} + + + +
+

{{ _('e.g. \"Every 2 Year(s)\" for an MOT due every two years. When marked complete, the next occurrence is created automatically.') }}

diff --git a/app/templates/vehicles/view.html b/app/templates/vehicles/view.html index ab921ed..356a89e 100644 --- a/app/templates/vehicles/view.html +++ b/app/templates/vehicles/view.html @@ -70,11 +70,20 @@

{{ vehicle.name }}<
+ {% if vehicle.uses_fuel() %}
{{ _('Total Fuel Cost') }}
{{ "%.2f"|format(stats.total_fuel_cost) }}
{{ current_user.currency }}
+ {% endif %} + {% if vehicle.uses_charging() %} +
+
{{ _('Total Charging Cost') }}
+
{{ "%.2f"|format(stats.total_charging_cost) }}
+
{{ current_user.currency }}
+
+ {% endif %}
{{ _('Other Expenses') }}
{{ "%.2f"|format(stats.total_expense_cost) }}
@@ -85,11 +94,20 @@

{{ vehicle.name }}<
{{ "%.0f"|format(stats.total_distance) }}
{{ vehicle.get_effective_odometer_unit() }}

+ {% if vehicle.uses_fuel() %}
{{ _('Avg. Consumption') }}
{% if stats.avg_consumption %}{{ "%.1f"|format(stats.avg_consumption) }}{% else %}-{% endif %}
{{ current_user.consumption_unit }}
+ {% endif %} + {% if vehicle.uses_charging() %} +
+
{{ _('Avg. Charging') }}
+
{% if stats.avg_charging_consumption %}{{ "%.1f"|format(stats.avg_charging_consumption) }}{% else %}-{% endif %}
+
kWh / 100 {{ vehicle.get_effective_odometer_unit() }}
+
+ {% endif %}
{{ _('Cost per') }} {{ vehicle.get_effective_odometer_unit() }}
{% if stats.cost_per_distance %}{{ "%.2f"|format(stats.cost_per_distance) }}{% else %}-{% endif %}
@@ -694,6 +712,7 @@

{{ _('Parts & Cons })(); +{% if vehicle.uses_fuel() %}

{{ _('Fuel Consumption Trend') }}

@@ -701,6 +720,7 @@

{{ _('Fuel Co

+{% endif %}
diff --git a/config.py b/config.py index c5b5ae5..7a25c82 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,7 @@ basedir = Path(__file__).parent.absolute() -APP_VERSION = '0.22.3' +APP_VERSION = '0.22.4' 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_charging.py b/tests/test_charging.py index b27f566..6db95b0 100644 --- a/tests/test_charging.py +++ b/tests/test_charging.py @@ -35,6 +35,75 @@ def sample_session(app, test_user, ev_vehicle): return session +class TestEVStats: + """#175 — EV cost/consumption stats must include charging.""" + + def test_total_cost_includes_charging(self, app, test_user, ev_vehicle): + # Pure EV: no fuel logs, but two charging sessions + ev_vehicle.odometer_unit = 'km' + db.session.commit() + db.session.add_all([ + ChargingSession(vehicle_id=ev_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 1), odometer=10000, kwh_added=30, total_cost=6.0), + ChargingSession(vehicle_id=ev_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 15), odometer=10500, kwh_added=40, total_cost=8.0), + ]) + db.session.commit() + assert ev_vehicle.get_total_cost() == 14.0 + + def test_average_charging_consumption_km(self, app, test_user, ev_vehicle): + ev_vehicle.odometer_unit = 'km' + db.session.commit() + db.session.add_all([ + ChargingSession(vehicle_id=ev_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 1), odometer=10000, kwh_added=30), + ChargingSession(vehicle_id=ev_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 15), odometer=10500, kwh_added=40), + ]) + db.session.commit() + # 70 kWh over 500 km = 14 kWh / 100 km + consumption = ev_vehicle.get_average_charging_consumption() + assert consumption is not None + assert abs(consumption - 14.0) < 0.01 + + def test_average_charging_consumption_mi_to_km(self, app, test_user, ev_vehicle): + """Vehicle in miles, asked for kWh/100km — must convert.""" + ev_vehicle.odometer_unit = 'mi' + db.session.commit() + db.session.add_all([ + ChargingSession(vehicle_id=ev_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 1), odometer=10000, kwh_added=30), + ChargingSession(vehicle_id=ev_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 15), odometer=10500, kwh_added=40), + ]) + db.session.commit() + # 70 kWh over 500 mi = 804.672 km → ~8.70 kWh/100km + consumption = ev_vehicle.get_average_charging_consumption('km') + assert consumption is not None + assert abs(consumption - 8.70) < 0.05 + + def test_cost_per_kwh(self, app, test_user, ev_vehicle): + db.session.add_all([ + ChargingSession(vehicle_id=ev_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 1), kwh_added=30, total_cost=9.0), + ChargingSession(vehicle_id=ev_vehicle.id, user_id=test_user.id, + date=date(2026, 4, 15), kwh_added=20, total_cost=4.0), + ]) + db.session.commit() + # 13 / 50 = 0.26 + assert abs(ev_vehicle.get_cost_per_kwh() - 0.26) < 0.001 + + def test_no_sessions_returns_none(self, app, test_user, ev_vehicle): + assert ev_vehicle.get_average_charging_consumption() is None + assert ev_vehicle.get_cost_per_kwh() is None + + def test_fuel_index_lists_charging(self, auth_client, sample_session): + resp = auth_client.get('/fuel/') + assert resp.status_code == 200 + assert b'Charging History' in resp.data + assert b'Tesla Model 3' in resp.data + + class TestChargingIndex: def test_index_requires_auth(self, client): resp = client.get('/charging/', follow_redirects=False) diff --git a/tests/test_reminders.py b/tests/test_reminders.py index bcc2032..767af16 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -118,3 +118,58 @@ def test_uncomplete_reminder(self, auth_client, sample_reminder): assert resp.status_code == 200 db.session.refresh(sample_reminder) assert sample_reminder.is_completed is False + + +class TestReminderFlexibleRecurrence: + """#184 — recurrence is now a unit + interval pair (e.g., every 2 years).""" + + def test_create_with_interval(self, auth_client, sample_vehicle): + resp = auth_client.post('/reminders/new', data={ + 'vehicle_id': str(sample_vehicle.id), + 'title': 'French MOT', + 'reminder_type': 'mot', + 'due_date': '2025-06-01', + 'recurrence': 'yearly', + 'recurrence_interval': '2', + 'notify_days_before': '14', + }, follow_redirects=True) + assert resp.status_code == 200 + r = Reminder.query.filter_by(title='French MOT').first() + assert r is not None + assert r.recurrence == 'yearly' + assert r.recurrence_interval == 2 + + def test_complete_uses_interval_for_next_occurrence(self, auth_client, sample_vehicle, test_user): + r = Reminder( + vehicle_id=sample_vehicle.id, user_id=test_user.id, + title='Biennial MOT', reminder_type='mot', + due_date=date(2025, 6, 1), + recurrence='yearly', recurrence_interval=2, + ) + db.session.add(r) + db.session.commit() + auth_client.post(f'/reminders/{r.id}/complete', follow_redirects=True) + next_r = Reminder.query.filter(Reminder.title == 'Biennial MOT', Reminder.is_completed == False).first() + assert next_r is not None + assert next_r.due_date == date(2027, 6, 1) + assert next_r.recurrence_interval == 2 + + def test_legacy_quarterly_still_resolves(self): + from app.routes.reminders import calculate_next_due_date + # Reminders created before 0.22.4 may have recurrence='quarterly' + # and recurrence_interval=1; should still advance by 3 months. + next_due = calculate_next_due_date(date(2025, 1, 1), 'quarterly', 1) + assert next_due == date(2025, 4, 1) + + def test_legacy_biannual_still_resolves(self): + from app.routes.reminders import calculate_next_due_date + next_due = calculate_next_due_date(date(2025, 1, 1), 'biannual', 1) + assert next_due == date(2025, 7, 1) + + def test_daily_interval(self): + from app.routes.reminders import calculate_next_due_date + assert calculate_next_due_date(date(2025, 6, 1), 'daily', 10) == date(2025, 6, 11) + + def test_weekly_interval(self): + from app.routes.reminders import calculate_next_due_date + assert calculate_next_due_date(date(2025, 6, 1), 'weekly', 3) == date(2025, 6, 22)