From fa77a691ce0cc8941fe470a762f352b27f4f0563 Mon Sep 17 00:00:00 2001 From: Marius Peter Date: Sun, 23 Nov 2025 17:54:45 +0100 Subject: Last commit. --- app/controllers/dashboard_controller.rb | 43 +---------- app/controllers/targets_controller.rb | 41 +++++------ app/helpers/targets_helper.rb | 2 - app/models/concerns/nutrient_vector.rb | 55 ++++++++++++++ app/models/nutrient.rb | 4 + app/models/nutrient_measurement.rb | 12 +-- app/models/target.rb | 67 ++++++++++------- app/models/target_allocation.rb | 3 +- .../_nutrient_measurements_table.html.erb | 4 +- .../dashboard/_nutrient_target_table.html.erb | 35 ++++----- app/views/dashboard/_raft_allocation.html.erb | 9 --- app/views/dashboard/_target_table.html.erb | 39 ---------- app/views/dashboard/index.html.erb | 4 - app/views/targets/index.html.erb | 34 +++------ app/views/targets/new.html.erb | 85 ---------------------- 15 files changed, 152 insertions(+), 285 deletions(-) delete mode 100644 app/helpers/targets_helper.rb create mode 100644 app/models/concerns/nutrient_vector.rb create mode 100644 app/models/nutrient.rb delete mode 100644 app/views/dashboard/_raft_allocation.html.erb delete mode 100644 app/views/dashboard/_target_table.html.erb (limited to 'app') diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index a315eda..fc8cbd8 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,50 +1,11 @@ class DashboardController < ApplicationController def index - # Raft allocation by crop type - # @raft_data = raft_data_series - - @nutrient_profiles = NutrientProfile.order(:name) - - # Nutrient target table - # @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first - # @target = TargetNutrientCalculator.call + @latest_measurement = NutrientMeasurement.order(measured_on: :desc).first + @latest_target = Target.order(created_at: :desc).first # Measurement history table @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10) @npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k) @ammonium_measurement_data = NutrientMeasurement.data_series_for(:nnh4) - - @weighted = Target.first.weighted_requirements # => { "nno3"=>..., "p"=>..., ... } - - last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first - @latest_measurements = {} - - if last - # Use the same keys as NutrientProfile to keep naming consistent. - keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s) - keys.each do |k| - @latest_measurements[k] = last.send(k) if last.respond_to?(k) - end - end - end - - private - - def raft_data_series - data_series = [] - - counts = Raft.left_outer_joins(:crop) - .group("crops.name", "crops.crop_type") - .count - - counts.each do |(crop_name, crop_type), count| - name = (crop_name || "unassigned").titleize - type = (crop_type || "unassigned").titleize - data = { type => count } - data_series << { name:, data: } - end - - unassigned, assigned = data_series.partition { |s| s[:name].casecmp("unassigned").zero? } - assigned + unassigned end end diff --git a/app/controllers/targets_controller.rb b/app/controllers/targets_controller.rb index a11ddd7..679cb75 100644 --- a/app/controllers/targets_controller.rb +++ b/app/controllers/targets_controller.rb @@ -6,16 +6,24 @@ class TargetsController < ApplicationController end def new - @target = Target.new(name: "Cible #{Date.today + 1.month}") - seed_allocations + @target = Target.new + @nutrient_profiles = NutrientProfile.order(:name) + # Build one allocation per profile so each appears as a row + @nutrient_profiles.each do |np| + @target.target_allocations.build(nutrient_profile: np, percentage: 12.5) + end end def create @target = Target.new(target_params) if @target.save - redirect_to @target, notice: "Cible enregistrée." + redirect_to @target, notice: "Cible créée." else - seed_allocations if @target.target_allocations.blank? + # Rebuild rows for any profiles missing (e.g., after validation errors) + existing_ids = @target.target_allocations.map(&:nutrient_profile_id) + (NutrientProfile.where.not(id: existing_ids)).order(:name).each do |np| + @target.target_allocations.build(nutrient_profile: np, percentage: 0) + end render :new, status: :unprocessable_entity end end @@ -31,18 +39,12 @@ class TargetsController < ApplicationController end end - def show - @weighted = @target.weighted_requirements # => { "nno3"=>..., "p"=>..., ... } - - last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first - @latest_measurements = {} - - if last - # Use the same keys as NutrientProfile to keep naming consistent. - keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s) - keys.each do |k| - @latest_measurements[k] = last.send(k) if last.respond_to?(k) - end + def destroy + @target = Target.find(params[:id]) + @target.destroy + respond_to do |format| + format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@target)) } + format.html { redirect_to targets_path, notice: "Cible supprimé." } end end @@ -52,13 +54,6 @@ class TargetsController < ApplicationController @target = Target.find(params[:id]) end - def seed_allocations - existing_ids = @target.target_allocations.map(&:nutrient_profile_id).compact - (NutrientProfile.order(:name).pluck(:id) - existing_ids).each do |np_id| - @target.target_allocations.build(nutrient_profile_id: np_id, percentage: 12.5) - end - end - def target_params params.require(:target).permit( :name, diff --git a/app/helpers/targets_helper.rb b/app/helpers/targets_helper.rb deleted file mode 100644 index 8484878..0000000 --- a/app/helpers/targets_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module TargetsHelper -end diff --git a/app/models/concerns/nutrient_vector.rb b/app/models/concerns/nutrient_vector.rb new file mode 100644 index 0000000..ba4f28a --- /dev/null +++ b/app/models/concerns/nutrient_vector.rb @@ -0,0 +1,55 @@ +module NutrientVector + extend ActiveSupport::Concern + + NUTRIENT_KEYS = %i[ + nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4 + ].freeze + + included do + # Define simple readers (nno3, p, k, ...) that read from #nutrient_values + NUTRIENT_KEYS.each do |k| + define_method(k) { nutrient_values[k] } + end + end + + # Hash-like access + def [](key) + key = key.to_sym + return nil unless NUTRIENT_KEYS.include?(key) + public_send(key) + end + + def keys = NUTRIENT_KEYS + + # Returns a copy to avoid accidental mutation + def to_h + NUTRIENT_KEYS.index_with { |k| public_send(k) } + end + + # Iterate over pairs + def each_pair + return enum_for(:each_pair) unless block_given? + NUTRIENT_KEYS.each { |k| yield k, public_send(k) } + end + + # Simple difference (self - other), useful for “how far from target?” + def delta_against(other) + NUTRIENT_KEYS.index_with { |k| (public_send(k).to_f) - (other.public_send(k).to_f) } + end + + # Percent difference relative to other (e.g., measurement vs target) + # Returns 0 when both are 0, and nil when target is 0 but measurement isn’t. + def percent_diff_against(other) + NUTRIENT_KEYS.index_with do |k| + a = public_send(k).to_f + b = other.public_send(k).to_f + if b.zero? && a.zero? + 0.0 + elsif b.zero? + nil + else + ((a - b) / b) * 100.0 + end + end + end +end diff --git a/app/models/nutrient.rb b/app/models/nutrient.rb new file mode 100644 index 0000000..0840086 --- /dev/null +++ b/app/models/nutrient.rb @@ -0,0 +1,4 @@ +class Nutrient < ApplicationRecord + validates :formula, presence: true, uniqueness: true + validates :name, presence: true +end diff --git a/app/models/nutrient_measurement.rb b/app/models/nutrient_measurement.rb index 1139af7..b56c5ed 100644 --- a/app/models/nutrient_measurement.rb +++ b/app/models/nutrient_measurement.rb @@ -1,7 +1,5 @@ class NutrientMeasurement < ApplicationRecord - NUTRIENT_FIELDS = %i[ - nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4 - ].freeze + include NutrientVector validates :measured_on, presence: true validates :measured_on, uniqueness: true @@ -12,7 +10,11 @@ class NutrientMeasurement < ApplicationRecord end end - def self.nutrient_fields - NUTRIENT_FIELDS + private + + def nutrient_values + @nutrient_values ||= NutrientVector::NUTRIENT_KEYS.index_with do |k| + read_attribute(k).to_f + end end end diff --git a/app/models/target.rb b/app/models/target.rb index 9211e74..2efd3b2 100644 --- a/app/models/target.rb +++ b/app/models/target.rb @@ -1,42 +1,53 @@ -# app/models/target.rb class Target < ApplicationRecord - has_many :target_allocations, dependent: :destroy - has_many :nutrient_profiles, through: :target_allocations + include NutrientVector - accepts_nested_attributes_for :target_allocations, allow_destroy: true - # validate :percentages_sum_to_100 + has_many :target_allocations, inverse_of: :target, dependent: :destroy - def weighted_requirements - totals = Hash.new(0.0) - denom = 100.0 + after_initialize :set_defaults, if: :new_record? - target_allocations.includes(:nutrient_profile).each do |alloc| - profile = alloc.nutrient_profile - next unless profile + validate :allocations_sum_to_100 - weight = (alloc.percentage || 0).to_f / denom - next if weight <= 0 + # Recompute when allocations change; keep it simple (memoized per instance) + def recompute_nutrients! + @nutrient_values = nil + nutrient_values + end - # Prefer the helper, but gracefully fall back to slicing attributes. - reqs = if profile.respond_to?(:requirements_hash) - profile.requirements_hash - else - profile.attributes.slice(*NutrientProfile::NUTRIENT_KEYS.map(&:to_s)) - end + private + + def nutrient_values + @nutrient_values ||= get_nutrient_values + end + + def get_nutrient_values + sums = Hash.new(0.0) + allocs = target_allocations.includes(:nutrient_profile) - reqs.each do |nutrient_key, value| - next if value.nil? - totals[nutrient_key.to_s] += value.to_f * weight + allocs.each do |alloc| + weight = alloc.percentage.to_f / 100.0 + profile = alloc.nutrient_profile + NutrientVector::NUTRIENT_KEYS.each do |k| + sums[k] += profile.public_send(k).to_f * weight end end - totals + # Ensure all keys exist, even when there are no allocations + NutrientVector::NUTRIENT_KEYS.each { |k| sums[k] ||= 0.0 } + sums.freeze end - private + def set_defaults + self.name ||= "Cible #{Date.today + 1.month}" + if target_allocations.empty? + nutrient_profiles = NutrientProfile.limit(3) + nutrient_profiles.each do |profile| + target_allocations.build(nutrient_profile: profile, percentage: 0) + end + end + end - # def percentages_sum_to_100 - # sum = target_allocations.sum { |a| a.percentage.to_f } - # errors.add(:base, "La somme des pourcentages doit être égale à 100%") unless (sum - 100.0).abs <= 0.1 - # end + def allocations_sum_to_100 + sum = target_allocations.reject(&:marked_for_destruction?).sum { |a| a.percentage.to_f } + errors.add(:base, "La somme des pourcentages doit être 100%") unless (sum - 100).abs < 0.01 + end end diff --git a/app/models/target_allocation.rb b/app/models/target_allocation.rb index 6aa1dcb..5e971a0 100644 --- a/app/models/target_allocation.rb +++ b/app/models/target_allocation.rb @@ -1,7 +1,6 @@ class TargetAllocation < ApplicationRecord - belongs_to :target + belongs_to :target, inverse_of: :target_allocations, touch: true belongs_to :nutrient_profile validates :percentage, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } - validates :nutrient_profile_id, uniqueness: { scope: :target_id } end diff --git a/app/views/dashboard/_nutrient_measurements_table.html.erb b/app/views/dashboard/_nutrient_measurements_table.html.erb index 82ea6ff..4d516bd 100644 --- a/app/views/dashboard/_nutrient_measurements_table.html.erb +++ b/app/views/dashboard/_nutrient_measurements_table.html.erb @@ -2,8 +2,8 @@

