Currently, components could belong to bikes. But let’s say I have 3 chains and I rotate them every month. Or I have multiple wheelsets and I rotate them between multiple bikes. It would be super nice to track when something was mounted and when something is unmounted.

One of the simplest solutions for that would be to create a table to make these connections.

class CreateComponentAssignments < ActiveRecord::Migration[6.0]
  def change
    create_table :component_assignments do |t|
      t.references :bike, foreign_key: true
      t.references :component, foreign_key: true
      t.datetime :started_at
      t.datetime :ended_at

      t.timestamps
    end
  end
end

With new record

require "active_record"

module Db
  module Records
    class ComponentAssignment < ActiveRecord::Base
      belongs_to :bike
      belongs_to :component
    end
  end
end

but also I need to update old ones

module Db
  module Records
    class Component < ActiveRecord::Base
      has_many :component_assignments, class_name: "Db::Records::ComponentAssignment"
      has_many :bikes, through: :component_assignments
    end
  end
end
module Db
  module Records
    class Bike < ActiveRecord::Base
      has_many :component_assignments, class_name: "Db::Records::ComponentAssignment"
      has_many :components, through: :component_assignments
    end
  end
end

With this setup, you can now create and manage associations between components and bikes using the ComponentAssignment model. Each ComponentAssignment record will have a started_at and an optional ended_at to represent the time period for which the component is associated with a bike.

For example, to assign a component to a bike for a specific period, you can do the following:

component = Db::Records::Component.find_by(name: "Chain")
bike = Db::Records::Bike.find_by(name: "Mountain Bike")
assignment = ComponentAssignment.create(component: component, bike: bike, started_at: Time.new(2023, 6, 1, 11, 30), ended_at: Time.new(2023, 6, 30, 12, 00))

Later, if you want to change the association for a component, you can update the ended_at for the existing assignment and create a new assignment for the new association:

assignment = ComponentAssignment.find_by(component: component, bike: bike, ended_at: nil)
assignment.update(ended_at: Time.now)

new_bike = Db::Records::Bike.find_by(name: "Road Bike")
new_assignment = ComponentAssignment.create(component: component, bike: new_bike, started_at: Time.now)

With this approach, keeping bike_id in component record is currently redundant. But in the controller, we have an endpoint for filtering components by bike. Would be nice if it still works. Method all_by(filters) in the repository is universal, and probably be useful in the future, but right now I can replace it with something more useful for this situation.

def all_by_bikes(bike_id:)
  assignment_table = Db::Records::ComponentAssignment.arel_table

  Db::Records::Component
    .joins(:component_assignments)
    .where(
      Db::Records::ComponentAssignment.arel_table[:bike_id].eq(bike_id)
        .and(assignment_table[:started_at].lteq(Time.now))
        .and(assignment_table[:ended_at].gteq(Time.now).or(assignment_table[:ended_at].eq(nil)))
    )
    .map { |record| to_model(record).to_h }
end

What happen in this query? Let’s break down the query line by line:

assignment_table = Db::Records::ComponentAssignment.arel_table

In this line, we create a variable assignment_table, which holds the Arel table object for the Db::Records::ComponentAssignment table. This allows us to reference the table’s columns and build conditions using the Arel syntax.

Db::Records::Component
  .joins(:component_assignments)

Here, we are querying the Db::Records::Component table and using the joins method to join it with the component_assignments table. This ensures that we fetch only those components that have related component assignments.

.where(
  Db::Records::ComponentAssignment.arel_table[:bike_id].eq(bike_id)
    .and(assignment_table[:started_at].lteq(Time.now))
    .and(assignment_table[:ended_at].gteq(Time.now).or(assignment_table[:ended_at].eq(nil)))
)

This part specifies the conditions for the query using the where method. Let’s break it down further:

  • Db::Records::ComponentAssignment.arel_table[:bike_id].eq(bike_id): This condition checks if the bike_id column in the component_assignments table is equal to the given bike_id. It filters components based on the specified bike_id.
  • assignment_table[:started_at].lteq(Time.now): This condition checks if the started_at column in the component_assignments table is less than or equal to the current time (Time.now). It ensures that we only consider assignments that have started on or before today.
  • assignment_table[:ended_at].gteq(Time.now).or(assignment_table[:ended_at].eq(nil)): This condition checks if the ended_at column in the component_assignments table is greater than or equal to the current time (Time.now) or if it is NULL. This allows us to include assignments that have either not ended yet (greater than now) or have no specified end date.
