diff --git a/app/controllers/fuel_entries_controller.rb b/app/controllers/fuel_entries_controller.rb index db6b5f5..db1fc07 100644 --- a/app/controllers/fuel_entries_controller.rb +++ b/app/controllers/fuel_entries_controller.rb @@ -38,9 +38,20 @@ class FuelEntriesController < ApplicationController end 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) @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @maintenance_entry = @vehicle.maintenance_entries.build(date: Date.current) 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 diff --git a/app/controllers/maintenance_entries_controller.rb b/app/controllers/maintenance_entries_controller.rb index be23e21..e614be5 100644 --- a/app/controllers/maintenance_entries_controller.rb +++ b/app/controllers/maintenance_entries_controller.rb @@ -53,9 +53,20 @@ class MaintenanceEntriesController < ApplicationController end 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) @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @fuel_entry = @vehicle.fuel_entries.build(date: Date.current) 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 diff --git a/app/controllers/maintenance_schedules_controller.rb b/app/controllers/maintenance_schedules_controller.rb new file mode 100644 index 0000000..357294e --- /dev/null +++ b/app/controllers/maintenance_schedules_controller.rb @@ -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 diff --git a/app/controllers/vehicles_controller.rb b/app/controllers/vehicles_controller.rb index ee2c705..7f57bd9 100644 --- a/app/controllers/vehicles_controller.rb +++ b/app/controllers/vehicles_controller.rb @@ -72,10 +72,27 @@ class VehiclesController < ApplicationController end 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) @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) @fuel_entry ||= @vehicle.fuel_entries.build(date: Date.current) @maintenance_entry ||= @vehicle.maintenance_entries.build(date: Date.current) 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 diff --git a/app/models/maintenance_entry.rb b/app/models/maintenance_entry.rb index 62632b8..3a1133e 100644 --- a/app/models/maintenance_entry.rb +++ b/app/models/maintenance_entry.rb @@ -20,9 +20,9 @@ class MaintenanceEntry < ApplicationRecord return if vehicle.blank? || name.blank? scheduled = vehicle.maintenance_schedules.any? do |schedule| - schedule.maintenance_type.casecmp?(name) + schedule.active? && schedule.maintenance_type.casecmp?(name) 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 errors.add(:name, "must match a maintenance schedule for the vehicle") diff --git a/app/models/maintenance_notification.rb b/app/models/maintenance_notification.rb new file mode 100644 index 0000000..5ffa722 --- /dev/null +++ b/app/models/maintenance_notification.rb @@ -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 diff --git a/app/models/maintenance_schedule.rb b/app/models/maintenance_schedule.rb index 467c801..fe677f6 100644 --- a/app/models/maintenance_schedule.rb +++ b/app/models/maintenance_schedule.rb @@ -4,4 +4,18 @@ class MaintenanceSchedule < ApplicationRecord 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 :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 diff --git a/app/models/maintenance_schedule_status.rb b/app/models/maintenance_schedule_status.rb new file mode 100644 index 0000000..173a822 --- /dev/null +++ b/app/models/maintenance_schedule_status.rb @@ -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 diff --git a/app/views/maintenance_schedules/_form.html.erb b/app/views/maintenance_schedules/_form.html.erb new file mode 100644 index 0000000..5de17b0 --- /dev/null +++ b/app/views/maintenance_schedules/_form.html.erb @@ -0,0 +1,47 @@ +<%= form_with model: [vehicle, maintenance_schedule] do |form| %> + <% if maintenance_schedule.errors.any? %> +
+

<%= pluralize(maintenance_schedule.errors.count, "error") %> prevented this maintenance schedule from being saved

+ + +
+ <% end %> + +
+ <%= form.label :maintenance_type %>
+ <%= form.text_field :maintenance_type, required: true %> +
+ +
+ <%= form.label :mileage_interval %>
+ <%= form.number_field :mileage_interval, min: 1, required: true %> +
+ +
+ <%= form.label :time_interval, "Time interval in days" %>
+ <%= form.number_field :time_interval, min: 1, required: true %> +
+ +
+ <%= form.label :baseline_odometer %>
+ <%= form.number_field :baseline_odometer, min: 0, required: true %> +
+ +
+ <%= form.label :baseline_date %>
+ <%= form.date_field :baseline_date, max: Date.current, required: true %> +
+ +
+ <%= form.check_box :active %> + <%= form.label :active %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/maintenance_schedules/edit.html.erb b/app/views/maintenance_schedules/edit.html.erb new file mode 100644 index 0000000..356eb98 --- /dev/null +++ b/app/views/maintenance_schedules/edit.html.erb @@ -0,0 +1,7 @@ +
+

