Finished phase006
CI / scan_ruby (push) Failing after 7s
CI / scan_js (push) Failing after 7s
CI / lint (push) Failing after 8s

This commit is contained in:
2026-09-16 16:37:03 -06:00
parent 0826d267f2
commit 87d2141ed0
22 changed files with 754 additions and 10 deletions
+12 -1
View File
@@ -38,9 +38,20 @@ class FuelEntriesController < ApplicationController
end end
def prepare_vehicle_show def prepare_vehicle_show
@maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type) @maintenance_schedules = @vehicle.maintenance_schedules.active.order(:maintenance_type)
@maintenance_schedule_statuses = maintenance_schedule_statuses(@maintenance_schedules)
@fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@maintenance_entry = @vehicle.maintenance_entries.build(date: Date.current) @maintenance_entry = @vehicle.maintenance_entries.build(date: Date.current)
end end
def maintenance_schedule_statuses(schedules)
schedules.index_with do |schedule|
latest_entry = @vehicle.maintenance_entries
.where("lower(name) = ?", schedule.maintenance_type.downcase)
.order(date: :desc, created_at: :desc)
.first
MaintenanceScheduleStatus.new(schedule, latest_entry, current_odometer: @vehicle.current_odometer)
end
end
end end
@@ -53,9 +53,20 @@ class MaintenanceEntriesController < ApplicationController
end end
def prepare_vehicle_show def prepare_vehicle_show
@maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type) @maintenance_schedules = @vehicle.maintenance_schedules.active.order(:maintenance_type)
@maintenance_schedule_statuses = maintenance_schedule_statuses(@maintenance_schedules)
@fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@fuel_entry = @vehicle.fuel_entries.build(date: Date.current) @fuel_entry = @vehicle.fuel_entries.build(date: Date.current)
end end
def maintenance_schedule_statuses(schedules)
schedules.index_with do |schedule|
latest_entry = @vehicle.maintenance_entries
.where("lower(name) = ?", schedule.maintenance_type.downcase)
.order(date: :desc, created_at: :desc)
.first
MaintenanceScheduleStatus.new(schedule, latest_entry, current_odometer: @vehicle.current_odometer)
end
end
end end
@@ -0,0 +1,65 @@
class MaintenanceSchedulesController < ApplicationController
before_action :set_vehicle
before_action :set_maintenance_schedule, only: %i[show edit update deactivate]
def index
@maintenance_schedules = @vehicle.maintenance_schedules.order(active: :desc, maintenance_type: :asc)
end
def show
end
def new
@maintenance_schedule = @vehicle.maintenance_schedules.build(
baseline_odometer: @vehicle.current_odometer,
baseline_date: Date.current
)
end
def create
@maintenance_schedule = @vehicle.maintenance_schedules.build(maintenance_schedule_params)
if @maintenance_schedule.save
redirect_to vehicle_maintenance_schedules_path(@vehicle), notice: "Maintenance schedule created."
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @maintenance_schedule.update(maintenance_schedule_params)
redirect_to vehicle_maintenance_schedules_path(@vehicle), notice: "Maintenance schedule updated."
else
render :edit, status: :unprocessable_entity
end
end
def deactivate
@maintenance_schedule.update!(active: false)
redirect_to vehicle_maintenance_schedules_path(@vehicle), notice: "Maintenance schedule deactivated."
end
private
def set_vehicle
@vehicle = Vehicle.find(params[:vehicle_id])
end
def set_maintenance_schedule
@maintenance_schedule = @vehicle.maintenance_schedules.find(params[:id])
end
def maintenance_schedule_params
params.require(:maintenance_schedule).permit(
:maintenance_type,
:mileage_interval,
:time_interval,
:baseline_odometer,
:baseline_date,
:active
)
end
end
+18 -1
View File
@@ -72,10 +72,27 @@ class VehiclesController < ApplicationController
end end
def prepare_show def prepare_show
@maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type) @maintenance_schedules = @vehicle.maintenance_schedules.active.order(:maintenance_type)
@maintenance_schedule_statuses = maintenance_schedule_statuses(@maintenance_schedules)
@fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@fuel_entry ||= @vehicle.fuel_entries.build(date: Date.current) @fuel_entry ||= @vehicle.fuel_entries.build(date: Date.current)
@maintenance_entry ||= @vehicle.maintenance_entries.build(date: Date.current) @maintenance_entry ||= @vehicle.maintenance_entries.build(date: Date.current)
end end
def maintenance_schedule_statuses(schedules)
schedules.index_with do |schedule|
latest_entry = latest_maintenance_entry_for(schedule)
status = MaintenanceScheduleStatus.new(schedule, latest_entry, current_odometer: @vehicle.current_odometer)
MaintenanceNotification.record_status_change(schedule, status)
status
end
end
def latest_maintenance_entry_for(schedule)
@vehicle.maintenance_entries
.where("lower(name) = ?", schedule.maintenance_type.downcase)
.order(date: :desc, created_at: :desc)
.first
end
end end
+2 -2
View File
@@ -20,9 +20,9 @@ class MaintenanceEntry < ApplicationRecord
return if vehicle.blank? || name.blank? return if vehicle.blank? || name.blank?
scheduled = vehicle.maintenance_schedules.any? do |schedule| scheduled = vehicle.maintenance_schedules.any? do |schedule|
schedule.maintenance_type.casecmp?(name) schedule.active? && schedule.maintenance_type.casecmp?(name)
end end
scheduled ||= vehicle.maintenance_schedules.where("lower(maintenance_type) = ?", name.downcase).exists? if vehicle.persisted? scheduled ||= vehicle.maintenance_schedules.active.where("lower(maintenance_type) = ?", name.downcase).exists? if vehicle.persisted?
return if scheduled return if scheduled
errors.add(:name, "must match a maintenance schedule for the vehicle") errors.add(:name, "must match a maintenance schedule for the vehicle")
+18
View File
@@ -0,0 +1,18 @@
class MaintenanceNotification
NOTIFIABLE_STATUSES = %w[upcoming overdue].freeze
def self.record_status_change(schedule, status)
return if schedule.last_status == status.status
previous_status = schedule.last_status.presence || "unknown"
schedule.update!(last_status: status.status, last_status_changed_at: Time.current)
return unless NOTIFIABLE_STATUSES.include?(status.status)
Rails.logger.warn(
"Maintenance schedule #{status.status}: vehicle_id=#{schedule.vehicle_id} " \
"maintenance_schedule_id=#{schedule.id} maintenance_type=#{schedule.maintenance_type.inspect} " \
"previous_status=#{previous_status} due_odometer=#{status.due_odometer} due_date=#{status.due_date} " \
"reason=#{status.reason.inspect}"
)
end
end
+14
View File
@@ -4,4 +4,18 @@ class MaintenanceSchedule < ApplicationRecord
validates :maintenance_type, presence: true, uniqueness: { scope: :vehicle_id, case_sensitive: false } validates :maintenance_type, presence: true, uniqueness: { scope: :vehicle_id, case_sensitive: false }
validates :mileage_interval, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :mileage_interval, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :time_interval, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :time_interval, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :baseline_odometer, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :baseline_date, presence: true
validates :active, inclusion: { in: [ true, false ] }
validate :baseline_date_cannot_be_in_the_future
scope :active, -> { where(active: true) }
private
def baseline_date_cannot_be_in_the_future
return if baseline_date.blank? || baseline_date <= Date.current
errors.add(:baseline_date, "can't be in the future")
end
end end
+110
View File
@@ -0,0 +1,110 @@
class MaintenanceScheduleStatus
UPCOMING_PERCENT = 0.05
attr_reader :schedule, :latest_entry, :status, :due_odometer, :due_date
def initialize(schedule, latest_entry, current_odometer:)
@schedule = schedule
@latest_entry = latest_entry
@current_odometer = current_odometer
@due_odometer = baseline_odometer + schedule.mileage_interval
@due_date = baseline_date + schedule.time_interval.days
@status = calculate_status
end
def overdue?
status == "overdue"
end
def upcoming?
status == "upcoming"
end
def no_history?
status == "no_history"
end
def current?
status == "current"
end
def status_label
case status
when "overdue" then "Overdue"
when "upcoming" then "Upcoming"
when "no_history" then "No history"
else "Current"
end
end
def reason
case status
when "overdue"
overdue_reasons.join(" and ")
when "upcoming"
upcoming_reasons.join(" and ")
when "no_history"
"No matching maintenance entry has been recorded."
else
"Maintenance is current."
end
end
private
attr_reader :current_odometer
def calculate_status
return "overdue" if mileage_overdue? || time_overdue?
return "upcoming" if mileage_upcoming? || time_upcoming?
return "no_history" if latest_entry.blank?
"current"
end
def baseline_odometer
latest_entry&.odometer || schedule.baseline_odometer
end
def baseline_date
latest_entry&.date || schedule.baseline_date
end
def mileage_overdue?
current_odometer >= due_odometer
end
def time_overdue?
Date.current >= due_date
end
def mileage_upcoming?
current_odometer >= due_odometer - mileage_upcoming_window
end
def time_upcoming?
Date.current >= due_date - time_upcoming_window.days
end
def mileage_upcoming_window
(schedule.mileage_interval * UPCOMING_PERCENT).ceil
end
def time_upcoming_window
(schedule.time_interval * UPCOMING_PERCENT).ceil
end
def overdue_reasons
reasons = []
reasons << "mileage interval reached" if mileage_overdue?
reasons << "time interval reached" if time_overdue?
reasons
end
def upcoming_reasons
reasons = []
reasons << "within #{mileage_upcoming_window} miles of due odometer" if mileage_upcoming?
reasons << "within #{time_upcoming_window} days of due date" if time_upcoming?
reasons
end
end
@@ -0,0 +1,47 @@
<%= form_with model: [vehicle, maintenance_schedule] do |form| %>
<% if maintenance_schedule.errors.any? %>
<section>
<h2><%= pluralize(maintenance_schedule.errors.count, "error") %> prevented this maintenance schedule from being saved</h2>
<ul>
<% maintenance_schedule.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</section>
<% end %>
<div>
<%= form.label :maintenance_type %><br>
<%= form.text_field :maintenance_type, required: true %>
</div>
<div>
<%= form.label :mileage_interval %><br>
<%= form.number_field :mileage_interval, min: 1, required: true %>
</div>
<div>
<%= form.label :time_interval, "Time interval in days" %><br>
<%= form.number_field :time_interval, min: 1, required: true %>
</div>
<div>
<%= form.label :baseline_odometer %><br>
<%= form.number_field :baseline_odometer, min: 0, required: true %>
</div>
<div>
<%= form.label :baseline_date %><br>
<%= form.date_field :baseline_date, max: Date.current, required: true %>
</div>
<div>
<%= form.check_box :active %>
<%= form.label :active %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
@@ -0,0 +1,7 @@
<main>
<p><%= link_to "Back to maintenance schedules", vehicle_maintenance_schedules_path(@vehicle) %></p>
<h1>Edit Maintenance Schedule</h1>
<%= render "form", vehicle: @vehicle, maintenance_schedule: @maintenance_schedule %>
</main>
@@ -0,0 +1,46 @@
<main>
<p><%= notice %></p>
<p><%= link_to "Back to vehicle", vehicle_path(@vehicle) %></p>
<h1>Maintenance Schedules</h1>
<p><%= @vehicle.year %> <%= @vehicle.make %> <%= @vehicle.model %></p>
<p><%= link_to "New maintenance schedule", new_vehicle_maintenance_schedule_path(@vehicle) %></p>
<% if @maintenance_schedules.any? %>
<table>
<thead>
<tr>
<th>Type</th>
<th>Mileage interval</th>
<th>Time interval</th>
<th>Baseline odometer</th>
<th>Baseline date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% @maintenance_schedules.each do |schedule| %>
<tr>
<td><%= link_to schedule.maintenance_type, vehicle_maintenance_schedule_path(@vehicle, schedule) %></td>
<td><%= number_with_delimiter(schedule.mileage_interval) %> miles</td>
<td><%= schedule.time_interval %> days</td>
<td><%= number_with_delimiter(schedule.baseline_odometer) %></td>
<td><%= schedule.baseline_date %></td>
<td><%= schedule.active? ? "Active" : "Inactive" %></td>
<td>
<%= link_to "Edit", edit_vehicle_maintenance_schedule_path(@vehicle, schedule) %>
<% if schedule.active? %>
<%= button_to "Deactivate", deactivate_vehicle_maintenance_schedule_path(@vehicle, schedule), method: :patch %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p>No maintenance schedules recorded.</p>
<% end %>
</main>
@@ -0,0 +1,7 @@
<main>
<p><%= link_to "Back to maintenance schedules", vehicle_maintenance_schedules_path(@vehicle) %></p>
<h1>New Maintenance Schedule</h1>
<%= render "form", vehicle: @vehicle, maintenance_schedule: @maintenance_schedule %>
</main>
@@ -0,0 +1,26 @@
<main>
<p><%= notice %></p>
<p><%= link_to "Back to maintenance schedules", vehicle_maintenance_schedules_path(@vehicle) %></p>
<h1><%= @maintenance_schedule.maintenance_type %></h1>
<dl>
<dt>Mileage interval</dt>
<dd><%= number_with_delimiter(@maintenance_schedule.mileage_interval) %> miles</dd>
<dt>Time interval</dt>
<dd><%= @maintenance_schedule.time_interval %> days</dd>
<dt>Baseline odometer</dt>
<dd><%= number_with_delimiter(@maintenance_schedule.baseline_odometer) %></dd>
<dt>Baseline date</dt>
<dd><%= @maintenance_schedule.baseline_date %></dd>
<dt>Status</dt>
<dd><%= @maintenance_schedule.active? ? "Active" : "Inactive" %></dd>
</dl>
<p><%= link_to "Edit", edit_vehicle_maintenance_schedule_path(@vehicle, @maintenance_schedule) %></p>
</main>
+55
View File
@@ -49,6 +49,54 @@
<section> <section>
<h2>Maintenance Schedules</h2> <h2>Maintenance Schedules</h2>
<p><%= link_to "Manage maintenance schedules", vehicle_maintenance_schedules_path(@vehicle) %></p>
<% overdue_statuses = @maintenance_schedule_statuses.values.select(&:overdue?) %>
<% upcoming_statuses = @maintenance_schedule_statuses.values.select(&:upcoming?) %>
<% no_history_statuses = @maintenance_schedule_statuses.values.select(&:no_history?) %>
<% if overdue_statuses.any? %>
<section>
<h3>Overdue Maintenance</h3>
<ul>
<% overdue_statuses.each do |status| %>
<li>
<%= status.schedule.maintenance_type %> is overdue: <%= status.reason %>.
Due at <%= number_with_delimiter(status.due_odometer) %> miles or <%= status.due_date %>.
</li>
<% end %>
</ul>
</section>
<% end %>
<% if upcoming_statuses.any? %>
<section>
<h3>Upcoming Maintenance</h3>
<ul>
<% upcoming_statuses.each do |status| %>
<li>
<%= status.schedule.maintenance_type %> is upcoming: <%= status.reason %>.
Due at <%= number_with_delimiter(status.due_odometer) %> miles or <%= status.due_date %>.
</li>
<% end %>
</ul>
</section>
<% end %>
<% if no_history_statuses.any? %>
<section>
<h3>No Maintenance History</h3>
<ul>
<% no_history_statuses.each do |status| %>
<li>
<%= status.schedule.maintenance_type %> has no matching maintenance entry yet.
Baseline due point is <%= number_with_delimiter(status.due_odometer) %> miles or <%= status.due_date %>.
</li>
<% end %>
</ul>
</section>
<% end %>
<% if @maintenance_schedules.any? %> <% if @maintenance_schedules.any? %>
<table> <table>
<thead> <thead>
@@ -56,14 +104,21 @@
<th>Type</th> <th>Type</th>
<th>Mileage interval</th> <th>Mileage interval</th>
<th>Time interval</th> <th>Time interval</th>
<th>Next due odometer</th>
<th>Next due date</th>
<th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% @maintenance_schedules.each do |schedule| %> <% @maintenance_schedules.each do |schedule| %>
<% status = @maintenance_schedule_statuses.fetch(schedule) %>
<tr> <tr>
<td><%= schedule.maintenance_type %></td> <td><%= schedule.maintenance_type %></td>
<td><%= number_with_delimiter(schedule.mileage_interval) %> miles</td> <td><%= number_with_delimiter(schedule.mileage_interval) %> miles</td>
<td><%= schedule.time_interval %> days</td> <td><%= schedule.time_interval %> days</td>
<td><%= number_with_delimiter(status.due_odometer) %></td>
<td><%= status.due_date %></td>
<td><%= status.status_label %></td>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>
+3
View File
@@ -16,6 +16,9 @@ Rails.application.routes.draw do
resources :vehicles do resources :vehicles do
resources :fuel_entries, only: :create resources :fuel_entries, only: :create
resources :maintenance_entries, only: :create resources :maintenance_entries, only: :create
resources :maintenance_schedules, except: :destroy do
patch :deactivate, on: :member
end
end end
root "vehicles#index" root "vehicles#index"
@@ -0,0 +1,30 @@
class AddStatusTrackingToMaintenanceSchedules < ActiveRecord::Migration[8.1]
def change
add_column :maintenance_schedules, :baseline_odometer, :integer
add_column :maintenance_schedules, :baseline_date, :date
add_column :maintenance_schedules, :active, :boolean, default: true, null: false
add_column :maintenance_schedules, :last_status, :string
add_column :maintenance_schedules, :last_status_changed_at, :datetime
reversible do |dir|
dir.up do
execute <<~SQL.squish
UPDATE maintenance_schedules
SET baseline_odometer = (
SELECT vehicles.current_odometer
FROM vehicles
WHERE vehicles.id = maintenance_schedules.vehicle_id
)
SQL
execute <<~SQL.squish
UPDATE maintenance_schedules
SET baseline_date = DATE(created_at)
SQL
end
end
change_column_null :maintenance_schedules, :baseline_odometer, false
change_column_null :maintenance_schedules, :baseline_date, false
end
end
Generated
+6 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_09_16_103647) do ActiveRecord::Schema[8.1].define(version: 2026_09_16_120000) do
create_table "access_keys", force: :cascade do |t| create_table "access_keys", force: :cascade do |t|
t.boolean "auto_rotate", default: true, null: false t.boolean "auto_rotate", default: true, null: false
t.integer "auto_rotation_frequency_days", default: 30, null: false t.integer "auto_rotation_frequency_days", default: 30, null: false
@@ -48,7 +48,12 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_16_103647) do
end end
create_table "maintenance_schedules", force: :cascade do |t| create_table "maintenance_schedules", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.date "baseline_date", null: false
t.integer "baseline_odometer", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.string "last_status"
t.datetime "last_status_changed_at"
t.string "maintenance_type", null: false t.string "maintenance_type", null: false
t.integer "mileage_interval", null: false t.integer "mileage_interval", null: false
t.integer "time_interval", null: false t.integer "time_interval", null: false
+39
View File
@@ -0,0 +1,39 @@
# Phase 006 Execution
## Scope
- Added maintenance schedule management pages.
- Added manual schedule baselines for mileage and time calculations.
- Added overdue, upcoming, no-history, and current maintenance status calculations.
- Added vehicle show alerts for overdue, upcoming, and no-history schedules.
- Added logger-backed notification behavior for status changes.
- Added specs for schedule CRUD and maintenance status behavior.
## Changes
- Added schedule fields for `baseline_odometer`, `baseline_date`, `active`, `last_status`, and `last_status_changed_at`.
- Added validation for manual baselines and schedule active state.
- Added nested maintenance schedule routes under vehicles.
- Added `MaintenanceSchedulesController` with list, show, new, create, edit, update, and deactivate actions.
- Added separate maintenance schedule views and a shared form partial.
- Added `MaintenanceScheduleStatus` for calculating due odometer/date and status.
- Added `MaintenanceNotification` as the isolated notification stub that logs status changes through `Rails.logger`.
- Updated `vehicles#show` to show overdue alerts, upcoming warnings, no-history notices, next due odometer, next due date, and status.
- Updated maintenance entry validation and forms to use only active schedules for new entries.
- Added request specs for schedule CRUD, soft deactivation, vehicle alerts, and status-change logging.
- Added model specs for mileage-based, time-based, upcoming, no-history, and current status cases.
## Decisions
- Maintenance schedule baselines are manually entered per schedule.
- Without matching maintenance history, status uses the schedule baseline.
- With matching maintenance history, status uses the latest matching maintenance entry instead of the schedule baseline.
- A schedule is overdue exactly when either mileage or time interval is reached.
- Upcoming warnings use a 5% lead window for both mileage and time intervals.
- Schedules are soft-deactivated instead of deleted.
- Vehicle show is allowed to update stored status tracking and log status changes.
- Status changes are stored for all statuses, but notifications are logged only for `upcoming` and `overdue` statuses.
## Verification
- Passed: `bundle exec rspec spec/models/maintenance_schedule_spec.rb spec/models/maintenance_schedule_status_spec.rb`
- Passed: `bundle exec rspec spec/requests/vehicles_spec.rb`
- Passed: `bundle exec rspec`
- Passed: `bin/rubocop app/models/maintenance_schedule.rb app/models/maintenance_entry.rb app/models/maintenance_schedule_status.rb app/models/maintenance_notification.rb app/controllers/vehicles_controller.rb app/controllers/fuel_entries_controller.rb app/controllers/maintenance_entries_controller.rb app/controllers/maintenance_schedules_controller.rb spec/models/maintenance_schedule_spec.rb spec/models/maintenance_schedule_status_spec.rb spec/requests/vehicles_spec.rb`
- Passed: `bin/ci`
+3
View File
@@ -4,5 +4,8 @@ FactoryBot.define do
maintenance_type { "Oil Change" } maintenance_type { "Oil Change" }
mileage_interval { 5_000 } mileage_interval { 5_000 }
time_interval { 180 } time_interval { 180 }
baseline_odometer { 10_000 }
baseline_date { Date.current }
active { true }
end end
end end
+29 -4
View File
@@ -6,18 +6,43 @@ RSpec.describe MaintenanceSchedule do
end end
it "requires all fields" do it "requires all fields" do
schedule = build(:maintenance_schedule, vehicle: nil, maintenance_type: nil, mileage_interval: nil, time_interval: nil) schedule = build(
:maintenance_schedule,
vehicle: nil,
maintenance_type: nil,
mileage_interval: nil,
time_interval: nil,
baseline_odometer: nil,
baseline_date: nil,
active: nil
)
expect(schedule).not_to be_valid expect(schedule).not_to be_valid
expect(schedule.errors.attribute_names).to include(:vehicle, :maintenance_type, :mileage_interval, :time_interval) expect(schedule.errors.attribute_names).to include(
:vehicle,
:maintenance_type,
:mileage_interval,
:time_interval,
:baseline_odometer,
:baseline_date,
:active
)
end end
it "requires positive integer intervals" do it "requires valid numeric values" do
schedule = build(:maintenance_schedule, mileage_interval: 0, time_interval: 0) schedule = build(:maintenance_schedule, mileage_interval: 0, time_interval: 0, baseline_odometer: -1)
expect(schedule).not_to be_valid expect(schedule).not_to be_valid
expect(schedule.errors[:mileage_interval]).to be_present expect(schedule.errors[:mileage_interval]).to be_present
expect(schedule.errors[:time_interval]).to be_present expect(schedule.errors[:time_interval]).to be_present
expect(schedule.errors[:baseline_odometer]).to be_present
end
it "rejects future baseline dates" do
schedule = build(:maintenance_schedule, baseline_date: Date.current + 1.day)
expect(schedule).not_to be_valid
expect(schedule.errors[:baseline_date]).to be_present
end end
it "requires maintenance type to be unique per vehicle case-insensitively" do it "requires maintenance type to be unique per vehicle case-insensitively" do
@@ -0,0 +1,58 @@
require "rails_helper"
RSpec.describe MaintenanceScheduleStatus do
it "marks a schedule overdue when the mileage interval is reached" do
schedule = build(:maintenance_schedule, mileage_interval: 5_000, baseline_odometer: 10_000, time_interval: 180)
status = described_class.new(schedule, nil, current_odometer: 15_000)
expect(status.status).to eq("overdue")
expect(status.reason).to include("mileage interval reached")
end
it "marks a schedule overdue when the time interval is reached" do
schedule = build(:maintenance_schedule, time_interval: 180, baseline_date: Date.current - 180.days)
status = described_class.new(schedule, nil, current_odometer: 10_000)
expect(status.status).to eq("overdue")
expect(status.reason).to include("time interval reached")
end
it "marks a schedule upcoming within five percent of the mileage interval" do
schedule = build(:maintenance_schedule, mileage_interval: 5_000, baseline_odometer: 10_000, baseline_date: Date.current)
status = described_class.new(schedule, nil, current_odometer: 14_750)
expect(status.status).to eq("upcoming")
expect(status.reason).to include("within 250 miles")
end
it "marks a schedule upcoming within five percent of the time interval" do
schedule = build(:maintenance_schedule, time_interval: 180, baseline_date: Date.current - 171.days)
status = described_class.new(schedule, nil, current_odometer: 10_000)
expect(status.status).to eq("upcoming")
expect(status.reason).to include("within 9 days")
end
it "marks a no-history schedule as no history when it is not upcoming or overdue" do
schedule = build(:maintenance_schedule, baseline_odometer: 10_000, mileage_interval: 5_000, baseline_date: Date.current)
status = described_class.new(schedule, nil, current_odometer: 10_100)
expect(status.status).to eq("no_history")
end
it "marks a schedule current when matching history exists and no interval is near" do
schedule = build(:maintenance_schedule, mileage_interval: 5_000, time_interval: 180)
entry = build(:maintenance_entry, odometer: 12_000, date: Date.current - 10.days)
status = described_class.new(schedule, entry, current_odometer: 12_500)
expect(status.status).to eq("current")
expect(status.due_odometer).to eq(17_000)
expect(status.due_date).to eq(entry.date + 180.days)
end
end
+147
View File
@@ -111,6 +111,7 @@ RSpec.describe "Vehicles" do
expect(response.body).to include(vehicle.make) expect(response.body).to include(vehicle.make)
expect(response.body).to include(vehicle.model) expect(response.body).to include(vehicle.model)
expect(response.body).to include("Maintenance Schedules") expect(response.body).to include("Maintenance Schedules")
expect(response.body).to include("Manage maintenance schedules")
expect(response.body).to include("Fuel History") expect(response.body).to include("Fuel History")
expect(response.body).to include("Maintenance History") expect(response.body).to include("Maintenance History")
expect(response.body).to include(vehicle.maintenance_schedules.first.maintenance_type) expect(response.body).to include(vehicle.maintenance_schedules.first.maintenance_type)
@@ -121,6 +122,141 @@ RSpec.describe "Vehicles" do
expect(response.body.index("12,000")).to be < response.body.index("11,000") expect(response.body.index("12,000")).to be < response.body.index("11,000")
expect(response.body.index(newer_maintenance_entry.notes)).to be < response.body.index(older_maintenance_entry.notes) expect(response.body.index(newer_maintenance_entry.notes)).to be < response.body.index(older_maintenance_entry.notes)
end end
it "shows overdue maintenance alerts" do
vehicle = create(:vehicle, current_odometer: 15_000)
create(
:maintenance_schedule,
vehicle: vehicle,
maintenance_type: "Oil Change",
mileage_interval: 5_000,
baseline_odometer: 10_000,
baseline_date: Date.current
)
get vehicle_path(vehicle)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Overdue Maintenance")
expect(response.body).to include("Oil Change is overdue")
end
it "shows upcoming maintenance warnings" do
vehicle = create(:vehicle, current_odometer: 14_750)
create(
:maintenance_schedule,
vehicle: vehicle,
maintenance_type: "Oil Change",
mileage_interval: 5_000,
baseline_odometer: 10_000,
baseline_date: Date.current
)
get vehicle_path(vehicle)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Upcoming Maintenance")
expect(response.body).to include("Oil Change is upcoming")
end
it "shows no-history maintenance status without marking it overdue" do
vehicle = create(:vehicle, current_odometer: 10_100)
create(
:maintenance_schedule,
vehicle: vehicle,
maintenance_type: "Oil Change",
mileage_interval: 5_000,
baseline_odometer: 10_000,
baseline_date: Date.current
)
get vehicle_path(vehicle)
expect(response).to have_http_status(:ok)
expect(response.body).to include("No Maintenance History")
expect(response.body).to include("Oil Change has no matching maintenance entry yet")
expect(response.body).not_to include("Oil Change is overdue")
end
it "logs overdue notifications only when schedule status changes" do
vehicle = create(:vehicle, current_odometer: 15_000)
schedule = create(
:maintenance_schedule,
vehicle: vehicle,
maintenance_type: "Oil Change",
mileage_interval: 5_000,
baseline_odometer: 10_000,
baseline_date: Date.current
)
allow(Rails.logger).to receive(:warn)
get vehicle_path(vehicle)
get vehicle_path(vehicle)
expect(schedule.reload.last_status).to eq("overdue")
expect(Rails.logger).to have_received(:warn).once.with(/Maintenance schedule overdue: vehicle_id=#{vehicle.id}/)
end
end
describe "maintenance schedule CRUD" do
before { identify_user }
it "lists maintenance schedules" do
vehicle = create(:vehicle)
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
get vehicle_maintenance_schedules_path(vehicle)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Maintenance Schedules")
expect(response.body).to include("Oil Change")
end
it "creates a maintenance schedule" do
vehicle = create(:vehicle)
expect do
post vehicle_maintenance_schedules_path(vehicle), params: { maintenance_schedule: valid_maintenance_schedule_params }
end.to change(MaintenanceSchedule, :count).by(1)
expect(response).to redirect_to(vehicle_maintenance_schedules_path(vehicle))
expect(MaintenanceSchedule.last.baseline_odometer).to eq(10_000)
end
it "rejects invalid maintenance schedule params" do
vehicle = create(:vehicle)
expect do
post vehicle_maintenance_schedules_path(vehicle), params: { maintenance_schedule: valid_maintenance_schedule_params.merge(mileage_interval: 0) }
end.not_to change(MaintenanceSchedule, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response.body).to include("prevented this maintenance schedule from being saved")
end
it "updates a maintenance schedule" do
vehicle = create(:vehicle)
schedule = create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
patch vehicle_maintenance_schedule_path(vehicle, schedule), params: {
maintenance_schedule: valid_maintenance_schedule_params.merge(maintenance_type: "Tire Rotation")
}
expect(response).to redirect_to(vehicle_maintenance_schedules_path(vehicle))
expect(schedule.reload.maintenance_type).to eq("Tire Rotation")
end
it "soft deactivates a maintenance schedule" do
vehicle = create(:vehicle)
schedule = create(:maintenance_schedule, vehicle: vehicle, active: true)
expect do
patch deactivate_vehicle_maintenance_schedule_path(vehicle, schedule)
end.not_to change(MaintenanceSchedule, :count)
expect(response).to redirect_to(vehicle_maintenance_schedules_path(vehicle))
expect(schedule.reload).not_to be_active
end
end end
describe "POST /vehicles/:vehicle_id/fuel_entries" do describe "POST /vehicles/:vehicle_id/fuel_entries" do
@@ -368,6 +504,17 @@ RSpec.describe "Vehicles" do
} }
end end
def valid_maintenance_schedule_params
{
maintenance_type: "Oil Change",
mileage_interval: 5_000,
time_interval: 180,
baseline_odometer: 10_000,
baseline_date: Date.current,
active: "1"
}
end
def identify_user(user = create(:user)) def identify_user(user = create(:user))
@identified_user = user @identified_user = user
access_key = AccessKey.current access_key = AccessKey.current