.map { |record| to_model(record).to_h }

Finally, after specifying the conditions, we use the map method to convert the resulting ActiveRecord objects to your custom model objects using the to_model method. The .to_h at the end converts the model objects to a hash.

Overall, this query fetches components that have associated component assignments with a specific bike_id and are currently valid based on their start and end dates (if any). It then converts the result to your custom model objects and returns them as an array of hashes. I remove bike_id from the component earlier. But It would be nice to return bike_id based on component usage. Frontend would know about which bike should get details.

def to_model(record)
  Components::Model.new(
    id: record.id,
    name: record.name,
    brand: record.brand,
    model: record.model,
    weight: record.weight,
    notes: record.notes,
    bike_id: last_bike_id(record)
  )
end

def last_bike_id(record)
  record.component_assignments.where(ended_at: nil).last&.bike&.id
end

Rspec tests for that should be much more fun

describe "#all_by_bikes" do
  let(:today) { Date.today }

  it "returns all components assigned to a specific bike and currently valid" do
    bike = Db::Records::Bike.create(name: "Test Bike")
    component1 = Db::Records::Component.create(name: "Component 1")
    component2 = Db::Records::Component.create(name: "Component 2")
    assignment1 = Db::Records::ComponentAssignment.create(bike: bike, component: component1, start_date: today - 2.days, end_date: today + 2.days)
    assignment2 = Db::Records::ComponentAssignment.create(bike: bike, component: component2, start_date: today - 5.days, end_date: today - 3.days)

    components = repository.all_by_bikes(bike_id: bike.id)

    expect(components).to be_an(Array)
    expect(components.length).to eq(1)

    component = components.first
    expect(component).to include(
      id: component1.id,
      name: component1.name,
    )
  end

  it "returns an empty array when no components are assigned to the bike" do
    bike = Db::Records::Bike.create(name: "Test Bike")

    components = repository.all_by_bikes(bike_id: bike.id)

    expect(components).to be_an(Array)
    expect(components).to be_empty
  end

  it "returns an empty array when no valid assignments exist for the bike" do
    bike = Db::Records::Bike.create(name: "Test Bike")
    component1 = Db::Records::Component.create(name: "Component 1")
    assignment1 = Db::Records::ComponentAssignment.create(bike: bike, component: component1, start_date: today - 5.days, end_date: today - 3.days)

    components = repository.all_by_bikes(bike_id: bike.id)

    expect(components).to be_an(Array)
    expect(components).to be_empty
  end
end

Now I have a nice question. Should mount and unmount be on the bike controller or component controller, or maybe it should be separate controllers? Mounting and unmounting are actions that affect components, but also bikes. Bikes are made from components. When you mount a component, you are associating it with a specific bike, but the main entity being modified is the component. Similarly, when you unmount a component, you are removing the association from the component.

Therefore, it would be more appropriate to have separate controllers for Components and ComponentAssignments

With this design, the actions are more logically organized and aligned with the concepts they represent. The ComponentsController handles CRUD operations for components, while the ComponentAssignmentsController handles the specific actions related to mounting and unmounting components. The create action is responsible for mounting a component, while the destroy action is responsible for unmounting a component

module ComponentAssignments
  class Controller
    def create(request)
      component_data = ComponentAssignments::Contract.new.call(JSON.parse(request.body.read))
      if component_data.errors.to_h.any?
        [500, {"content-type" => "text/plain"}, ["Error creating component"]]
      else
        component = ComponentAssignments::Repository.new.create(
          bike_id: component_data["bike_id"],
          component_id: component_data["component_id"]
        )
        if component
          [201, {"content-type" => "text/plain"}, ["Create"]]
        else
          [500, {"content-type" => "text/plain"}, ["Error creating component assignments"]]
        end
      end
    end

    def delete(request)
      component_data = ComponentAssignments::Contract.new.call(JSON.parse(request.body.read))
      if component_data.errors.to_h.any?
        [500, {"content-type" => "text/plain"}, ["Error creating component"]]
      else
        delete = ComponentAssignments::Repository.new.delete(
          bike_id: component_data["bike_id"],
          component_id: component_data["component_id"]
        )
        if delete
          [200, {"content-type" => "text/plain"}, ["Delete assignment"]]
        else
          [500, {"content-type" => "text/plain"}, ["Error deleting component assignments"]]
        end
      end
    rescue ComponentAssignments::RecordNotFound
      [404, {"content-type" => "text/plain"}, ["Not Found"]]
    end
  end