Relevé des Nutriments

- <%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-primary" %> - <%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-secondary" %> + <%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-outline-primary" %> + <%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-outline-secondary" %>
diff --git a/app/views/dashboard/_nutrient_target_table.html.erb b/app/views/dashboard/_nutrient_target_table.html.erb index 7cf294c..006d262 100644 --- a/app/views/dashboard/_nutrient_target_table.html.erb +++ b/app/views/dashboard/_nutrient_target_table.html.erb @@ -2,40 +2,33 @@

Complémentation

- <%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-primary" %> - <%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-secondary" %> + <%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-outline-primary" %> + <%= link_to "Liste des cibles", targets_path, class: "btn btn-sm btn-outline-secondary" %> + <%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-outline-secondary" %>
- +
- - + + - <% wr = @weighted || {} %> - <% lm = @latest_measurements || {} %> + <% Nutrient.all.each do |nutrient| %> + <% measured = @latest_measurement.send(nutrient.formula.downcase) %> + <% target = @latest_target.send(nutrient.formula.downcase) %> + <% delta = (target.to_f - measured.to_f) / 100.0 if measured || target %> - <% keys = (wr.keys + lm.keys).map(&:to_s).uniq.sort %> - <% keys.each do |nut| %> - <% measured = lm[nut] %> - <% target = wr[nut] %> - <% delta = (measured.to_f - target.to_f) if measured || target %> - - +
Nutriment RelevéCibleDelta<%= @latest_target&.name || "Cible" %>Delta (%)
<%= nut.upcase %><%= nutrient.formula %> - <% if measured.nil? %> - - <% else %> - <%= number_with_precision(measured, precision: 2) %> - <% end %> + <%= measured.present? ? number_with_precision(measured, precision: 2) : — %> @@ -50,7 +43,7 @@ <% if measured.nil? && target.nil? %> <% else %> - <% badge = + <% badge_class = if delta.nil? "text-bg-secondary" elsif delta.abs <= 0.01 @@ -60,7 +53,7 @@ else "text-bg-danger" end %> - + <%= number_with_precision(delta.to_f, precision: 2) %> <% end %> diff --git a/app/views/dashboard/_raft_allocation.html.erb b/app/views/dashboard/_raft_allocation.html.erb deleted file mode 100644 index 1c9ef9a..0000000 --- a/app/views/dashboard/_raft_allocation.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
-
-
Crop Allocation
- <%#= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %> -
- - <%= bar_chart @raft_data, stacked: true %> -
- diff --git a/app/views/dashboard/_target_table.html.erb b/app/views/dashboard/_target_table.html.erb deleted file mode 100644 index b1553dc..0000000 --- a/app/views/dashboard/_target_table.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -
-
-
Target nutrient concentrations
- <%= link_to "Get Ferti© recipe", ferti_recipe_path, class: "btn btn-sm btn-primary" %> -
- -
- - - - - - - - - - - <% NutrientsHelper::NUTRIENTS.each do |n| %> - <% latest = @latest_measurement[n] || 0 %> - <% target = @target[n] || 0 %> - <% delta = target - latest %> - - - - - - - <% end %> - -
Nutrient Latest (mg/L)Target (mg/L)Δ %
<%= n.upcase %><%= fmt(latest) %><%= fmt(target) %> - - <%= fmt(delta) %>% - -
-
- -
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index a954e4c..50fab18 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -2,8 +2,4 @@ <%= render "nutrient_target_table", nutrient_profiles: @nutrient_profiles %> -<%#= render "raft_allocation" %> - -<%#= render "target_table" %> - <%= render "nutrient_measurements_table" %> diff --git a/app/views/targets/index.html.erb b/app/views/targets/index.html.erb index b552038..e536deb 100644 --- a/app/views/targets/index.html.erb +++ b/app/views/targets/index.html.erb @@ -5,31 +5,29 @@
- +
- - - - + + <% if @targets.present? %> - <% @targets.each do |t| %> - <% sum_pct = t.target_allocations.sum { |a| a.percentage.to_f } %> + <% @targets.each do |target| %> + <% sum_pct = target.allocations.sum { |a| a.percentage.to_f } %> <% badge_class = (sum_pct - 100.0).abs <= 0.01 ? "bg-success" : "bg-danger" %> - - <% end %> diff --git a/app/views/targets/new.html.erb b/app/views/targets/new.html.erb index 42cb7bd..e69de29 100644 --- a/app/views/targets/new.html.erb +++ b/app/views/targets/new.html.erb @@ -1,85 +0,0 @@ -<% content_for :title, "Ajouter une Cible" %> - -

