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 @@
{{ _('Sessions logged for your electric and plug-in hybrid vehicles') }}
+| {{ _('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') }} + | +
When marked complete, a recurring reminder will automatically create the next occurrence
+ +{{ _('e.g. \"Every 2 Year(s)\" for an MOT due every two years. When marked complete, the next occurrence is created automatically.') }}