end

I aligned my frontend to use this endpoint, but I’m close to the moment when I should rethink and rewrite frontend part of the application.

function toggleComponentAssignmentForm() {
  const componentAssignmentForm = document.getElementById('componentAssignmentForm');
  if (componentAssignmentForm.style.display === 'none') {
    showComponentAssignmentForm();
  } else {
    hideComponentAssignmentForm();
  }
}

// Function to show the component assignment form
function showComponentAssignmentForm() {
  Promise.all([fetch('/bikes'), fetch('/components')])
    .then(responses => Promise.all(responses.map(response => response.json())))
    .then(([bikesData, componentsData]) => {
      if (bikesData.length === 0) {
        alert('No bikes available. Please create a bike first.');
      } else {
        const componentAssignmentForm = document.getElementById('componentAssignmentForm');
        componentAssignmentForm.style.display = 'block';

        const bikeSelect = document.getElementById('bikeSelect');
        bikeSelect.innerHTML = '';

        bikesData.forEach(bike => {
          const option = document.createElement('option');
          option.value = bike.id;
          option.textContent = bike.name;
          bikeSelect.appendChild(option);
        });

        const componentSelect = document.getElementById('componentSelect');
        componentSelect.innerHTML = '';

        const availableComponents = componentsData.filter(component => component.bike_id === null);

        availableComponents.forEach(component => {
          const option = document.createElement('option');
          option.value = component.id;
          option.textContent = component.name;
          componentSelect.appendChild(option);
        });
      }
    })
    .catch(error => {
      console.error('Error:', error);
      bikesList.textContent = 'Error retrieving data.';
    });
}

// Function to hide the component assignment form
function hideComponentAssignmentForm() {
  const componentAssignmentForm = document.getElementById('componentAssignmentForm');
  componentAssignmentForm.style.display = 'none';
}

// Function to create a new component assignment
function createComponentAssignment() {
  const bikeSelect = document.getElementById('bikeSelect');
  const componentSelect = document.getElementById('componentSelect');

  const selectedBikeId = bikeSelect.value;
  const selectedComponentId = componentSelect.value;

  if (selectedBikeId && selectedComponentId) {
    fetch(`/component_assignments`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        bike_id: parseInt(selectedBikeId),
        component_id: parseInt(selectedComponentId)
      })
    })
      .then(response => {
        if (response.ok) {
          viewAllComponents();
        } else {
          throw new Error('Failed to create component assignment.');
        }
      })
      .catch(error => {
        console.error('Error:', error);
        bikesList.textContent = 'Error creating component assignment.';
      });
  } else {
    alert('Please select a bike and a component before creating the assignment.');
  }
}

// Function to delete a component assignment
function deleteComponentAssignment(component_id, bike_id) {
  const selectedBikeId = bike_id;
  const selectedComponentId = component_id;
  if (selectedBikeId && selectedComponentId) {
    fetch(`/component_assignments`, {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        bike_id: parseInt(selectedBikeId),
        component_id: parseInt(selectedComponentId)
      })
    })
      .then(response => {
        if (response.ok) {
          viewAllComponents();
        } else {
          throw new Error('Failed to delete component assignment.');
        }
      })
      .catch(error => {
        console.error('Error:', error);
        bikesList.textContent = 'Error delete component assignment.';
      });
  }
}

By implementing the proposed solution and refining the frontend, you can easily track and manage component assignments for your bike inventory system. The ComponentAssignments table helps you record the mounting and unmounting of components, providing valuable insights into component usage and optimizing your bike maintenance process. The whole code is on github