[Rails] Fertilizer recipe solver for the FAPG.
Last commit.
Changed files
- app/controllers/dashboard_controller.rb
- app/controllers/targets_controller.rb
- app/helpers/targets_helper.rb
- app/models/concerns/nutrient_vector.rb
- app/models/nutrient.rb
- app/models/nutrient_measurement.rb
- app/models/target.rb
- app/models/target_allocation.rb
- app/views/dashboard/_nutrient_measurements_table.html.erb
- app/views/dashboard/_nutrient_target_table.html.erb
- app/views/dashboard/_raft_allocation.html.erb
- app/views/dashboard/_target_table.html.erb
- app/views/dashboard/index.html.erb
- app/views/targets/index.html.erb
- app/views/targets/new.html.erb
- db/migrate/20250820175727_create_nutrients.rb
- db/migrate/20250823142743_create_beds.rb
- db/migrate/20250823142858_create_rafts.rb
- db/migrate/20250901112954_rename_crops_to_nutrient_profiles.rb
- db/schema.rb
- db/seeds.rb
- db/seeds/Nutrient.rb
- db/seeds/NutrientProfile.rb
- db/seeds/Target.rb
app/controllers/dashboard_controller.rb
@@ -1,50 +1,11 @@
1
1
class DashboardController < ApplicationController
2
2
def index
3
Removed:
# Raft allocation by crop type
4
Removed:
# @raft_data = raft_data_series
3
Added:
@latest_measurement = NutrientMeasurement.order(measured_on: :desc).first
4
Added:
@latest_target = Target.order(created_at: :desc).first
5
5
6
Removed:
@nutrient_profiles = NutrientProfile.order(:name)
7
Removed:
8
Removed:
# Nutrient target table
9
Removed:
# @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
10
Removed:
# @target = TargetNutrientCalculator.call
11
Removed:
12
6
# Measurement history table
13
7
@measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
14
8
@npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k)
15
9
@ammonium_measurement_data = NutrientMeasurement.data_series_for(:nnh4)
16
Removed:
17
Removed:
@weighted = Target.first.weighted_requirements # => { "nno3"=>..., "p"=>..., ... }
18
Removed:
19
Removed:
last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
20
Removed:
@latest_measurements = {}
21
Removed:
22
Removed:
if last
23
Removed:
# Use the same keys as NutrientProfile to keep naming consistent.
24
Removed:
keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s)
25
Removed:
keys.each do |k|
26
Removed:
@latest_measurements[k] = last.send(k) if last.respond_to?(k)
27
Removed:
end
28
Removed:
end
29
Removed:
end
30
Removed:
31
Removed:
private
32
Removed:
33
Removed:
def raft_data_series
34
Removed:
data_series = []
35
Removed:
36
Removed:
counts = Raft.left_outer_joins(:crop)
37
Removed:
.group("crops.name", "crops.crop_type")
38
Removed:
.count
39
Removed:
40
Removed:
counts.each do |(crop_name, crop_type), count|
41
Removed:
name = (crop_name || "unassigned").titleize
42
Removed:
type = (crop_type || "unassigned").titleize
43
Removed:
data = { type => count }
44
Removed:
data_series << { name:, data: }
45
Removed:
end
46
Removed:
47
Removed:
unassigned, assigned = data_series.partition { |s| s[:name].casecmp("unassigned").zero? }
48
Removed:
assigned + unassigned
49
10
end
50
11
end
app/controllers/targets_controller.rb
@@ -6,16 +6,24 @@
6
6
end
7
7
8
8
def new
9
Removed:
@target = Target.new(name: "Cible #{Date.today + 1.month}")
10
Removed:
seed_allocations
9
Added:
@target = Target.new
10
Added:
@nutrient_profiles = NutrientProfile.order(:name)
11
Added:
# Build one allocation per profile so each appears as a row
12
Added:
@nutrient_profiles.each do |np|
13
Added:
@target.target_allocations.build(nutrient_profile: np, percentage: 12.5)
14
Added:
end
11
15
end
12
16
13
17
def create
14
18
@target = Target.new(target_params)
15
19
if @target.save
16
Removed:
redirect_to @target, notice: "Cible enregistrée."
20
Added:
redirect_to @target, notice: "Cible créée."
17
21
else
18
Removed:
seed_allocations if @target.target_allocations.blank?
22
Added:
# Rebuild rows for any profiles missing (e.g., after validation errors)
23
Added:
existing_ids = @target.target_allocations.map(&:nutrient_profile_id)
24
Added:
(NutrientProfile.where.not(id: existing_ids)).order(:name).each do |np|
25
Added:
@target.target_allocations.build(nutrient_profile: np, percentage: 0)
26
Added:
end
19
27
render :new, status: :unprocessable_entity
20
28
end
21
29
end
@@ -31,18 +39,12 @@
31
39
end
32
40
end
33
41
34
Removed:
def show
35
Removed:
@weighted = @target.weighted_requirements # => { "nno3"=>..., "p"=>..., ... }
36
Removed:
37
Removed:
last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
38
Removed:
@latest_measurements = {}
39
Removed:
40
Removed:
if last
41
Removed:
# Use the same keys as NutrientProfile to keep naming consistent.
42
Removed:
keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s)
43
Removed:
keys.each do |k|
44
Removed:
@latest_measurements[k] = last.send(k) if last.respond_to?(k)
45
Removed:
end
42
Added:
def destroy
43
Added:
@target = Target.find(params[:id])
44
Added:
@target.destroy
45
Added:
respond_to do |format|
46
Added:
format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@target)) }
47
Added:
format.html { redirect_to targets_path, notice: "Cible supprimé." }
46
48
end
47
49
end
48
50
@@ -50,13 +52,6 @@
50
52
51
53
def set_target
52
54
@target = Target.find(params[:id])
53
Removed:
end
54
Removed:
55
Removed:
def seed_allocations
56
Removed:
existing_ids = @target.target_allocations.map(&:nutrient_profile_id).compact
57
Removed:
(NutrientProfile.order(:name).pluck(:id) - existing_ids).each do |np_id|
58
Removed:
@target.target_allocations.build(nutrient_profile_id: np_id, percentage: 12.5)
59
Removed:
end
60
55
end
61
56
62
57
def target_params
app/helpers/targets_helper.rb
@@ -1,2 +0,0 @@
1
Removed:
module TargetsHelper
2
Removed:
end
app/models/concerns/nutrient_vector.rb
@@ -0,0 +1,55 @@
1
Added:
module NutrientVector
2
Added:
extend ActiveSupport::Concern
3
Added:
4
Added:
NUTRIENT_KEYS = %i[
5
Added:
nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4
6
Added:
].freeze
7
Added:
8
Added:
included do
9
Added:
# Define simple readers (nno3, p, k, ...) that read from #nutrient_values
10
Added:
NUTRIENT_KEYS.each do |k|
11
Added:
define_method(k) { nutrient_values[k] }
12
Added:
end
13
Added:
end
14
Added:
15
Added:
# Hash-like access
16
Added:
def [](key)
17
Added:
key = key.to_sym
18
Added:
return nil unless NUTRIENT_KEYS.include?(key)
19
Added:
public_send(key)
20
Added:
end
21
Added:
22
Added:
def keys = NUTRIENT_KEYS
23
Added:
24
Added:
# Returns a copy to avoid accidental mutation
25
Added:
def to_h
26
Added:
NUTRIENT_KEYS.index_with { |k| public_send(k) }
27
Added:
end
28
Added:
29
Added:
# Iterate over pairs
30
Added:
def each_pair
31
Added:
return enum_for(:each_pair) unless block_given?
32
Added:
NUTRIENT_KEYS.each { |k| yield k, public_send(k) }
33
Added:
end
34
Added:
35
Added:
# Simple difference (self - other), useful for “how far from target?”
36
Added:
def delta_against(other)
37
Added:
NUTRIENT_KEYS.index_with { |k| (public_send(k).to_f) - (other.public_send(k).to_f) }
38
Added:
end
39
Added:
40
Added:
# Percent difference relative to other (e.g., measurement vs target)
41
Added:
# Returns 0 when both are 0, and nil when target is 0 but measurement isn’t.
42
Added:
def percent_diff_against(other)
43
Added:
NUTRIENT_KEYS.index_with do |k|
44
Added:
a = public_send(k).to_f
45
Added:
b = other.public_send(k).to_f
46
Added:
if b.zero? && a.zero?
47
Added:
0.0
48
Added:
elsif b.zero?
49
Added:
nil
50
Added:
else
51
Added:
((a - b) / b) * 100.0
52
Added:
end
53
Added:
end
54
Added:
end
55
Added:
end
app/models/nutrient.rb
@@ -0,0 +1,4 @@
1
Added:
class Nutrient < ApplicationRecord
2
Added:
validates :formula, presence: true, uniqueness: true
3
Added:
validates :name, presence: true
4
Added:
end
app/models/nutrient_measurement.rb
@@ -1,7 +1,5 @@
1
1
class NutrientMeasurement < ApplicationRecord
2
Removed:
NUTRIENT_FIELDS = %i[
3
Removed:
nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4
4
Removed:
].freeze
2
Added:
include NutrientVector
5
3
6
4
validates :measured_on, presence: true
7
5
validates :measured_on, uniqueness: true
@@ -12,7 +10,11 @@
12
10
end
13
11
end
14
12
15
Removed:
def self.nutrient_fields
16
Removed:
NUTRIENT_FIELDS
13
Added:
private
14
Added:
15
Added:
def nutrient_values
16
Added:
@nutrient_values ||= NutrientVector::NUTRIENT_KEYS.index_with do |k|
17
Added:
read_attribute(k).to_f
18
Added:
end
17
19
end
18
20
end
app/models/target.rb
@@ -1,42 +1,53 @@
1
Removed:
# app/models/target.rb
2
1
class Target < ApplicationRecord
3
Removed:
has_many :target_allocations, dependent: :destroy
4
Removed:
has_many :nutrient_profiles, through: :target_allocations
2
Added:
include NutrientVector
5
3
6
Removed:
accepts_nested_attributes_for :target_allocations, allow_destroy: true
7
Removed:
# validate :percentages_sum_to_100
4
Added:
has_many :target_allocations, inverse_of: :target, dependent: :destroy
8
5
9
Removed:
def weighted_requirements
10
Removed:
totals = Hash.new(0.0)
11
Removed:
denom = 100.0
6
Added:
after_initialize :set_defaults, if: :new_record?
12
7
13
Removed:
target_allocations.includes(:nutrient_profile).each do |alloc|
14
Removed:
profile = alloc.nutrient_profile
15
Removed:
next unless profile
8
Added:
validate :allocations_sum_to_100
16
9
17
Removed:
weight = (alloc.percentage || 0).to_f / denom
18
Removed:
next if weight <= 0
10
Added:
# Recompute when allocations change; keep it simple (memoized per instance)
11
Added:
def recompute_nutrients!
12
Added:
@nutrient_values = nil
13
Added:
nutrient_values
14
Added:
end
19
15
20
Removed:
# Prefer the helper, but gracefully fall back to slicing attributes.
21
Removed:
reqs = if profile.respond_to?(:requirements_hash)
22
Removed:
profile.requirements_hash
23
Removed:
else
24
Removed:
profile.attributes.slice(*NutrientProfile::NUTRIENT_KEYS.map(&:to_s))
25
Removed:
end
16
Added:
private
26
17
27
Removed:
reqs.each do |nutrient_key, value|
28
Removed:
next if value.nil?
29
Removed:
totals[nutrient_key.to_s] += value.to_f * weight
18
Added:
def nutrient_values
19
Added:
@nutrient_values ||= get_nutrient_values
20
Added:
end
21
Added:
22
Added:
def get_nutrient_values
23
Added:
sums = Hash.new(0.0)
24
Added:
allocs = target_allocations.includes(:nutrient_profile)
25
Added:
26
Added:
allocs.each do |alloc|
27
Added:
weight = alloc.percentage.to_f / 100.0
28
Added:
profile = alloc.nutrient_profile
29
Added:
NutrientVector::NUTRIENT_KEYS.each do |k|
30
Added:
sums[k] += profile.public_send(k).to_f * weight
30
31
end
31
32
end
32
33
33
Removed:
totals
34
Added:
# Ensure all keys exist, even when there are no allocations
35
Added:
NutrientVector::NUTRIENT_KEYS.each { |k| sums[k] ||= 0.0 }
36
Added:
sums.freeze
34
37
end
35
38
36
Removed:
private
39
Added:
def set_defaults
40
Added:
self.name ||= "Cible #{Date.today + 1.month}"
41
Added:
if target_allocations.empty?
42
Added:
nutrient_profiles = NutrientProfile.limit(3)
43
Added:
nutrient_profiles.each do |profile|
44
Added:
target_allocations.build(nutrient_profile: profile, percentage: 0)
45
Added:
end
46
Added:
end
47
Added:
end
37
48
38
Removed:
# def percentages_sum_to_100
39
Removed:
# sum = target_allocations.sum { |a| a.percentage.to_f }
40
Removed:
# errors.add(:base, "La somme des pourcentages doit être égale à 100%") unless (sum - 100.0).abs <= 0.1
41
Removed:
# end
49
Added:
def allocations_sum_to_100
50
Added:
sum = target_allocations.reject(&:marked_for_destruction?).sum { |a| a.percentage.to_f }
51
Added:
errors.add(:base, "La somme des pourcentages doit être 100%") unless (sum - 100).abs < 0.01
52
Added:
end
42
53
end
app/models/target_allocation.rb
@@ -1,7 +1,6 @@
1
1
class TargetAllocation < ApplicationRecord
2
Removed:
belongs_to :target
2
Added:
belongs_to :target, inverse_of: :target_allocations, touch: true
3
3
belongs_to :nutrient_profile
4
4
5
5
validates :percentage, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
6
Removed:
validates :nutrient_profile_id, uniqueness: { scope: :target_id }
7
6
end
app/views/dashboard/_nutrient_measurements_table.html.erb
@@ -2,8 +2,8 @@
2
2
<div class="card-header d-flex justify-content-between align-items-center">
3
3
<h4 class="mb-0">Relevé des Nutriments</h4>
4
4
<div class="btn-group">
5
Removed:
<%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-primary" %>
6
Removed:
<%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-secondary" %>
5
Added:
<%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-outline-primary" %>
6
Added:
<%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-outline-secondary" %>
7
7
</div>
8
8
</div>
9
9
app/views/dashboard/_nutrient_target_table.html.erb
@@ -2,40 +2,33 @@
2
2
<div class="card-header d-flex justify-content-between align-items-center">
3
3
<h4 class="mb-0">Complémentation</h4>
4
4
<div class="btn-group">
5
Removed:
<%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-primary" %>
6
Removed:
<%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-secondary" %>
5
Added:
<%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-outline-primary" %>
6
Added:
<%= link_to "Liste des cibles", targets_path, class: "btn btn-sm btn-outline-secondary" %>
7
Added:
<%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-outline-secondary" %>
7
8
</div>
8
9
</div>
9
10
10
11
<div class="card-body p-0">
11
12
<div class="table-responsive">
12
Removed:
<table class="table table-sm table-striped table-hover align-middle mb-0">
13
Added:
<table class="table table-striped table-hover align-middle mb-0">
13
14
<thead class="table-light">
14
15
<tr>
15
16
<th>Nutriment</th>
16
17
<th class="text-end">Relevé</th>
17
Removed:
<th class="text-end">Cible</th>
18
Removed:
<th class="text-end">Delta</th>
18
Added:
<th class="text-end"><%= @latest_target&.name || "Cible" %></th>
19
Added:
<th class="text-end">Delta (%)</th>
19
20
</tr>
20
21
</thead>
21
22
<tbody>
22
Removed:
<% wr = @weighted || {} %>
23
Removed:
<% lm = @latest_measurements || {} %>
23
Added:
<% Nutrient.all.each do |nutrient| %>
24
Added:
<% measured = @latest_measurement.send(nutrient.formula.downcase) %>
25
Added:
<% target = @latest_target.send(nutrient.formula.downcase) %>
26
Added:
<% delta = (target.to_f - measured.to_f) / 100.0 if measured || target %>
24
27
25
Removed:
<% keys = (wr.keys + lm.keys).map(&:to_s).uniq.sort %>
26
Removed:
<% keys.each do |nut| %>
27
Removed:
<% measured = lm[nut] %>
28
Removed:
<% target = wr[nut] %>
29
Removed:
<% delta = (measured.to_f - target.to_f) if measured || target %>
30
28
<tr>
31
Removed:
<td class="fw-semibold"><%= nut.upcase %></td>
32
Removed:
29
Added:
<td class="fw-semibold"><%= nutrient.formula %></td>
33
30
<td class="text-end">
34
Removed:
<% if measured.nil? %>
35
Removed:
<span class="text-muted">—</span>
36
Removed:
<% else %>
37
Removed:
<%= number_with_precision(measured, precision: 2) %>
38
Removed:
<% end %>
31
Added:
<%= measured.present? ? number_with_precision(measured, precision: 2) : — %>
39
32
</td>
40
33
41
34
<td class="text-end">
@@ -50,7 +43,7 @@
50
43
<% if measured.nil? && target.nil? %>
51
44
<span class="text-muted">—</span>
52
45
<% else %>
53
Removed:
<% badge =
46
Added:
<% badge_class =
54
47
if delta.nil?
55
48
"text-bg-secondary"
56
49
elsif delta.abs <= 0.01
@@ -60,7 +53,7 @@
60
53
else
61
54
"text-bg-danger"
62
55
end %>
63
Removed:
<span class="badge <%= badge %>">
56
Added:
<span class="badge <%= badge_class %>">
64
57
<%= number_with_precision(delta.to_f, precision: 2) %>
65
58
</span>
66
59
<% end %>
app/views/dashboard/_raft_allocation.html.erb
@@ -1,9 +0,0 @@
1
Removed:
<div class="card shadow mb-4">
2
Removed:
<div class="card-header d-flex justify-content-between align-items-center">
3
Removed:
<h5 class="mb-0">Crop Allocation</h5>
4
Removed:
<%#= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %>
5
Removed:
</div>
6
Removed:
7
Removed:
<%= bar_chart @raft_data, stacked: true %>
8
Removed:
</div>
9
Removed:
app/views/dashboard/_target_table.html.erb
@@ -1,39 +0,0 @@
1
Removed:
<div class="card shadow mb-4">
2
Removed:
<div class="card-header d-flex justify-content-between align-items-center">
3
Removed:
<h5 class="mb-0">Target nutrient concentrations</h5>
4
Removed:
<%= link_to "Get Ferti© recipe", ferti_recipe_path, class: "btn btn-sm btn-primary" %>
5
Removed:
</div>
6
Removed:
7
Removed:
<div class="table-responsive">
8
Removed:
<table class="table table-sm align-middle mb-0">
9
Removed:
<thead class="table-light">
10
Removed:
<tr>
11
Removed:
<th scope="col" class="text-nowrap">Nutrient</th>
12
Removed:
<th scope="col" class="text-end"> Latest (mg/L)</th>
13
Removed:
<th scope="col" class="text-end">Target (mg/L)</th>
14
Removed:
<th scope="col" class="text-end">Δ %</th>
15
Removed:
</tr>
16
Removed:
</thead>
17
Removed:
<tbody>
18
Removed:
<% NutrientsHelper::NUTRIENTS.each do |n| %>
19
Removed:
<% latest = @latest_measurement[n] || 0 %>
20
Removed:
<% target = @target[n] || 0 %>
21
Removed:
<% delta = target - latest %>
22
Removed:
<tr>
23
Removed:
<th scope="row" class="text-nowrap"><%= n.upcase %></th>
24
Removed:
<td class="text-end"><%= fmt(latest) %></td>
25
Removed:
<td class="text-end"><%= fmt(target) %></td>
26
Removed:
<td class="text-end">
27
Removed:
<span class="badge <%= delta_badge_class(delta) %>">
28
Removed:
<%= fmt(delta) %>%
29
Removed:
</span>
30
Removed:
</td>
31
Removed:
</tr>
32
Removed:
<% end %>
33
Removed:
</tbody>
34
Removed:
</table>
35
Removed:
</div>
36
Removed:
<div class="card-footer">
37
Removed:
Latest measurement: <%= @latest_measurement.measured_on || "none yet" %>
38
Removed:
</div>
39
Removed:
</div>
app/views/dashboard/index.html.erb
@@ -2,8 +2,4 @@
2
2
3
3
<%= render "nutrient_target_table", nutrient_profiles: @nutrient_profiles %>
4
4
5
Removed:
<%#= render "raft_allocation" %>
6
Removed:
7
Removed:
<%#= render "target_table" %>
8
Removed:
9
5
<%= render "nutrient_measurements_table" %>
app/views/targets/index.html.erb
@@ -5,31 +5,29 @@
5
5
</div>
6
6
7
7
<div class="table-responsive">
8
Removed:
<table class="table table-sm table-striped table-hover align-middle mb-0">
8
Added:
<table class="table table-hover align-middle">
9
9
<thead class="table-light">
10
10
<tr>
11
11
<th>Nom</th>
12
Removed:
<th>Répartition</tr>
13
Removed:
<th class="text-end" style="width: 140px;">Total %</th>
14
Removed:
<th class="text-nowrap" style="width: 190px;">Créé le</th>
15
Removed:
<th class="text-end" style="width: 180px;">Actions</th>
12
Added:
<th>Répartition</th>
13
Added:
<th>Date de Création</th>
16
14
</tr>
17
15
</thead>
18
16
<tbody>
19
17
<% if @targets.present? %>
20
Removed:
<% @targets.each do |t| %>
21
Removed:
<% sum_pct = t.target_allocations.sum { |a| a.percentage.to_f } %>
18
Added:
<% @targets.each do |target| %>
19
Added:
<% sum_pct = target.allocations.sum { |a| a.percentage.to_f } %>
22
20
<% badge_class = (sum_pct - 100.0).abs <= 0.01 ? "bg-success" : "bg-danger" %>
23
21
<tr>
24
22
<td class="fw-semibold">
25
Removed:
<%= link_to t.name.presence || "Objectif ##{t.id}", t %>
23
Added:
<%= link_to target.name.presence || "Cible ##{t.id}", target %>
26
24
</td>
27
25
<td>
28
Removed:
<% if t.target_allocations.empty? %>
26
Added:
<% if target.allocations.empty? %>
29
27
<span class="text-muted">Aucune répartition définie</span>
30
28
<% else %>
31
Removed:
<ul class="list-unstyled mb-0 d-flex flex-wrap gap-2">
32
Removed:
<% t.target_allocations.each do |a| %>
29
Added:
<ul class="list-unstyled mb-0 d-flex flex-wrap gap-3">
30
Added:
<% target.allocations.each do |a| %>
33
31
<li class="badge text-bg-light border">
34
32
<%= a.nutrient_profile&.name || "Profil ##{a.nutrient_profile_id}" %>
35
33
— <%= number_with_precision(a.percentage.to_f, precision: 2) %>%
@@ -38,20 +36,8 @@
38
36
</ul>
39
37
<% end %>
40
38
</td>
41
Removed:
<td class="text-end">
42
Removed:
<span class="badge <%= badge_class %>">
43
Removed:
<%= number_with_precision(sum_pct, precision: 2) %>%
44
Removed:
</span>
45
Removed:
</td>
46
39
<td class="text-nowrap">
47
Removed:
<%= l(t.created_at, format: :short) %>
48
Removed:
</td>
49
Removed:
<td class="text-end text-nowrap">
50
Removed:
<%= link_to "Voir", t, class: "btn btn-outline-secondary btn-sm" %>
51
Removed:
<%= link_to "Modifier", edit_target_path(t), class: "btn btn-outline-primary btn-sm" %>
52
Removed:
<%# FIXME: Doesn't work. %>
53
Removed:
<%= link_to "Supprimer", t, class: "btn btn-outline-danger btn-sm",
54
Removed:
data: { turbo_method: :delete, turbo_confirm: "Supprimer cet objectif ?" } %>
40
Added:
<%= l(target.created_at, format: :short) %>
55
41
</td>
56
42
</tr>
57
43
<% end %>
app/views/targets/new.html.erb
@@ -1,85 +0,0 @@
1
Removed:
<% content_for :title, "Ajouter une Cible" %>
2
Removed:
3
Removed:
<h1 class="display-1">Ajouter une Cible</h1>
4
Removed:
5
Removed:
<%= form_with(model: @target) do |f| %>
6
Removed:
<div class="card shadow-sm">
7
Removed:
<div class="card-header">
8
Removed:
<%= f.text_field :name, class: "form-control", placeholder: "Nom de la cible" %>
9
Removed:
</div>
10
Removed:
11
Removed:
<div class="card-body p-0">
12
Removed:
<div class="table-responsive">
13
Removed:
<table class="table table-hover align-middle mb-0">
14
Removed:
<thead class="table-light">
15
Removed:
<tr>
16
Removed:
<th>Profil</th>
17
Removed:
<th class="text-end" style="width: 180px;">Proportion</th>
18
Removed:
</tr>
19
Removed:
</thead>
20
Removed:
<tbody id="alloc-table-body">
21
Removed:
<%= f.fields_for :target_allocations do |af| %>
22
Removed:
<% np = af.object.nutrient_profile %>
23
Removed:
<tr>
24
Removed:
<td>
25
Removed:
<%= af.hidden_field :nutrient_profile_id %>
26
Removed:
<strong><%= np&.name.capitalize || "Profil ##{af.object.nutrient_profile_id}" %></strong>
27
Removed:
</td>
28
Removed:
<td class="text-end">
29
Removed:
<div class="input-group input-group-sm" style="max-width: 160px; margin-left:auto;">
30
Removed:
<%= af.number_field :percentage,
31
Removed:
in: 0..100, step: 0.5,
32
Removed:
class: "form-control text-end alloc-input",
33
Removed:
placeholder: "0.0",
34
Removed:
data: { action: "input->alloc#sum" } %>
35
Removed:
<span class="input-group-text">%</span>
36
Removed:
</div>
37
Removed:
</td>
38
Removed:
</tr>
39
Removed:
<% end %>
40
Removed:
</tbody>
41
Removed:
<tfoot>
42
Removed:
<tr>
43
Removed:
<td class="small text-muted">Ajustez chaque pourcentage pour totaliser 100%.</td>
44
Removed:
<td class="text-end">
45
Removed:
<span class="badge bg-secondary" id="alloc-total">Total : 0%</span>
46
Removed:
</td>
47
Removed:
</tr>
48
Removed:
</tfoot>
49
Removed:
</table>
50
Removed:
</div>
51
Removed:
</div>
52
Removed:
53
Removed:
<div class="card-footer d-flex gap-2 justify-content-end">
54
Removed:
<div class="btn-group">
55
Removed:
<%= f.submit "Enregistrer l’objectif", class: "btn btn-primary", id: "submit-btn" %>
56
Removed:
<%= link_to "Annuler", targets_path, class: "btn btn-secondary" %>
57
Removed:
</div>
58
Removed:
</div>
59
Removed:
</div>
60
Removed:
<% end %>
61
Removed:
62
Removed:
<script>
63
Removed:
// Lightweight client-side sum check (no Stimulus required).
64
Removed:
document.addEventListener("turbo:load", initAllocSum);
65
Removed:
document.addEventListener("DOMContentLoaded", initAllocSum);
66
Removed:
67
Removed:
function initAllocSum() {
68
Removed:
const inputs = document.querySelectorAll(".alloc-input");
69
Removed:
const totalBadge = document.getElementById("alloc-total");
70
Removed:
const submitBtn = document.getElementById("submit-btn");
71
Removed:
if (!inputs.length || !totalBadge) return;
72
Removed:
73
Removed:
function updateTotal() {
74
Removed:
let sum = 0;
75
Removed:
inputs.forEach(i => sum += parseFloat(i.value || "0"));
76
Removed:
const rounded = Math.round(sum * 100) / 100;
77
Removed:
totalBadge.textContent = `Total : ${rounded}%`;
78
Removed:
totalBadge.className = "badge " + (Math.abs(rounded - 100) < 0.01 ? "bg-success" : "bg-danger");
79
Removed:
if (submitBtn) submitBtn.disabled = !(Math.abs(rounded - 100) < 0.01);
80
Removed:
}
81
Removed:
82
Removed:
inputs.forEach(i => i.addEventListener("input", updateTotal));
83
Removed:
updateTotal();
84
Removed:
}
85
Removed:
</script>
db/migrate/20250820175727_create_nutrients.rb
@@ -3,8 +3,6 @@
3
3
create_table :nutrients do |t|
4
4
t.string :formula
5
5
t.string :name
6
Removed:
7
Removed:
t.timestamps
8
6
end
9
7
end
10
8
end
db/migrate/20250823142743_create_beds.rb
@@ -1,11 +0,0 @@
1
Removed:
class CreateBeds < ActiveRecord::Migration[8.0]
2
Removed:
def change
3
Removed:
create_table :beds do |t|
4
Removed:
t.integer :location, null: false
5
Removed:
6
Removed:
t.timestamps
7
Removed:
end
8
Removed:
9
Removed:
add_index :beds, :location, unique: true
10
Removed:
end
11
Removed:
end
db/migrate/20250823142858_create_rafts.rb
@@ -1,13 +0,0 @@
1
Removed:
class CreateRafts < ActiveRecord::Migration[8.0]
2
Removed:
def change
3
Removed:
create_table :rafts do |t|
4
Removed:
t.references :bed, null: false, foreign_key: true
5
Removed:
t.integer :location, null: false
6
Removed:
t.references :crop, null: true, foreign_key: true
7
Removed:
8
Removed:
t.timestamps
9
Removed:
end
10
Removed:
11
Removed:
add_index :rafts, [ :bed_id, :location ], unique: true
12
Removed:
end
13
Removed:
end
db/migrate/20250901112954_rename_crops_to_nutrient_profiles.rb
@@ -1,10 +1,6 @@
1
1
class RenameCropsToNutrientProfiles < ActiveRecord::Migration[8.0]
2
2
def change
3
3
rename_table :crops, :nutrient_profiles
4
Removed:
5
Removed:
rename_column :rafts, :crop_id, :crop_nutrient_need_id
6
Removed:
add_index :rafts, :crop_nutrient_need_id unless index_exists?(:rafts, :crop_nutrient_need_id)
7
Removed:
8
4
remove_column :nutrient_profiles, :crop_type
9
5
end
10
6
end
db/schema.rb
@@ -11,13 +11,6 @@
11
11
# It's strongly recommended that you check this file into your version control system.
12
12
13
13
ActiveRecord::Schema[8.0].define(version: 2025_09_08_181147) do
14
Removed:
create_table "beds", force: :cascade do |t|
15
Removed:
t.integer "location", null: false
16
Removed:
t.datetime "created_at", null: false
17
Removed:
t.datetime "updated_at", null: false
18
Removed:
t.index ["location"], name: "index_beds_on_location", unique: true
19
Removed:
end
20
Removed:
21
14
create_table "fertilizer_components", force: :cascade do |t|
22
15
t.string "name"
23
16
t.string "formula"
@@ -107,21 +100,8 @@
107
100
create_table "nutrients", force: :cascade do |t|
108
101
t.string "formula"
109
102
t.string "name"
110
Removed:
t.datetime "created_at", null: false
111
Removed:
t.datetime "updated_at", null: false
112
103
end
113
104
114
Removed:
create_table "rafts", force: :cascade do |t|
115
Removed:
t.integer "bed_id", null: false
116
Removed:
t.integer "location", null: false
117
Removed:
t.integer "crop_nutrient_need_id"
118
Removed:
t.datetime "created_at", null: false
119
Removed:
t.datetime "updated_at", null: false
120
Removed:
t.index ["bed_id", "location"], name: "index_rafts_on_bed_id_and_location", unique: true
121
Removed:
t.index ["bed_id"], name: "index_rafts_on_bed_id"
122
Removed:
t.index ["crop_nutrient_need_id"], name: "index_rafts_on_crop_nutrient_need_id"
123
Removed:
end
124
Removed:
125
105
create_table "target_allocations", force: :cascade do |t|
126
106
t.integer "target_id", null: false
127
107
t.integer "nutrient_profile_id", null: false
@@ -140,8 +120,6 @@
140
120
141
121
add_foreign_key "fertilizer_compositions", "fertilizer_components"
142
122
add_foreign_key "fertilizer_compositions", "fertilizer_products"
143
Removed:
add_foreign_key "rafts", "beds"
144
Removed:
add_foreign_key "rafts", "nutrient_profiles", column: "crop_nutrient_need_id"
145
123
add_foreign_key "target_allocations", "nutrient_profiles"
146
124
add_foreign_key "target_allocations", "targets"
147
125
end
db/seeds.rb
@@ -8,7 +8,8 @@
8
8
# MovieGenre.find_or_create_by!(name: genre_name)
9
9
# end
10
10
11
Removed:
MODELS = [ NutrientMeasurement,
11
Added:
MODELS = [ Nutrient,
12
Added:
NutrientMeasurement,
12
13
NutrientProfile,
13
14
Target
14
15
].freeze
db/seeds/Nutrient.rb
@@ -0,0 +1,22 @@
1
Added:
nutrients = [
2
Added:
{ formula: "NNO3", name: "Nitrate" },
3
Added:
{ formula: "NNH4", name: "Ammonium" },
4
Added:
{ formula: "P", name: "Phosphorus" },
5
Added:
{ formula: "K", name: "Potassium" },
6
Added:
{ formula: "Ca", name: "Calcium" },
7
Added:
{ formula: "Mg", name: "Magnesium" },
8
Added:
{ formula: "S", name: "Sulfur" },
9
Added:
{ formula: "Na", name: "Sodium" },
10
Added:
{ formula: "Cl", name: "Chloride" },
11
Added:
{ formula: "Si", name: "Silicon" },
12
Added:
{ formula: "Fe", name: "Iron" },
13
Added:
{ formula: "Zn", name: "Zinc" },
14
Added:
{ formula: "B", name: "Boron" },
15
Added:
{ formula: "Mn", name: "Manganese" },
16
Added:
{ formula: "Cu", name: "Copper" },
17
Added:
{ formula: "Mo", name: "Molybdenum" }
18
Added:
]
19
Added:
20
Added:
nutrients.each do |attrs|
21
Added:
Nutrient.find_or_create_by!(attrs)
22
Added:
end
db/seeds/NutrientProfile.rb
@@ -1,4 +1,5 @@
1
Removed:
[ { name: "formule moyenne générale",
1
Added:
profiles = [
2
Added:
{ name: "formule moyenne générale",
2
3
nno3: 160.00,
3
4
p: 30.00,
4
5
k: 230.00,
@@ -102,7 +103,9 @@
102
103
mn: 0.11,
103
104
cu: 0.03,
104
105
mo: 0.01 }
105
Removed:
].each do |profile|
106
Added:
]
107
Added:
108
Added:
profiles.each do |profile|
106
109
NutrientProfile.find_or_create_by!(name: profile[:name]) do |p|
107
110
p.attributes = profile
108
111
end
db/seeds/Target.rb
@@ -10,7 +10,7 @@
10
10
missing = wanted.keys - profiles.keys
11
11
raise "Missing NutrientProfile(s): #{missing.join(', ')}" if missing.any?
12
12
13
Removed:
target = Target.find_or_create_by!(name: "Objectif par défaut")
13
Added:
target = Target.find_or_create_by!(name: "Cible par défaut")
14
14
15
15
target.target_allocations.destroy_all
16
16
wanted.each do |name, pct|