Ajouter une Cible

- -<%= form_with(model: @target) do |f| %> -
-
- <%= f.text_field :name, class: "form-control", placeholder: "Nom de la cible" %> -
- -
-
-
NomRépartition
Total %Créé leActionsRépartitionDate de Création
- <%= link_to t.name.presence || "Objectif ##{t.id}", t %> + <%= link_to target.name.presence || "Cible ##{t.id}", target %> - <% if t.target_allocations.empty? %> + <% if target.allocations.empty? %> Aucune répartition définie <% else %> -
    - <% t.target_allocations.each do |a| %> +
      + <% target.allocations.each do |a| %>
    • <%= a.nutrient_profile&.name || "Profil ##{a.nutrient_profile_id}" %> — <%= number_with_precision(a.percentage.to_f, precision: 2) %>% @@ -38,20 +36,8 @@
    <% end %>
- - <%= number_with_precision(sum_pct, precision: 2) %>% - - - <%= l(t.created_at, format: :short) %> - - <%= link_to "Voir", t, class: "btn btn-outline-secondary btn-sm" %> - <%= link_to "Modifier", edit_target_path(t), class: "btn btn-outline-primary btn-sm" %> - <%# FIXME: Doesn't work. %> - <%= link_to "Supprimer", t, class: "btn btn-outline-danger btn-sm", - data: { turbo_method: :delete, turbo_confirm: "Supprimer cet objectif ?" } %> + <%= l(target.created_at, format: :short) %>
- - - - - - - - <%= f.fields_for :target_allocations do |af| %> - <% np = af.object.nutrient_profile %> - - - - - <% end %> - - - - - - - -
ProfilProportion
- <%= af.hidden_field :nutrient_profile_id %> - <%= np&.name.capitalize || "Profil ##{af.object.nutrient_profile_id}" %> - -
- <%= af.number_field :percentage, - in: 0..100, step: 0.5, - class: "form-control text-end alloc-input", - placeholder: "0.0", - data: { action: "input->alloc#sum" } %> - % -
-
Ajustez chaque pourcentage pour totaliser 100%. - Total : 0% -
-
- - - - -<% end %> - - -- cgit v1.2.3