Skip to content
Merged
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
51 changes: 45 additions & 6 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion app/routes/fuel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
62 changes: 27 additions & 35 deletions app/routes/reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
7 changes: 6 additions & 1 deletion app/routes/vehicles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions app/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
</div>
</div>

{% if total_charging_cost and total_charging_cost > 0 %}
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">{{ _('Total Charging Cost') }}</dt>
<dd class="text-lg font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format(total_charging_cost) }} {{ current_user.currency }}</dd>
</dl>
</div>
</div>
</div>
</div>
{% endif %}

<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
Expand Down
58 changes: 58 additions & 0 deletions app/templates/fuel/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,62 @@ <h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No fuel logs<
</div>
</div>
{% endif %}

{% if charging_sessions %}
<div class="mt-8 sm:flex sm:items-center sm:justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _('Charging History') }}</h2>
<p class="text-gray-500 dark:text-gray-400">{{ _('Sessions logged for your electric and plug-in hybrid vehicles') }}</p>
</div>
<a href="{{ url_for('charging.new') }}"
class="mt-4 sm:mt-0 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-500 hover:bg-blue-600">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Add Charging Session') }}
</a>
</div>
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Date') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Vehicle') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Odometer') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('kWh') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Cost') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Location') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for session in charging_sessions %}
<tr class="hover:bg-gray-50 dark:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{{ session.date|format_date }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
<a href="{{ url_for('vehicles.view', vehicle_id=session.vehicle_id) }}" class="text-primary-600 hover:text-primary-500">
{{ session.vehicle.name }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{% if session.odometer %}{{ "%.0f"|format(session.odometer) }} {{ session.vehicle.get_effective_odometer_unit() }}{% else %}-{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{% if session.kwh_added %}{{ "%.1f"|format(session.kwh_added) }} kWh{% else %}-{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{% if session.total_cost %}{{ "%.2f"|format(session.total_cost) }} {{ current_user.currency }}{% else %}-{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ session.location or '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ url_for('charging.edit', session_id=session.id) }}" class="text-primary-600 hover:text-primary-900">{{ _('Edit') }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}
29 changes: 19 additions & 10 deletions app/templates/reminders/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,25 @@ <h1 class="text-xl font-semibold text-gray-900 dark:text-white">

<!-- Recurrence -->
<div>
<label for="recurrence" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recurrence</label>
<select name="recurrence" id="recurrence"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-primary-500 focus:ring-primary-500">
{% for rec_id, rec_name in recurrence_options %}
<option value="{{ rec_id }}" {% if reminder and reminder.recurrence == rec_id %}selected{% endif %}>
{{ rec_name }}
</option>
{% endfor %}
</select>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">When marked complete, a recurring reminder will automatically create the next occurrence</p>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Recurrence') }}</label>
<div class="flex gap-2 items-stretch">
<span class="inline-flex items-center px-3 text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700 border border-r-0 border-gray-300 dark:border-gray-600 rounded-l-md">
{{ _('Every') }}
</span>
<input type="number" name="recurrence_interval" id="recurrence_interval"
min="1" step="1"
value="{{ reminder.recurrence_interval if reminder and reminder.recurrence_interval else 1 }}"
class="w-20 border-y border-gray-300 dark:border-gray-600 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-white">
<select name="recurrence" id="recurrence"
class="flex-1 rounded-r-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-primary-500 focus:ring-primary-500">
{% for rec_id, rec_name in recurrence_options %}
<option value="{{ rec_id }}" {% if reminder and reminder.recurrence == rec_id %}selected{% endif %}>
{{ rec_name }}
</option>
{% endfor %}
</select>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _('e.g. \"Every 2 Year(s)\" for an MOT due every two years. When marked complete, the next occurrence is created automatically.') }}</p>
</div>

<!-- Notification Days -->
Expand Down
Loading