<%= link_to "Back to maintenance schedules", vehicle_maintenance_schedules_path(@vehicle) %>

+ +

Edit Maintenance Schedule

+ + <%= render "form", vehicle: @vehicle, maintenance_schedule: @maintenance_schedule %> +
diff --git a/app/views/maintenance_schedules/index.html.erb b/app/views/maintenance_schedules/index.html.erb new file mode 100644 index 0000000..2f56b49 --- /dev/null +++ b/app/views/maintenance_schedules/index.html.erb @@ -0,0 +1,46 @@ +
+

<%= notice %>

+ +

<%= link_to "Back to vehicle", vehicle_path(@vehicle) %>

+ +

Maintenance Schedules

+

<%= @vehicle.year %> <%= @vehicle.make %> <%= @vehicle.model %>

+ +

<%= link_to "New maintenance schedule", new_vehicle_maintenance_schedule_path(@vehicle) %>

+ + <% if @maintenance_schedules.any? %> + + + + + + + + + + + + + + <% @maintenance_schedules.each do |schedule| %> + + + + + + + + + + <% end %> + +
TypeMileage intervalTime intervalBaseline odometerBaseline dateStatusActions
<%= link_to schedule.maintenance_type, vehicle_maintenance_schedule_path(@vehicle, schedule) %><%= number_with_delimiter(schedule.mileage_interval) %> miles<%= schedule.time_interval %> days<%= number_with_delimiter(schedule.baseline_odometer) %><%= schedule.baseline_date %><%= schedule.active? ? "Active" : "Inactive" %> + <%= 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 %> +
+ <% else %> +

No maintenance schedules recorded.

+ <% end %> +
diff --git a/app/views/maintenance_schedules/new.html.erb b/app/views/maintenance_schedules/new.html.erb new file mode 100644 index 0000000..3ca2a5d --- /dev/null +++ b/app/views/maintenance_schedules/new.html.erb @@ -0,0 +1,7 @@ +
+

<%= link_to "Back to maintenance schedules", vehicle_maintenance_schedules_path(@vehicle) %>

+ +

New Maintenance Schedule

+ + <%= render "form", vehicle: @vehicle, maintenance_schedule: @maintenance_schedule %> +
diff --git a/app/views/maintenance_schedules/show.html.erb b/app/views/maintenance_schedules/show.html.erb new file mode 100644 index 0000000..d47c469 --- /dev/null +++ b/app/views/maintenance_schedules/show.html.erb @@ -0,0 +1,26 @@ +
+

<%= notice %>

+ +

<%= link_to "Back to maintenance schedules", vehicle_maintenance_schedules_path(@vehicle) %>

+ +

<%= @maintenance_schedule.maintenance_type %>

+ +
+
Mileage interval
+
<%= number_with_delimiter(@maintenance_schedule.mileage_interval) %> miles
+ +
Time interval
+
<%= @maintenance_schedule.time_interval %> days
+ +
Baseline odometer
+
<%= number_with_delimiter(@maintenance_schedule.baseline_odometer) %>
+ +
Baseline date
+
<%= @maintenance_schedule.baseline_date %>
+ +
Status
+
<%= @maintenance_schedule.active? ? "Active" : "Inactive" %>
+
+ +

<%= link_to "Edit", edit_vehicle_maintenance_schedule_path(@vehicle, @maintenance_schedule) %>

+
diff --git a/app/views/vehicles/show.html.erb b/app/views/vehicles/show.html.erb index 21ee1dc..38169ec 100644 --- a/app/views/vehicles/show.html.erb +++ b/app/views/vehicles/show.html.erb @@ -49,6 +49,54 @@

Maintenance Schedules

+

<%= link_to "Manage maintenance schedules", vehicle_maintenance_schedules_path(@vehicle) %>

+ + <% 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? %> +
+

