[Rails] Fertilizer recipe solver for the FAPG.
Create Target and nutrient target table on dashboard.
Changed files
- app/controllers/dashboard_controller.rb
- app/controllers/nutrient_measurements_controller.rb
- app/controllers/targets_controller.rb
- app/helpers/targets_helper.rb
- app/models/nutrient_measurement.rb
- app/models/nutrient_profile.rb
- app/models/target.rb
- app/models/target_allocation.rb
- app/views/dashboard/_nutrient_measurements.html.erb
- app/views/dashboard/_nutrient_measurements_table.html.erb
- app/views/dashboard/_nutrient_profile_allocator.html.erb
- app/views/dashboard/_nutrient_target_table.html.erb
- app/views/dashboard/_target_table.html.erb
- app/views/dashboard/index.html.erb
- app/views/nutrient_measurement/index.html.erb
- app/views/nutrient_measurements/_form.html.erb
- app/views/nutrient_measurements/index.html.erb
- app/views/nutrient_measurements/new.html.erb
- app/views/targets/create.html.erb
- app/views/targets/edit.html.erb
- app/views/targets/index.html.erb
- app/views/targets/new.html.erb
- app/views/targets/show.html.erb
- app/views/targets/update.html.erb
- config/routes.rb
- db/migrate/20250908181137_create_targets.rb
- db/migrate/20250908181147_create_target_allocations.rb
- db/schema.rb
- db/seeds/NutrientProfile.rb
- test/controllers/targets_controller_test.rb
- test/fixtures/target_allocations.yml
- test/fixtures/targets.yml
- test/models/target_allocation_test.rb
- test/models/target_test.rb
app/controllers/dashboard_controller.rb
@@ -10,10 +10,22 @@
10
10
# @target = TargetNutrientCalculator.call
11
11
12
12
# Measurement history table
13
Removed:
# @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
13
Added:
@measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
14
Added:
@npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k)
15
Added:
@ammonium_measurement_data = NutrientMeasurement.data_series_for(:nnh4)
14
16
15
Removed:
# @npk_measurement_data = measurement_data_series(:nno3, :p, :k)
16
Removed:
# @ammonium_measurement_data = measurement_data_series(:nnh4)
17
Added:
@weighted = Target.first.weighted_requirements # => { "nno3"=>..., "p"=>..., ... }
18
Added:
19
Added:
last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
20
Added:
@latest_measurements = {}
21
Added:
22
Added:
if last
23
Added:
# Use the same keys as NutrientProfile to keep naming consistent.
24
Added:
keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s)
25
Added:
keys.each do |k|
26
Added:
@latest_measurements[k] = last.send(k) if last.respond_to?(k)
27
Added:
end
28
Added:
end
17
29
end
18
30
19
31
private
@@ -34,14 +46,5 @@
34
46
35
47
unassigned, assigned = data_series.partition { |s| s[:name].casecmp("unassigned").zero? }
36
48
assigned + unassigned
37
Removed:
end
38
Removed:
39
Removed:
def measurement_data_series(*nutrients)
40
Removed:
nutrients.map do |formula|
41
Removed:
{ name: Nutrient.find_by!(formula:).name,
42
Removed:
data: NutrientMeasurement
43
Removed:
.order(:measured_on)
44
Removed:
.pluck(:measured_on, formula) }
45
Removed:
end
46
49
end
47
50
end
app/controllers/nutrient_measurements_controller.rb
@@ -0,0 +1,26 @@
1
Added:
class NutrientMeasurementsController < ApplicationController
2
Added:
def index
3
Added:
@nutrient_measurements = NutrientMeasurement.order(measured_on: :desc)
4
Added:
@npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k)
5
Added:
end
6
Added:
7
Added:
def new
8
Added:
@nutrient_measurement = NutrientMeasurement.new(measured_on: Date.today)
9
Added:
end
10
Added:
11
Added:
def create
12
Added:
@measurement = NutrientMeasurement.new(nutrient_measurement_params)
13
Added:
if @measurement.save
14
Added:
redirect_to @measurement, notice: "Relevé enregistré."
15
Added:
else
16
Added:
render :new, status: :unprocessable_entity
17
Added:
end
18
Added:
end
19
Added:
20
Added:
private
21
Added:
22
Added:
def nutrient_measurement_params
23
Added:
permitted = [ :measured_on ] + NutrientMeasurement::NUTRIENT_FIELDS
24
Added:
params.require(:nutrient_measurement).permit(*permitted)
25
Added:
end
26
Added:
end
app/controllers/targets_controller.rb
@@ -0,0 +1,68 @@
1
Added:
class TargetsController < ApplicationController
2
Added:
before_action :set_target, only: %i[show edit update]
3
Added:
4
Added:
def index
5
Added:
@targets = Target.order(:name)
6
Added:
end
7
Added:
8
Added:
def new
9
Added:
@target = Target.new(name: "Cible #{Date.today + 1.month}")
10
Added:
seed_allocations
11
Added:
end
12
Added:
13
Added:
def create
14
Added:
@target = Target.new(target_params)
15
Added:
if @target.save
16
Added:
redirect_to @target, notice: "Cible enregistrée."
17
Added:
else
18
Added:
seed_allocations if @target.target_allocations.blank?
19
Added:
render :new, status: :unprocessable_entity
20
Added:
end
21
Added:
end
22
Added:
23
Added:
def edit
24
Added:
end
25
Added:
26
Added:
def update
27
Added:
if @target.update(target_params)
28
Added:
redirect_to @target, notice: "Cible mise à jour."
29
Added:
else
30
Added:
render :edit, status: :unprocessable_entity
31
Added:
end
32
Added:
end
33
Added:
34
Added:
def show
35
Added:
@weighted = @target.weighted_requirements # => { "nno3"=>..., "p"=>..., ... }
36
Added:
37
Added:
last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
38
Added:
@latest_measurements = {}
39
Added:
40
Added:
if last
41
Added:
# Use the same keys as NutrientProfile to keep naming consistent.
42
Added:
keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s)
43
Added:
keys.each do |k|
44
Added:
@latest_measurements[k] = last.send(k) if last.respond_to?(k)
45
Added:
end
46
Added:
end
47
Added:
end
48
Added:
49
Added:
private
50
Added:
51
Added:
def set_target
52
Added:
@target = Target.find(params[:id])
53
Added:
end
54
Added:
55
Added:
def seed_allocations
56
Added:
existing_ids = @target.target_allocations.map(&:nutrient_profile_id).compact
57
Added:
(NutrientProfile.order(:name).pluck(:id) - existing_ids).each do |np_id|
58
Added:
@target.target_allocations.build(nutrient_profile_id: np_id, percentage: 12.5)
59
Added:
end
60
Added:
end
61
Added:
62
Added:
def target_params
63
Added:
params.require(:target).permit(
64
Added:
:name,
65
Added:
target_allocations_attributes: [ :id, :nutrient_profile_id, :percentage, :_destroy ]
66
Added:
)
67
Added:
end
68
Added:
end
app/helpers/targets_helper.rb
@@ -0,0 +1,2 @@
1
Added:
module TargetsHelper
2
Added:
end
app/models/nutrient_measurement.rb
@@ -1,4 +1,18 @@
1
1
class NutrientMeasurement < ApplicationRecord
2
Added:
NUTRIENT_FIELDS = %i[
3
Added:
nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4
4
Added:
].freeze
5
Added:
2
6
validates :measured_on, presence: true
3
7
validates :measured_on, uniqueness: true
8
Added:
9
Added:
def self.data_series_for(*nutrients)
10
Added:
nutrients.map do |formula|
11
Added:
{ name: formula, data: self.order(:measured_on).pluck(:measured_on, formula) }
12
Added:
end
13
Added:
end
14
Added:
15
Added:
def self.nutrient_fields
16
Added:
NUTRIENT_FIELDS
17
Added:
end
4
18
end
app/models/nutrient_profile.rb
@@ -1,2 +1,13 @@
1
1
class NutrientProfile < ApplicationRecord
2
Added:
# Align these keys with your schema columns (per your schema.txt)
3
Added:
NUTRIENT_KEYS = %i[
4
Added:
nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4
5
Added:
].freeze
6
Added:
7
Added:
# Returns a Hash of nutrient => numeric requirement (nil kept; caller can skip nils)
8
Added:
def requirements_hash
9
Added:
attributes
10
Added:
.slice(*NUTRIENT_KEYS.map(&:to_s)) # only nutrient columns
11
Added:
.transform_keys(&:to_s)
12
Added:
end
2
13
end
app/models/target.rb
@@ -0,0 +1,42 @@
1
Added:
# app/models/target.rb
2
Added:
class Target < ApplicationRecord
3
Added:
has_many :target_allocations, dependent: :destroy
4
Added:
has_many :nutrient_profiles, through: :target_allocations
5
Added:
6
Added:
accepts_nested_attributes_for :target_allocations, allow_destroy: true
7
Added:
validate :percentages_sum_to_100
8
Added:
9
Added:
def weighted_requirements
10
Added:
totals = Hash.new(0.0)
11
Added:
denom = 100.0
12
Added:
13
Added:
target_allocations.includes(:nutrient_profile).each do |alloc|
14
Added:
profile = alloc.nutrient_profile
15
Added:
next unless profile
16
Added:
17
Added:
weight = (alloc.percentage || 0).to_f / denom
18
Added:
next if weight <= 0
19
Added:
20
Added:
# Prefer the helper, but gracefully fall back to slicing attributes.
21
Added:
reqs = if profile.respond_to?(:requirements_hash)
22
Added:
profile.requirements_hash
23
Added:
else
24
Added:
profile.attributes.slice(*NutrientProfile::NUTRIENT_KEYS.map(&:to_s))
25
Added:
end
26
Added:
27
Added:
reqs.each do |nutrient_key, value|
28
Added:
next if value.nil?
29
Added:
totals[nutrient_key.to_s] += value.to_f * weight
30
Added:
end
31
Added:
end
32
Added:
33
Added:
totals
34
Added:
end
35
Added:
36
Added:
private
37
Added:
38
Added:
def percentages_sum_to_100
39
Added:
sum = target_allocations.reject(&:marked_for_destruction?).sum { |a| a.percentage.to_f }
40
Added:
errors.add(:base, "La somme des pourcentages doit être égale à 100%") unless (sum - 100.0).abs <= 0.01
41
Added:
end
42
Added:
end
app/models/target_allocation.rb
@@ -0,0 +1,7 @@
1
Added:
class TargetAllocation < ApplicationRecord
2
Added:
belongs_to :target
3
Added:
belongs_to :nutrient_profile
4
Added:
5
Added:
validates :percentage, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
6
Added:
validates :nutrient_profile_id, uniqueness: { scope: :target_id }
7
Added:
end
app/views/dashboard/_nutrient_measurements.html.erb
@@ -1,22 +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">Nutrient Measurements</h5>
4
Removed:
<div class="btn-group">
5
Removed:
<%#= link_to "Add new measurement", editor_rafts_path, class: "btn btn-sm btn-primary" %>
6
Removed:
<%#= link_to "View all", editor_rafts_path, class: "btn btn-sm btn-secondary" %>
7
Removed:
</div>
8
Removed:
</div>
9
Removed:
10
Removed:
<div class="card-body p-0">
11
Removed:
<div class="container mb-3">
12
Removed:
<%= line_chart @npk_measurement_data,
13
Removed:
title: "NPK",
14
Removed:
ytitle: "Concentration (mg/L)" %>
15
Removed:
</div>
16
Removed:
<div class="container mb-3">
17
Removed:
<%= line_chart @ammonium_measurement_data,
18
Removed:
title: "Ammonium",
19
Removed:
ytitle: "Concentration (mg/L)" %>
20
Removed:
</div>
21
Removed:
</div>
22
Removed:
</div>
app/views/dashboard/_nutrient_measurements_table.html.erb
@@ -0,0 +1,18 @@
1
Added:
<div class="card shadow my-3">
2
Added:
<div class="card-header d-flex justify-content-between align-items-center">
3
Added:
<h4 class="mb-0">Relevé des Nutriments</h4>
4
Added:
<div class="btn-group">
5
Added:
<%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-primary" %>
6
Added:
<%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-secondary" %>
7
Added:
</div>
8
Added:
</div>
9
Added:
10
Added:
<div class="card-body">
11
Added:
<%= line_chart @npk_measurement_data,
12
Added:
title: "NPK",
13
Added:
ytitle: "Concentration (mg/L)" %>
14
Added:
<%= line_chart @ammonium_measurement_data,
15
Added:
title: "Ammonium",
16
Added:
ytitle: "Concentration (mg/L)" %>
17
Added:
</div>
18
Added:
</div>
app/views/dashboard/_nutrient_profile_allocator.html.erb
@@ -1,187 +0,0 @@
1
Removed:
<%# Props: nutrient_profiles: ActiveRecord::Relation<NutrientProfile> %>
2
Removed:
<%# Fallback if controller didn't set @nutrient_profiles yet %>
3
Removed:
<% profiles = (local_assigns[:nutrient_profiles] || []).presence || [] %>
4
Removed:
5
Removed:
<%# We'll render a form purely for structure (no real submit yet) %>
6
Removed:
<%= form_with url: "#", method: :post, local: true, html: { id: "np-mix-form", "data-controller": "np-mix" } do %>
7
Removed:
<div class="card shadow">
8
Removed:
<div class="card-body">
9
Removed:
10
Removed:
<div class="d-flex justify-content-between align-items-center mb-2">
11
Removed:
<div class="small text-muted">
12
Removed:
Choisissez des <strong>profils de croissance</strong> et répartissez-les pour totaliser <strong>100%</strong>.
13
Removed:
</div>
14
Removed:
<div>
15
Removed:
Somme : <span id="np-mix-sum" class="badge bg-secondary">0%</span>
16
Removed:
</div>
17
Removed:
</div>
18
Removed:
19
Removed:
<div id="np-mix-rows" class="vstack gap-2">
20
Removed:
<%# Rows are injected by JS from the template below, including defaults %>
21
Removed:
</div>
22
Removed:
23
Removed:
<div class="mt-3 d-flex gap-2">
24
Removed:
<button type="button" class="btn btn-outline-primary" id="np-mix-add">
25
Removed:
+ Ajouter un profil
26
Removed:
</button>
27
Removed:
28
Removed:
<%# Placeholder "save" button for later backend wiring; disabled until total == 100 %>
29
Removed:
<button type="submit" class="btn btn-success ms-auto" id="np-mix-save" disabled>
30
Removed:
Enregistrer (à venir)
31
Removed:
</button>
32
Removed:
</div>
33
Removed:
</div>
34
Removed:
</div>
35
Removed:
36
Removed:
<%# --- Hidden template for a single row --- %>
37
Removed:
<template id="np-mix-row-template">
38
Removed:
<div class="np-mix-row d-flex align-items-center gap-2 border rounded p-2">
39
Removed:
<button type="button" class="btn btn-outline-danger btn-sm np-mix-delete" aria-label="Supprimer la ligne">
40
Removed:
Suppr.
41
Removed:
</button>
42
Removed:
43
Removed:
<div class="flex-grow-1">
44
Removed:
<select name="mix[items][][profile_id]" class="form-select form-select-sm np-mix-select" required>
45
Removed:
<% if profiles.any? %>
46
Removed:
<% profiles.each do |p| %>
47
Removed:
<option value="<%= p.id %>"><%= p.name %></option>
48
Removed:
<% end %>
49
Removed:
<% else %>
50
Removed:
<%# If no collection provided yet, at least show placeholders to demo the UI %>
51
Removed:
<option value="">-- Sélectionner un profil --</option>
52
Removed:
<option value="gen-croissance">Générique croissance</option>
53
Removed:
<option value="tomate-cycle">Tomate (cycle entier)</option>
54
Removed:
<option value="gen-floraison">Générique floraison</option>
55
Removed:
<% end %>
56
Removed:
</select>
57
Removed:
</div>
58
Removed:
59
Removed:
<div class="input-group input-group-sm" style="max-width: 140px;">
60
Removed:
<input type="number"
61
Removed:
name="mix[items][][percentage]"
62
Removed:
class="form-control text-end np-mix-percent"
63
Removed:
min="0" max="100" step="1" value="0" required>
64
Removed:
<span class="input-group-text">%</span>
65
Removed:
</div>
66
Removed:
</div>
67
Removed:
</template>
68
Removed:
69
Removed:
<%# --- Defaults to inject on load --- %>
70
Removed:
<script type="application/json" id="np-mix-defaults">
71
Removed:
{
72
Removed:
"items": [
73
Removed:
{ "name": "G\u00E9n\u00E9rique croissance", "percent": 50 },
74
Removed:
{ "name": "Tomate (cycle entier)", "percent": 30 },
75
Removed:
{ "name": "G\u00E9n\u00E9rique floraison", "percent": 20 }
76
Removed:
]
77
Removed:
}
78
Removed:
</script>
79
Removed:
80
Removed:
<%# --- Tiny inline JS to keep this self-contained (no Stimulus required) --- %>
81
Removed:
<script>
82
Removed:
(() => {
83
Removed:
const rowsContainer = document.getElementById('np-mix-rows');
84
Removed:
const addBtn = document.getElementById('np-mix-add');
85
Removed:
const saveBtn = document.getElementById('np-mix-save');
86
Removed:
const sumBadge = document.getElementById('np-mix-sum');
87
Removed:
const tpl = document.getElementById('np-mix-row-template');
88
Removed:
const defaultsJSON = document.getElementById('np-mix-defaults')?.textContent || "{}";
89
Removed:
const defaults = JSON.parse(defaultsJSON);
90
Removed:
91
Removed:
function currentSum() {
92
Removed:
return Array.from(rowsContainer.querySelectorAll('.np-mix-percent'))
93
Removed:
.reduce((acc, el) => acc + (parseFloat(el.value) || 0), 0);
94
Removed:
}
95
Removed:
96
Removed:
function refreshSum() {
97
Removed:
const sum = currentSum();
98
Removed:
sumBadge.textContent = `${sum}%`;
99
Removed:
sumBadge.classList.remove('bg-secondary','bg-danger','bg-success','bg-warning');
100
Removed:
101
Removed:
if (sum === 100) {
102
Removed:
sumBadge.classList.add('bg-success');
103
Removed:
saveBtn?.removeAttribute('disabled');
104
Removed:
} else if (sum > 100) {
105
Removed:
sumBadge.classList.add('bg-danger');
106
Removed:
saveBtn?.setAttribute('disabled', 'disabled');
107
Removed:
} else {
108
Removed:
sumBadge.classList.add('bg-warning');
109
Removed:
saveBtn?.setAttribute('disabled', 'disabled');
110
Removed:
}
111
Removed:
}
112
Removed:
113
Removed:
function setSelectByName(selectEl, targetName) {
114
Removed:
// Try to match by visible name; fall back to first option.
115
Removed:
const options = Array.from(selectEl.options);
116
Removed:
const found = options.find(o => o.text.trim().toLowerCase() === String(targetName || '').trim().toLowerCase());
117
Removed:
if (found) {
118
Removed:
selectEl.value = found.value;
119
Removed:
}
120
Removed:
}
121
Removed:
122
Removed:
function installRow({ name = null, percent = 0 } = {}) {
123
Removed:
const node = tpl.content.firstElementChild.cloneNode(true);
124
Removed:
125
Removed:
// Hook up events
126
Removed:
node.querySelector('.np-mix-delete').addEventListener('click', () => {
127
Removed:
node.remove();
128
Removed:
refreshSum();
129
Removed:
});
130
Removed:
131
Removed:
const selectEl = node.querySelector('.np-mix-select');
132
Removed:
const percentEl = node.querySelector('.np-mix-percent');
133
Removed:
134
Removed:
// Default selection (by name) and percent
135
Removed:
if (name) setSelectByName(selectEl, name);
136
Removed:
percentEl.value = percent;
137
Removed:
138
Removed:
// Input events
139
Removed:
selectEl.addEventListener('change', () => { /* reserved for later linkage */ });
140
Removed:
percentEl.addEventListener('input', () => {
141
Removed:
// Clamp and refresh
142
Removed:
let v = parseFloat(percentEl.value);
143
Removed:
if (isNaN(v)) v = 0;
144
Removed:
v = Math.max(0, Math.min(100, Math.round(v)));
145
Removed:
percentEl.value = v;
146
Removed:
refreshSum();
147
Removed:
});
148
Removed:
149
Removed:
rowsContainer.appendChild(node);
150
Removed:
}
151
Removed:
152
Removed:
// Init with three defaults
153
Removed:
const items = (defaults && defaults.items) ? defaults.items : [];
154
Removed:
if (items.length) {
155
Removed:
items.forEach(it => installRow({ name: it.name, percent: it.percent }));
156
Removed:
} else {
157
Removed:
// Fallback: create three blank rows
158
Removed:
for (let i = 0; i < 3; i++) installRow();
159
Removed:
}
160
Removed:
refreshSum();
161
Removed:
162
Removed:
// Add new blank row
163
Removed:
addBtn.addEventListener('click', () => {
164
Removed:
installRow({ name: null, percent: 0 });
165
Removed:
refreshSum();
166
Removed:
// Scroll to the new row on mobile for better UX
167
Removed:
rowsContainer.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'center' });
168
Removed:
});
169
Removed:
170
Removed:
// Prevent real submit for now (frontend only)
171
Removed:
document.getElementById('np-mix-form')?.addEventListener('submit', (e) => {
172
Removed:
e.preventDefault();
173
Removed:
// Later: wire to Turbo/JSON post. For now just a gentle nudge.
174
Removed:
saveBtn.textContent = 'Enregistrer (backend à venir)';
175
Removed:
saveBtn.blur();
176
Removed:
});
177
Removed:
})();
178
Removed:
</script>
179
Removed:
180
Removed:
<style>
181
Removed:
/* Small touch targets & tidy spacing on mobile */
182
Removed:
@media (max-width: 576px) {
183
Removed:
.np-mix-row { padding: .5rem; }
184
Removed:
.np-mix-row .btn { padding: .25rem .5rem; }
185
Removed:
}
186
Removed:
</style>
187
Removed:
<% end %>
app/views/dashboard/_nutrient_target_table.html.erb
@@ -0,0 +1,74 @@
1
Added:
<div class="card shadow my-3">
2
Added:
<div class="card-header d-flex justify-content-between align-items-center">
3
Added:
<h4 class="mb-0">Complémentation</h4>
4
Added:
<div class="btn-group">
5
Added:
<%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-primary" %>
6
Added:
<%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-secondary" %>
7
Added:
</div>
8
Added:
</div>
9
Added:
10
Added:
<div class="card-body p-0">
11
Added:
<div class="table-responsive">
12
Added:
<table class="table table-sm table-striped table-hover align-middle mb-0">
13
Added:
<thead class="table-light">
14
Added:
<tr>
15
Added:
<th>Nutriment</th>
16
Added:
<th class="text-end">Relevé</th>
17
Added:
<th class="text-end">Cible</th>
18
Added:
<th class="text-end">Delta</th>
19
Added:
</tr>
20
Added:
</thead>
21
Added:
<tbody>
22
Added:
<% wr = @weighted || {} %>
23
Added:
<% lm = @latest_measurements || {} %>
24
Added:
25
Added:
<% keys = (wr.keys + lm.keys).map(&:to_s).uniq.sort %>
26
Added:
<% keys.each do |nut| %>
27
Added:
<% measured = lm[nut] %>
28
Added:
<% target = wr[nut] %>
29
Added:
<% delta = (measured.to_f - target.to_f) if measured || target %>
30
Added:
<tr>
31
Added:
<td class="fw-semibold"><%= nut.upcase %></td>
32
Added:
33
Added:
<td class="text-end">
34
Added:
<% if measured.nil? %>
35
Added:
<span class="text-muted">—</span>
36
Added:
<% else %>
37
Added:
<%= number_with_precision(measured, precision: 2) %>
38
Added:
<% end %>
39
Added:
</td>
40
Added:
41
Added:
<td class="text-end">
42
Added:
<% if target.nil? %>
43
Added:
<span class="text-muted">—</span>
44
Added:
<% else %>
45
Added:
<%= number_with_precision(target, precision: 2) %>
46
Added:
<% end %>
47
Added:
</td>
48
Added:
49
Added:
<td class="text-end">
50
Added:
<% if measured.nil? && target.nil? %>
51
Added:
<span class="text-muted">—</span>
52
Added:
<% else %>
53
Added:
<% badge =
54
Added:
if delta.nil?
55
Added:
"text-bg-secondary"
56
Added:
elsif delta.abs <= 0.01
57
Added:
"text-bg-success"
58
Added:
elsif delta > 0
59
Added:
"text-bg-warning"
60
Added:
else
61
Added:
"text-bg-danger"
62
Added:
end %>
63
Added:
<span class="badge <%= badge %>">
64
Added:
<%= number_with_precision(delta.to_f, precision: 2) %>
65
Added:
</span>
66
Added:
<% end %>
67
Added:
</td>
68
Added:
</tr>
69
Added:
<% end %>
70
Added:
</tbody>
71
Added:
</table>
72
Added:
</div>
73
Added:
</div>
74
Added:
</div>
app/views/dashboard/_target_table.html.erb
@@ -9,8 +9,7 @@
9
9
<thead class="table-light">
10
10
<tr>
11
11
<th scope="col" class="text-nowrap">Nutrient</th>
12
Removed:
<th scope="col" class="text-end"> Latest (mg/L)
13
Removed:
</th>
12
Added:
<th scope="col" class="text-end"> Latest (mg/L)</th>
14
13
<th scope="col" class="text-end">Target (mg/L)</th>
15
14
<th scope="col" class="text-end">Δ %</th>
16
15
</tr>
app/views/dashboard/index.html.erb
@@ -1,9 +1,9 @@
1
1
<h1 class="display-1">Ferti</h1>
2
2
3
Removed:
<%= render "nutrient_profile_allocator", nutrient_profiles: @nutrient_profiles %>
3
Added:
<%= render "nutrient_target_table", nutrient_profiles: @nutrient_profiles %>
4
4
5
5
<%#= render "raft_allocation" %>
6
6
7
7
<%#= render "target_table" %>
8
8
9
Removed:
<%#= render "nutrient_measurements" %>
9
Added:
<%= render "nutrient_measurements_table" %>
app/views/nutrient_measurement/index.html.erb
@@ -1,27 +0,0 @@
1
Removed:
<h1>NutrientMeasurement#index</h1>
2
Removed:
<p>Find me in app/views/nutrient_measurement/index.html.erb</p>
3
Removed:
4
Removed:
<div class="table-responsive">
5
Removed:
<table class="table table-sm table-striped table-hover align-middle table-nutrient mb-0">
6
Removed:
<thead class="table-light">
7
Removed:
<tr>
8
Removed:
<th>Date</th>
9
Removed:
<th class="numeric" title="Total N = NO₃‑N + NH₄‑N">N (total)</th>
10
Removed:
<th class="numeric">P</th>
11
Removed:
<th class="numeric">K</th>
12
Removed:
<th class="numeric" title="Ammonia nitrogen">NH₄‑N</th>
13
Removed:
</tr>
14
Removed:
</thead>
15
Removed:
<tbody>
16
Removed:
<% @measurements.each do |m| %>
17
Removed:
<tr>
18
Removed:
<td><%= l(m.measured_on) %></td>
19
Removed:
<td class="numeric"><%= fmt2(total_n(m)) %></td>
20
Removed:
<td class="numeric"><%= fmt2(m.p) %></td>
21
Removed:
<td class="numeric"><%= fmt2(m.k) %></td>
22
Removed:
<td class="numeric"><%= fmt2(m.nnh4) %></td>
23
Removed:
</tr>
24
Removed:
<% end %>
25
Removed:
</tbody>
26
Removed:
</table>
27
Removed:
</div>
app/views/nutrient_measurements/_form.html.erb
@@ -0,0 +1,49 @@
1
Added:
<%= form_with(model: nutrient_measurement) do |form| %>
2
Added:
<% if nutrient_measurement.errors.any? %>
3
Added:
<div class="alert alert-danger">
4
Added:
<p class="mb-1"><strong><%= pluralize(nutrient_measurement.errors.count, "erreur") %></strong> empêchent l’enregistrement :</p>
5
Added:
<ul class="mb-0">
6
Added:
<% nutrient_measurement.errors.full_messages.each do |msg| %>
7
Added:
<li><%= msg %></li>
8
Added:
<% end %>
9
Added:
</ul>
10
Added:
</div>
11
Added:
<% end %>
12
Added:
13
Added:
<div class="mb-3">
14
Added:
<%= form.label :measured_on, "Date du relevé", class: "form-label" %>
15
Added:
<%= form.date_field :measured_on, class: "form-control", required: true %>
16
Added:
</div>
17
Added:
18
Added:
<h2 class="h6 mt-4 mb-2">Concentrations de nutriments — laisser vide si non mesuré</h2>
19
Added:
20
Added:
<div class="row g-2">
21
Added:
<% # You can reorganize into macros/micros if you prefer %>
22
Added:
<% labels = {
23
Added:
nno3: "Nitrate (N-NO₃)", p: "Phosphore (P)", k: "Potassium (K)",
24
Added:
ca: "Calcium (Ca)", mg: "Magnésium (Mg)", s: "Soufre (S)",
25
Added:
na: "Sodium (Na)", cl: "Chlore (Cl)", si: "Silicium (Si)",
26
Added:
fe: "Fer (Fe)", zn: "Zinc (Zn)", b: "Bore (B)",
27
Added:
mn: "Manganèse (Mn)", cu: "Cuivre (Cu)", mo: "Molybdène (Mo)",
28
Added:
nnh4: "Ammonium (N-NH₄)"
29
Added:
} %>
30
Added:
31
Added:
<% NutrientMeasurement::NUTRIENT_FIELDS.each do |field| %>
32
Added:
<div class="col-6 col-md-3">
33
Added:
<div class="input-group">
34
Added:
<%= form.number_field field,
35
Added:
class: "form-control",
36
Added:
placeholder: "—",
37
Added:
step: "0.01",
38
Added:
min: "0" %>
39
Added:
<span class="input-group-text">mg/L</span>
40
Added:
</div>
41
Added:
<label class="form-label d-block small text-muted mt-1"><%= labels[field] %></label>
42
Added:
</div>
43
Added:
<% end %>
44
Added:
</div>
45
Added:
46
Added:
<div>
47
Added:
<%= form.submit "Ajouter le relevé", class: "btn btn-primary" %>
48
Added:
</div>
49
Added:
<% end %>
app/views/nutrient_measurements/index.html.erb
@@ -0,0 +1,41 @@
1
Added:
<% content_for :title, "Liste des Relevé" %>
2
Added:
3
Added:
<h1 class="display-1">Liste des Relevés</h1>
4
Added:
5
Added:
<div class="d-flex justify-content-between align-items-center mb-3">
6
Added:
<div class="btn-group">
7
Added:
<%= link_to "Nouvelle mesure", new_nutrient_measurement_path, class: "btn btn-primary" %>
8
Added:
<%= link_to "Retour", root_path, class: "btn btn-outline-secondary" %>
9
Added:
</div>
10
Added:
</div>
11
Added:
12
Added:
<div class="table-responsive">
13
Added:
<table class="table table-sm table-striped table-hover align-middle table-nutrient mb-0">
14
Added:
<thead class="table-light">
15
Added:
<tr>
16
Added:
<th>Date</th>
17
Added:
<th class="text-end" title="Total N = NO₃-N + NH₄-N">N (total)</th>
18
Added:
<th class="text-end">P</th>
19
Added:
<th class="text-end">K</th>
20
Added:
<th class="text-end" title="Ammonia nitrogen">NH₄-N</th>
21
Added:
</tr>
22
Added:
</thead>
23
Added:
<tbody>
24
Added:
<% @nutrient_measurements.each do |m| %>
25
Added:
<tr>
26
Added:
<td><%= l(m.measured_on) %></td>
27
Added:
<td class="text-end">
28
Added:
<%= number_with_precision(m.nno3.to_f + m.nnh4.to_f, precision: 2) if m.nno3 || m.nnh4 %>
29
Added:
</td>
30
Added:
<td class="text-end"><%= number_with_precision(m.p, precision: 2) if m.p %></td>
31
Added:
<td class="text-end"><%= number_with_precision(m.k, precision: 2) if m.k %></td>
32
Added:
<td class="text-end"><%= number_with_precision(m.nnh4, precision: 2) if m.nnh4 %></td>
33
Added:
</tr>
34
Added:
<% end %>
35
Added:
</tbody>
36
Added:
</table>
37
Added:
</div>
38
Added:
39
Added:
<%= line_chart @npk_measurement_data,
40
Added:
title: "NPK",
41
Added:
ytitle: "Concentration (mg/L)" %>
app/views/nutrient_measurements/new.html.erb
@@ -0,0 +1,11 @@
1
Added:
<% content_for :title, "Ajouter un Relevé" %>
2
Added:
3
Added:
<h1 class="display-1">Ajouter un Relevé</h1>
4
Added:
5
Added:
<%= render "form", nutrient_measurement: @nutrient_measurement %>
6
Added:
7
Added:
<br>
8
Added:
9
Added:
<div>
10
Added:
<%= link_to "Retour", root_path, class: "btn btn-secondary" %>
11
Added:
</div>
app/views/targets/create.html.erb
@@ -0,0 +1,2 @@
1
Added:
<h1>Targets#create</h1>
2
Added:
<p>Find me in app/views/targets/create.html.erb</p>
app/views/targets/edit.html.erb
@@ -0,0 +1,2 @@
1
Added:
<h1>Targets#edit</h1>
2
Added:
<p>Find me in app/views/targets/edit.html.erb</p>
app/views/targets/index.html.erb
@@ -0,0 +1,68 @@
1
Added:
<h1 class="display-1">Cibles</h1>
2
Added:
3
Added:
<div class="btn-group my-3">
4
Added:
<%= link_to "Nouvelle Cible", new_target_path, class: "btn btn-primary" %>
5
Added:
</div>
6
Added:
7
Added:
<div class="table-responsive">
8
Added:
<table class="table table-sm table-striped table-hover align-middle mb-0">
9
Added:
<thead class="table-light">
10
Added:
<tr>
11
Added:
<th>Nom</th>
12
Added:
<th>Répartition</tr>
13
Added:
<th class="text-end" style="width: 140px;">Total %</th>
14
Added:
<th class="text-nowrap" style="width: 190px;">Créé le</th>
15
Added:
<th class="text-end" style="width: 180px;">Actions</th>
16
Added:
</tr>
17
Added:
</thead>
18
Added:
<tbody>
19
Added:
<% if @targets.present? %>
20
Added:
<% @targets.each do |t| %>
21
Added:
<% sum_pct = t.target_allocations.sum { |a| a.percentage.to_f } %>
22
Added:
<% badge_class = (sum_pct - 100.0).abs <= 0.01 ? "bg-success" : "bg-danger" %>
23
Added:
<tr>
24
Added:
<td class="fw-semibold">
25
Added:
<%= link_to t.name.presence || "Objectif ##{t.id}", t %>
26
Added:
</td>
27
Added:
<td>
28
Added:
<% if t.target_allocations.empty? %>
29
Added:
<span class="text-muted">Aucune répartition définie</span>
30
Added:
<% else %>
31
Added:
<ul class="list-unstyled mb-0 d-flex flex-wrap gap-2">
32
Added:
<% t.target_allocations.each do |a| %>
33
Added:
<li class="badge text-bg-light border">
34
Added:
<%= a.nutrient_profile&.name || "Profil ##{a.nutrient_profile_id}" %>
35
Added:
— <%= number_with_precision(a.percentage.to_f, precision: 2) %>%
36
Added:
</li>
37
Added:
<% end %>
38
Added:
</ul>
39
Added:
<% end %>
40
Added:
</td>
41
Added:
<td class="text-end">
42
Added:
<span class="badge <%= badge_class %>">
43
Added:
<%= number_with_precision(sum_pct, precision: 2) %>%
44
Added:
</span>
45
Added:
</td>
46
Added:
<td class="text-nowrap">
47
Added:
<%= l(t.created_at, format: :short) %>
48
Added:
</td>
49
Added:
<td class="text-end text-nowrap">
50
Added:
<%= link_to "Voir", t, class: "btn btn-outline-secondary btn-sm" %>
51
Added:
<%= link_to "Modifier", edit_target_path(t), class: "btn btn-outline-primary btn-sm" %>
52
Added:
<%# FIXME: Doesn't work. %>
53
Added:
<%= link_to "Supprimer", t, class: "btn btn-outline-danger btn-sm",
54
Added:
data: { turbo_method: :delete, turbo_confirm: "Supprimer cet objectif ?" } %>
55
Added:
</td>
56
Added:
</tr>
57
Added:
<% end %>
58
Added:
<% else %>
59
Added:
<tr>
60
Added:
<td colspan="5" class="text-center py-4 text-muted">
61
Added:
Aucun objectif pour le moment.
62
Added:
<%= link_to "Créer le premier", new_target_path %>.
63
Added:
</td>
64
Added:
</tr>
65
Added:
<% end %>
66
Added:
</tbody>
67
Added:
</table>
68
Added:
</div>
app/views/targets/new.html.erb
@@ -0,0 +1,85 @@
1
Added:
<% content_for :title, "Ajouter une Cible" %>
2
Added:
3
Added:
<h1 class="display-1">Ajouter une Cible</h1>
4
Added:
5
Added:
<%= form_with(model: @target) do |f| %>
6
Added:
<div class="card shadow-sm">
7
Added:
<div class="card-header">
8
Added:
<%= f.text_field :name, class: "form-control", placeholder: "Nom de la cible" %>
9
Added:
</div>
10
Added:
11
Added:
<div class="card-body p-0">
12
Added:
<div class="table-responsive">
13
Added:
<table class="table table-hover align-middle mb-0">
14
Added:
<thead class="table-light">
15
Added:
<tr>
16
Added:
<th>Profil</th>
17
Added:
<th class="text-end" style="width: 180px;">Proportion</th>
18
Added:
</tr>
19
Added:
</thead>
20
Added:
<tbody id="alloc-table-body">
21
Added:
<%= f.fields_for :target_allocations do |af| %>
22
Added:
<% np = af.object.nutrient_profile %>
23
Added:
<tr>
24
Added:
<td>
25
Added:
<%= af.hidden_field :nutrient_profile_id %>
26
Added:
<strong><%= np&.name.capitalize || "Profil ##{af.object.nutrient_profile_id}" %></strong>
27
Added:
</td>
28
Added:
<td class="text-end">
29
Added:
<div class="input-group input-group-sm" style="max-width: 160px; margin-left:auto;">
30
Added:
<%= af.number_field :percentage,
31
Added:
in: 0..100, step: 0.5,
32
Added:
class: "form-control text-end alloc-input",
33
Added:
placeholder: "0.0",
34
Added:
data: { action: "input->alloc#sum" } %>
35
Added:
<span class="input-group-text">%</span>
36
Added:
</div>
37
Added:
</td>
38
Added:
</tr>
39
Added:
<% end %>
40
Added:
</tbody>
41
Added:
<tfoot>
42
Added:
<tr>
43
Added:
<td class="small text-muted">Ajustez chaque pourcentage pour totaliser 100%.</td>
44
Added:
<td class="text-end">
45
Added:
<span class="badge bg-secondary" id="alloc-total">Total : 0%</span>
46
Added:
</td>
47
Added:
</tr>
48
Added:
</tfoot>
49
Added:
</table>
50
Added:
</div>
51
Added:
</div>
52
Added:
53
Added:
<div class="card-footer d-flex gap-2 justify-content-end">
54
Added:
<div class="btn-group">
55
Added:
<%= f.submit "Enregistrer l’objectif", class: "btn btn-primary", id: "submit-btn" %>
56
Added:
<%= link_to "Annuler", targets_path, class: "btn btn-secondary" %>
57
Added:
</div>
58
Added:
</div>
59
Added:
</div>
60
Added:
<% end %>
61
Added:
62
Added:
<script>
63
Added:
// Lightweight client-side sum check (no Stimulus required).
64
Added:
document.addEventListener("turbo:load", initAllocSum);
65
Added:
document.addEventListener("DOMContentLoaded", initAllocSum);
66
Added:
67
Added:
function initAllocSum() {
68
Added:
const inputs = document.querySelectorAll(".alloc-input");
69
Added:
const totalBadge = document.getElementById("alloc-total");
70
Added:
const submitBtn = document.getElementById("submit-btn");
71
Added:
if (!inputs.length || !totalBadge) return;
72
Added:
73
Added:
function updateTotal() {
74
Added:
let sum = 0;
75
Added:
inputs.forEach(i => sum += parseFloat(i.value || "0"));
76
Added:
const rounded = Math.round(sum * 100) / 100;
77
Added:
totalBadge.textContent = `Total : ${rounded}%`;
78
Added:
totalBadge.className = "badge " + (Math.abs(rounded - 100) < 0.01 ? "bg-success" : "bg-danger");
79
Added:
if (submitBtn) submitBtn.disabled = !(Math.abs(rounded - 100) < 0.01);
80
Added:
}
81
Added:
82
Added:
inputs.forEach(i => i.addEventListener("input", updateTotal));
83
Added:
updateTotal();
84
Added:
}
85
Added:
</script>
app/views/targets/show.html.erb
@@ -0,0 +1,3 @@
1
Added:
<h1>Targets#show</h1>
2
Added:
3
Added:
<%# TODO: add table comparing this target with the most recent measurement. %>
app/views/targets/update.html.erb
@@ -0,0 +1,2 @@
1
Added:
<h1>Targets#update</h1>
2
Added:
<p>Find me in app/views/targets/update.html.erb</p>
config/routes.rb
@@ -13,6 +13,7 @@
13
13
# end
14
14
15
15
# resources :fertilizer_products
16
Added:
resources :targets
16
17
resources :nutrient_profiles
17
18
resources :nutrient_measurements
18
19
db/migrate/20250908181137_create_targets.rb
@@ -0,0 +1,9 @@
1
Added:
class CreateTargets < ActiveRecord::Migration[8.0]
2
Added:
def change
3
Added:
create_table :targets do |t|
4
Added:
t.string :name
5
Added:
6
Added:
t.timestamps
7
Added:
end
8
Added:
end
9
Added:
end
db/migrate/20250908181147_create_target_allocations.rb
@@ -0,0 +1,11 @@
1
Added:
class CreateTargetAllocations < ActiveRecord::Migration[8.0]
2
Added:
def change
3
Added:
create_table :target_allocations do |t|
4
Added:
t.references :target, null: false, foreign_key: true
5
Added:
t.references :nutrient_profile, null: false, foreign_key: true
6
Added:
t.decimal :percentage
7
Added:
8
Added:
t.timestamps
9
Added:
end
10
Added:
end
11
Added:
end
db/schema.rb
@@ -10,7 +10,7 @@
10
10
#
11
11
# It's strongly recommended that you check this file into your version control system.
12
12
13
Removed:
ActiveRecord::Schema[8.0].define(version: 2025_09_01_112954) do
13
Added:
ActiveRecord::Schema[8.0].define(version: 2025_09_08_181147) do
14
14
create_table "beds", force: :cascade do |t|
15
15
t.integer "location", null: false
16
16
t.datetime "created_at", null: false
@@ -122,8 +122,26 @@
122
122
t.index ["crop_nutrient_need_id"], name: "index_rafts_on_crop_nutrient_need_id"
123
123
end
124
124
125
Added:
create_table "target_allocations", force: :cascade do |t|
126
Added:
t.integer "target_id", null: false
127
Added:
t.integer "nutrient_profile_id", null: false
128
Added:
t.decimal "percentage"
129
Added:
t.datetime "created_at", null: false
130
Added:
t.datetime "updated_at", null: false
131
Added:
t.index ["nutrient_profile_id"], name: "index_target_allocations_on_nutrient_profile_id"
132
Added:
t.index ["target_id"], name: "index_target_allocations_on_target_id"
133
Added:
end
134
Added:
135
Added:
create_table "targets", force: :cascade do |t|
136
Added:
t.string "name"
137
Added:
t.datetime "created_at", null: false
138
Added:
t.datetime "updated_at", null: false
139
Added:
end
140
Added:
125
141
add_foreign_key "fertilizer_compositions", "fertilizer_components"
126
142
add_foreign_key "fertilizer_compositions", "fertilizer_products"
127
143
add_foreign_key "rafts", "beds"
128
144
add_foreign_key "rafts", "nutrient_profiles", column: "crop_nutrient_need_id"
145
Added:
add_foreign_key "target_allocations", "nutrient_profiles"
146
Added:
add_foreign_key "target_allocations", "targets"
129
147
end
db/seeds/NutrientProfile.rb
@@ -101,7 +101,7 @@
101
101
b: 0.11,
102
102
mn: 0.11,
103
103
cu: 0.03,
104
Removed:
mo: 0.01 },
104
Added:
mo: 0.01 }
105
105
].each do |profile|
106
106
NutrientProfile.find_or_create_by!(name: profile[:name]) do |p|
107
107
p.attributes = profile
test/controllers/targets_controller_test.rb
@@ -0,0 +1,33 @@
1
Added:
require "test_helper"
2
Added:
3
Added:
class TargetsControllerTest < ActionDispatch::IntegrationTest
4
Added:
test "should get index" do
5
Added:
get targets_index_url
6
Added:
assert_response :success
7
Added:
end
8
Added:
9
Added:
test "should get new" do
10
Added:
get targets_new_url
11
Added:
assert_response :success
12
Added:
end
13
Added:
14
Added:
test "should get create" do
15
Added:
get targets_create_url
16
Added:
assert_response :success
17
Added:
end
18
Added:
19
Added:
test "should get edit" do
20
Added:
get targets_edit_url
21
Added:
assert_response :success
22
Added:
end
23
Added:
24
Added:
test "should get update" do
25
Added:
get targets_update_url
26
Added:
assert_response :success
27
Added:
end
28
Added:
29
Added:
test "should get show" do
30
Added:
get targets_show_url
31
Added:
assert_response :success
32
Added:
end
33
Added:
end
test/fixtures/target_allocations.yml
@@ -0,0 +1,11 @@
1
Added:
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2
Added:
3
Added:
one:
4
Added:
target: one
5
Added:
nutrient_profile: one
6
Added:
percentage: 9.99
7
Added:
8
Added:
two:
9
Added:
target: two
10
Added:
nutrient_profile: two
11
Added:
percentage: 9.99
test/fixtures/targets.yml
@@ -0,0 +1,7 @@
1
Added:
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2
Added:
3
Added:
one:
4
Added:
name: MyString
5
Added:
6
Added:
two:
7
Added:
name: MyString
test/models/target_allocation_test.rb
@@ -0,0 +1,7 @@
1
Added:
require "test_helper"
2
Added:
3
Added:
class TargetAllocationTest < ActiveSupport::TestCase
4
Added:
# test "the truth" do
5
Added:
# assert true
6
Added:
# end
7
Added:
end
test/models/target_test.rb
@@ -0,0 +1,7 @@
1
Added:
require "test_helper"
2
Added:
3
Added:
class TargetTest < ActiveSupport::TestCase
4
Added:
# test "the truth" do
5
Added:
# assert true
6
Added:
# end
7
Added:
end