Overdue Maintenance

+ +
+ <% end %> + + <% if upcoming_statuses.any? %> +
+

Upcoming Maintenance

+ +
+ <% end %> + + <% if no_history_statuses.any? %> +
+

No Maintenance History

+ +
+ <% end %> + <% if @maintenance_schedules.any? %> @@ -56,14 +104,21 @@ + + + <% @maintenance_schedules.each do |schedule| %> + <% status = @maintenance_schedule_statuses.fetch(schedule) %> + + + <% end %> diff --git a/config/routes.rb b/config/routes.rb index e346f47..bb4227c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,9 @@ Rails.application.routes.draw do resources :vehicles do resources :fuel_entries, only: :create resources :maintenance_entries, only: :create + resources :maintenance_schedules, except: :destroy do + patch :deactivate, on: :member + end end root "vehicles#index" diff --git a/db/migrate/20260916120000_add_status_tracking_to_maintenance_schedules.rb b/db/migrate/20260916120000_add_status_tracking_to_maintenance_schedules.rb new file mode 100644 index 0000000..98b43a9 --- /dev/null +++ b/db/migrate/20260916120000_add_status_tracking_to_maintenance_schedules.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 62027bd..dd0f49f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.boolean "auto_rotate", default: true, 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 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.string "last_status" + t.datetime "last_status_changed_at" t.string "maintenance_type", null: false t.integer "mileage_interval", null: false t.integer "time_interval", null: false diff --git a/executions/phase006.md b/executions/phase006.md new file mode 100644 index 0000000..d32927a --- /dev/null +++ b/executions/phase006.md @@ -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` diff --git a/spec/factories/maintenance_schedules.rb b/spec/factories/maintenance_schedules.rb index 7a40627..ed8ed77 100644 --- a/spec/factories/maintenance_schedules.rb +++ b/spec/factories/maintenance_schedules.rb @@ -4,5 +4,8 @@ FactoryBot.define do maintenance_type { "Oil Change" } mileage_interval { 5_000 } time_interval { 180 } + baseline_odometer { 10_000 } + baseline_date { Date.current } + active { true } end end diff --git a/spec/models/maintenance_schedule_spec.rb b/spec/models/maintenance_schedule_spec.rb index 5e5f882..864f3d4 100644 --- a/spec/models/maintenance_schedule_spec.rb +++ b/spec/models/maintenance_schedule_spec.rb @@ -6,18 +6,43 @@ RSpec.describe MaintenanceSchedule do end 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.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 - it "requires positive integer intervals" do - schedule = build(:maintenance_schedule, mileage_interval: 0, time_interval: 0) + it "requires valid numeric values" do + schedule = build(:maintenance_schedule, mileage_interval: 0, time_interval: 0, baseline_odometer: -1) expect(schedule).not_to be_valid expect(schedule.errors[:mileage_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 it "requires maintenance type to be unique per vehicle case-insensitively" do diff --git a/spec/models/maintenance_schedule_status_spec.rb b/spec/models/maintenance_schedule_status_spec.rb new file mode 100644 index 0000000..f7f8e70 --- /dev/null +++ b/spec/models/maintenance_schedule_status_spec.rb @@ -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 diff --git a/spec/requests/vehicles_spec.rb b/spec/requests/vehicles_spec.rb index 4fa3dc0..7947487 100644 --- a/spec/requests/vehicles_spec.rb +++ b/spec/requests/vehicles_spec.rb @@ -111,6 +111,7 @@ RSpec.describe "Vehicles" do expect(response.body).to include(vehicle.make) expect(response.body).to include(vehicle.model) 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("Maintenance History") 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(newer_maintenance_entry.notes)).to be < response.body.index(older_maintenance_entry.notes) 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 describe "POST /vehicles/:vehicle_id/fuel_entries" do @@ -368,6 +504,17 @@ RSpec.describe "Vehicles" do } 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)) @identified_user = user access_key = AccessKey.current
Type Mileage interval Time intervalNext due odometerNext due dateStatus
<%= schedule.maintenance_type %> <%= number_with_delimiter(schedule.mileage_interval) %> miles <%= schedule.time_interval %> days<%= number_with_delimiter(status.due_odometer) %><%= status.due_date %><%= status.status_label %>