From 52b044d6a4278c229992404ad5801769c2d13363 Mon Sep 17 00:00:00 2001 From: Marius Peter Date: Sun, 24 Aug 2025 20:29:54 +0200 Subject: First commit. Vive le Castel Peter ! --- app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 ++ app/controllers/application_controller.rb | 4 + app/controllers/concerns/.keep | 0 app/controllers/crops_controller.rb | 70 +++++++++++ app/controllers/dashboard_controller.rb | 45 +++++++ app/controllers/fertilizer_products_controller.rb | 70 +++++++++++ app/controllers/fertilizers_controller.rb | 70 +++++++++++ app/controllers/nutrient_measurement_controller.rb | 4 + app/controllers/rafts_controller.rb | 54 ++++++++ app/controllers/recipes_controller.rb | 8 ++ app/helpers/application_helper.rb | 2 + app/helpers/dashboard_helper.rb | 8 ++ app/helpers/nutrients_helper.rb | 21 ++++ app/helpers/recipes_helper.rb | 16 +++ app/javascript/application.js | 1 + app/jobs/application_job.rb | 7 ++ app/mailers/application_mailer.rb | 4 + app/models/application_record.rb | 3 + app/models/bed.rb | 4 + app/models/concerns/.keep | 0 app/models/crop.rb | 4 + app/models/fertilizer_component.rb | 3 + app/models/fertilizer_composition.rb | 6 + app/models/fertilizer_product.rb | 7 ++ app/models/nutrient.rb | 7 ++ app/models/nutrient_measurement.rb | 4 + app/models/raft.rb | 5 + app/services/fertilizer_recipe_calculator.rb | 136 +++++++++++++++++++++ app/services/target_nutrient_calculator.rb | 33 +++++ app/views/application/_copyright_footer.html.erb | 10 ++ app/views/application/_navbar.html.erb | 33 +++++ app/views/crops/_crop.html.erb | 92 ++++++++++++++ app/views/crops/_form.html.erb | 107 ++++++++++++++++ app/views/crops/edit.html.erb | 12 ++ app/views/crops/index.html.erb | 16 +++ app/views/crops/new.html.erb | 11 ++ app/views/crops/show.html.erb | 10 ++ app/views/dashboard/_raft_allocation.html.erb | 9 ++ app/views/dashboard/_recent_measurements.html.erb | 22 ++++ app/views/dashboard/_target_table.html.erb | 40 ++++++ app/views/dashboard/index.html.erb | 7 ++ .../_fertilizer_product.html.erb | 12 ++ app/views/fertilizer_products/_form.html.erb | 27 ++++ app/views/fertilizer_products/edit.html.erb | 12 ++ app/views/fertilizer_products/index.html.erb | 16 +++ app/views/fertilizer_products/new.html.erb | 11 ++ app/views/fertilizer_products/show.html.erb | 10 ++ app/views/layouts/application.html.erb | 46 +++++++ app/views/layouts/mailer.html.erb | 13 ++ app/views/layouts/mailer.text.erb | 1 + app/views/nutrient_measurement/index.html.erb | 27 ++++ app/views/pwa/manifest.json.erb | 22 ++++ app/views/pwa/service-worker.js | 26 ++++ app/views/rafts/editor.html.erb | 108 ++++++++++++++++ app/views/recipes/_table.html.erb | 32 +++++ app/views/recipes/show.html.erb | 108 ++++++++++++++++ 57 files changed, 1446 insertions(+) create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/crops_controller.rb create mode 100644 app/controllers/dashboard_controller.rb create mode 100644 app/controllers/fertilizer_products_controller.rb create mode 100644 app/controllers/fertilizers_controller.rb create mode 100644 app/controllers/nutrient_measurement_controller.rb create mode 100644 app/controllers/rafts_controller.rb create mode 100644 app/controllers/recipes_controller.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/helpers/dashboard_helper.rb create mode 100644 app/helpers/nutrients_helper.rb create mode 100644 app/helpers/recipes_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/bed.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/crop.rb create mode 100644 app/models/fertilizer_component.rb create mode 100644 app/models/fertilizer_composition.rb create mode 100644 app/models/fertilizer_product.rb create mode 100644 app/models/nutrient.rb create mode 100644 app/models/nutrient_measurement.rb create mode 100644 app/models/raft.rb create mode 100644 app/services/fertilizer_recipe_calculator.rb create mode 100644 app/services/target_nutrient_calculator.rb create mode 100644 app/views/application/_copyright_footer.html.erb create mode 100644 app/views/application/_navbar.html.erb create mode 100644 app/views/crops/_crop.html.erb create mode 100644 app/views/crops/_form.html.erb create mode 100644 app/views/crops/edit.html.erb create mode 100644 app/views/crops/index.html.erb create mode 100644 app/views/crops/new.html.erb create mode 100644 app/views/crops/show.html.erb create mode 100644 app/views/dashboard/_raft_allocation.html.erb create mode 100644 app/views/dashboard/_recent_measurements.html.erb create mode 100644 app/views/dashboard/_target_table.html.erb create mode 100644 app/views/dashboard/index.html.erb create mode 100644 app/views/fertilizer_products/_fertilizer_product.html.erb create mode 100644 app/views/fertilizer_products/_form.html.erb create mode 100644 app/views/fertilizer_products/edit.html.erb create mode 100644 app/views/fertilizer_products/index.html.erb create mode 100644 app/views/fertilizer_products/new.html.erb create mode 100644 app/views/fertilizer_products/show.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/nutrient_measurement/index.html.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100644 app/views/rafts/editor.html.erb create mode 100644 app/views/recipes/_table.html.erb create mode 100644 app/views/recipes/show.html.erb (limited to 'app') diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..fe93333 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..0d95db2 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/crops_controller.rb b/app/controllers/crops_controller.rb new file mode 100644 index 0000000..9619852 --- /dev/null +++ b/app/controllers/crops_controller.rb @@ -0,0 +1,70 @@ +class CropsController < ApplicationController + before_action :set_crop, only: %i[ show edit update destroy ] + + # GET /crops or /crops.json + def index + @crops = Crop.all + end + + # GET /crops/1 or /crops/1.json + def show + end + + # GET /crops/new + def new + @crop = Crop.new + end + + # GET /crops/1/edit + def edit + end + + # POST /crops or /crops.json + def create + @crop = Crop.new(crop_params) + + respond_to do |format| + if @crop.save + format.html { redirect_to @crop, notice: "Crop was successfully created." } + format.json { render :show, status: :created, location: @crop } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @crop.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /crops/1 or /crops/1.json + def update + respond_to do |format| + if @crop.update(crop_params) + format.html { redirect_to @crop, notice: "Crop was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @crop } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @crop.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /crops/1 or /crops/1.json + def destroy + @crop.destroy! + + respond_to do |format| + format.html { redirect_to crops_path, notice: "Crop was successfully destroyed.", status: :see_other } + format.json { head :no_content } + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_crop + @crop = Crop.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def crop_params + params.expect(crop: [ :name, :crop_type, :nno3, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo, :nnh4 ]) + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..4e9d560 --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,45 @@ +class DashboardController < ApplicationController + def index + # Raft allocation by crop type + @raft_data = raft_data_series + + # Nutrient target table + @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first + @target = TargetNutrientCalculator.call + + # Measurement history table + @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10) + + @npk_measurement_data = measurement_data_series(:nno3, :p, :k) + @ammonium_measurement_data = measurement_data_series(:nnh4) + 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 + + def measurement_data_series(*nutrients) + nutrients.map do |formula| + { name: Nutrient.find_by!(formula:).name, + data: NutrientMeasurement + .order(:measured_on) + .pluck(:measured_on, formula) } + end + end +end diff --git a/app/controllers/fertilizer_products_controller.rb b/app/controllers/fertilizer_products_controller.rb new file mode 100644 index 0000000..ff0d945 --- /dev/null +++ b/app/controllers/fertilizer_products_controller.rb @@ -0,0 +1,70 @@ +class FertilizerProductsController < ApplicationController + before_action :set_fertilizer_product, only: %i[ show edit update destroy ] + + # GET /fertilizer_products or /fertilizer_products.json + def index + @fertilizer_products = FertilizerProduct.all + end + + # GET /fertilizer_products/1 or /fertilizer_products/1.json + def show + end + + # GET /fertilizer_products/new + def new + @fertilizer_product = FertilizerProduct.new + end + + # GET /fertilizer_products/1/edit + def edit + end + + # POST /fertilizer_products or /fertilizer_products.json + def create + @fertilizer_product = FertilizerProduct.new(fertilizer_product_params) + + respond_to do |format| + if @fertilizer_product.save + format.html { redirect_to @fertilizer_product, notice: "Fertilizer product was successfully created." } + format.json { render :show, status: :created, location: @fertilizer_product } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @fertilizer_product.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /fertilizer_products/1 or /fertilizer_products/1.json + def update + respond_to do |format| + if @fertilizer_product.update(fertilizer_product_params) + format.html { redirect_to @fertilizer_product, notice: "Fertilizer product was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @fertilizer_product } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @fertilizer_product.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /fertilizer_products/1 or /fertilizer_products/1.json + def destroy + @fertilizer_product.destroy! + + respond_to do |format| + format.html { redirect_to fertilizer_products_path, notice: "Fertilizer product was successfully destroyed.", status: :see_other } + format.json { head :no_content } + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_fertilizer_product + @fertilizer_product = FertilizerProduct.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def fertilizer_product_params + params.expect(fertilizer_product: [ :name, :purity ]) + end +end diff --git a/app/controllers/fertilizers_controller.rb b/app/controllers/fertilizers_controller.rb new file mode 100644 index 0000000..040c462 --- /dev/null +++ b/app/controllers/fertilizers_controller.rb @@ -0,0 +1,70 @@ +class FertilizersController < ApplicationController + before_action :set_fertilizer, only: %i[ show edit update destroy ] + + # GET /fertilizers or /fertilizers.json + def index + @fertilizers = Fertilizer.all + end + + # GET /fertilizers/1 or /fertilizers/1.json + def show + end + + # GET /fertilizers/new + def new + @fertilizer = Fertilizer.new + end + + # GET /fertilizers/1/edit + def edit + end + + # POST /fertilizers or /fertilizers.json + def create + @fertilizer = Fertilizer.new(fertilizer_params) + + respond_to do |format| + if @fertilizer.save + format.html { redirect_to @fertilizer, notice: "Fertilizer was successfully created." } + format.json { render :show, status: :created, location: @fertilizer } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @fertilizer.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /fertilizers/1 or /fertilizers/1.json + def update + respond_to do |format| + if @fertilizer.update(fertilizer_params) + format.html { redirect_to @fertilizer, notice: "Fertilizer was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @fertilizer } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @fertilizer.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /fertilizers/1 or /fertilizers/1.json + def destroy + @fertilizer.destroy! + + respond_to do |format| + format.html { redirect_to fertilizers_path, notice: "Fertilizer was successfully destroyed.", status: :see_other } + format.json { head :no_content } + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_fertilizer + @fertilizer = Fertilizer.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def fertilizer_params + params.expect(fertilizer: [ :name, :formula, :nno3, :nnh4, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo ]) + end +end diff --git a/app/controllers/nutrient_measurement_controller.rb b/app/controllers/nutrient_measurement_controller.rb new file mode 100644 index 0000000..6d26bfa --- /dev/null +++ b/app/controllers/nutrient_measurement_controller.rb @@ -0,0 +1,4 @@ +class NutrientMeasurementController < ApplicationController + def index + end +end diff --git a/app/controllers/rafts_controller.rb b/app/controllers/rafts_controller.rb new file mode 100644 index 0000000..96d99fd --- /dev/null +++ b/app/controllers/rafts_controller.rb @@ -0,0 +1,54 @@ +class RaftsController < ApplicationController + before_action :set_collections, only: [ :editor ] + + def index + redirect_to editor_rafts_path + end + + def editor + # @beds, @crops set in before_action + end + + # 1) Assign ALL rafts + def assign_all + crop_id = normalized_crop_id(params[:crop_id]) + Raft.update_all(crop_id: crop_id) # nil ok + redirect_to editor_rafts_path, notice: "All rafts updated." + end + + # 2) Assign all rafts in ONE bed + def assign_bed + bed = Bed.find(params.require(:bed_id)) + crop_id = normalized_crop_id(params[:crop_id]) + bed.rafts.update_all(crop_id: crop_id) + redirect_to editor_rafts_path, notice: "Bed ##{bed.location} updated." + end + + # 3) Assign ONE raft + def assign_one + raft = Raft.find(params[:id]) + val = params[:crop_id].to_s + + if val.blank? || val.casecmp("null").zero? + # Skip validations; write NULL directly + raft.update_column(:crop_id, nil) + else + raft.update!(crop_id: val) + end + + redirect_to editor_rafts_path(anchor: "bed-#{raft.bed_id}"), notice: "Raft updated." + end + + private + + def set_collections + @beds = Bed.order(:location) + @crops = Crop.order(:name) + end + + # Accept "", "null" → nil to allow clearing + def normalized_crop_id(val) + return nil if val.blank? || val.to_s.downcase == "null" + val + end +end diff --git a/app/controllers/recipes_controller.rb b/app/controllers/recipes_controller.rb new file mode 100644 index 0000000..bcc29e7 --- /dev/null +++ b/app/controllers/recipes_controller.rb @@ -0,0 +1,8 @@ +class RecipesController < ApplicationController + def show + @latest = NutrientMeasurement.order(:measured_on).last || NutrientMeasurement.new + @target = TargetNutrientCalculator.call + volume = (params[:volume].presence || 100_000).to_i + @recipe = FertilizerRecipeCalculator.call(@latest, @target, water_volume_l: volume) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..de6be79 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb new file mode 100644 index 0000000..5d64d17 --- /dev/null +++ b/app/helpers/dashboard_helper.rb @@ -0,0 +1,8 @@ +module DashboardHelper + def fmt2(v) = number_with_precision(v, precision: 2) + + # Total nitrogen (NO3-N + NH4-N) + def total_n(measurement) + (measurement.nno3.to_f) + (measurement.nnh4.to_f) + end +end diff --git a/app/helpers/nutrients_helper.rb b/app/helpers/nutrients_helper.rb new file mode 100644 index 0000000..de98dc3 --- /dev/null +++ b/app/helpers/nutrients_helper.rb @@ -0,0 +1,21 @@ +module NutrientsHelper + NUTRIENTS = %i[nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4].freeze + + def percent_delta(measured, target) + return 0.0 if target.to_f.zero? + ((measured.to_f - target.to_f) / target.to_f) * 100.0 + end + + def fmt(v) + number_with_precision(v, precision: 2, strip_insignificant_zeros: true) + end + + def delta_badge_class(delta) + d = delta.abs + case d + when d < 0 then "bg-info" + when 0..5 then "bg-secondary" + else "bg-warning" + end + end +end diff --git a/app/helpers/recipes_helper.rb b/app/helpers/recipes_helper.rb new file mode 100644 index 0000000..02cb1a7 --- /dev/null +++ b/app/helpers/recipes_helper.rb @@ -0,0 +1,16 @@ +module RecipesHelper + def commercial_name_for(component) + # Try to find a FertilizerProduct via a join, but gracefully fallback. + if defined?(FertilizerProduct) && defined?(FertilizerComposition) + prod = FertilizerProduct.joins(:fertilizer_compositions) + .where(fertilizer_compositions: { fertilizer_component_id: component.id }) + .first + return prod.name if prod&.name.present? + end + component.name.presence || component.formula + end + + def fmt_kg(v) + number_with_precision(v.to_f, precision: 2, strip_insignificant_zeros: true) + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 0000000..beff742 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..3c34c81 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/bed.rb b/app/models/bed.rb new file mode 100644 index 0000000..33eafd2 --- /dev/null +++ b/app/models/bed.rb @@ -0,0 +1,4 @@ +class Bed < ApplicationRecord + has_many :rafts, -> { order(:location) }, dependent: :destroy + validates :location, presence: true, uniqueness: true +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/crop.rb b/app/models/crop.rb new file mode 100644 index 0000000..b0f168d --- /dev/null +++ b/app/models/crop.rb @@ -0,0 +1,4 @@ +class Crop < ApplicationRecord + has_many :rafts + enum :crop_type, { leafy: 0, fruit: 1, herb: 2 } +end diff --git a/app/models/fertilizer_component.rb b/app/models/fertilizer_component.rb new file mode 100644 index 0000000..701ae9b --- /dev/null +++ b/app/models/fertilizer_component.rb @@ -0,0 +1,3 @@ +class FertilizerComponent < ApplicationRecord + validates :name, presence: true +end diff --git a/app/models/fertilizer_composition.rb b/app/models/fertilizer_composition.rb new file mode 100644 index 0000000..cf2bb93 --- /dev/null +++ b/app/models/fertilizer_composition.rb @@ -0,0 +1,6 @@ +class FertilizerComposition < ApplicationRecord + belongs_to :fertilizer_product + belongs_to :fertilizer_component + + validates :percent_w, numericality: { greater_than: 0, less_than_or_equal_to: 100 } +end diff --git a/app/models/fertilizer_product.rb b/app/models/fertilizer_product.rb new file mode 100644 index 0000000..e41316b --- /dev/null +++ b/app/models/fertilizer_product.rb @@ -0,0 +1,7 @@ +class FertilizerProduct < ApplicationRecord + has_many :fertilizer_compositions, dependent: :destroy + has_many :fertilizer_components, through: :fertilizer_compositions + + validates :name, presence: true, uniqueness: true + validates :purity, numericality: { greater_than: 0, less_than_or_equal_to: 100 } +end diff --git a/app/models/nutrient.rb b/app/models/nutrient.rb new file mode 100644 index 0000000..c584668 --- /dev/null +++ b/app/models/nutrient.rb @@ -0,0 +1,7 @@ +class Nutrient < ApplicationRecord + validates :formula, presence: true, uniqueness: true + validates :name, presence: true + + before_update { raise ActiveRecord::ReadOnlyRecord } + before_destroy { raise ActiveRecord::ReadOnlyRecord } +end diff --git a/app/models/nutrient_measurement.rb b/app/models/nutrient_measurement.rb new file mode 100644 index 0000000..f1d6d5b --- /dev/null +++ b/app/models/nutrient_measurement.rb @@ -0,0 +1,4 @@ +class NutrientMeasurement < ApplicationRecord + validates :measured_on, presence: true + validates :measured_on, uniqueness: true +end diff --git a/app/models/raft.rb b/app/models/raft.rb new file mode 100644 index 0000000..af52700 --- /dev/null +++ b/app/models/raft.rb @@ -0,0 +1,5 @@ +class Raft < ApplicationRecord + belongs_to :bed + belongs_to :crop + validates :location, presence: true, uniqueness: { scope: :bed_id } +end diff --git a/app/services/fertilizer_recipe_calculator.rb b/app/services/fertilizer_recipe_calculator.rb new file mode 100644 index 0000000..28ba8aa --- /dev/null +++ b/app/services/fertilizer_recipe_calculator.rb @@ -0,0 +1,136 @@ +class FertilizerRecipeCalculator + NUTRIENTS = %i[nno3 p k ca mg s nnh4].freeze + + def self.call(latest, target, water_volume_l: 100_000) + new(latest, target, water_volume_l).call + end + + def initialize(latest, target, water_volume_l) + @latest = latest || NutrientMeasurement.new + @target = target || NutrientMeasurement.new + @vol_l = water_volume_l.to_f + end + + # Returns a Hash { FertilizerComponent => qty_kg } + def call + deltas_mg_per_l = NUTRIENTS.index_with do |k| + [ (value(@target, k) - value(@latest, k)), 0 ].max + end + + # mg/L * L = mg -> kg + needed_kg = deltas_mg_per_l.transform_values { |mg_l| mg_l * @vol_l / 1_000_000.0 } + + recipe = Hash.new(0.0) + + # 1) P via MAP or DAP (accounts NH4-N) + if (p_need = needed_kg[:p]) > 0 + map = fc_by_formula("MAP") || fc_by_name_like("monoammonium phosphate") + dap = fc_by_formula("DAP") || fc_by_name_like("diammonium phosphate") + carrier = [ map, dap ].compact.find { |c| positive?(c.p) } + if carrier + kg = kg_for(p_need, carrier.p) + recipe[carrier] += kg + needed_kg[:nnh4] = [ needed_kg[:nnh4] - kg * pct(carrier.nnh4), 0 ].max if positive?(carrier.nnh4) + needed_kg[:p] = 0 + end + end + + # 2) NO3-N via KNO3 then Ca(NO3)2 (accounts K/Ca) + if (n_need = needed_kg[:nno3]) > 0 + kno3 = fc_by_formula("KNO3") || fc_by_name_like("potassium nitrate") + can = fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate") + + if kno3&.nno3.to_f > 0 + kg = kg_for(n_need, kno3.nno3) + recipe[kno3] += kg + needed_kg[:k] = [ needed_kg[:k] - kg * pct(kno3.k), 0 ].max + n_need -= kg * pct(kno3.nno3) + end + + if n_need > 0 && can&.nno3.to_f > 0 + kg = kg_for(n_need, can.nno3) + recipe[can] += kg + needed_kg[:ca] = [ needed_kg[:ca] - kg * pct(can.ca), 0 ].max + n_need = 0 + end + + needed_kg[:nno3] = [ n_need, 0 ].max + end + + # 3) K via K2SO4 (accounts S) + if (k_need = needed_kg[:k]) > 0 + sop = fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate") + if sop&.k.to_f > 0 + kg = kg_for(k_need, sop.k) + recipe[sop] += kg + needed_kg[:s] = [ needed_kg[:s] - kg * pct(sop.s), 0 ].max + needed_kg[:k] = 0 + end + end + + # 4) Ca via Ca(NO3)2 (accounts NO3-N) + if (ca_need = needed_kg[:ca]) > 0 + can = recipe.keys.find { |c| norm_formula(c) == "ca(no3)2" } || + fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate") + if can&.ca.to_f > 0 + kg = kg_for(ca_need, can.ca) + recipe[can] += kg + needed_kg[:nno3] = [ needed_kg[:nno3] - kg * pct(can.nno3), 0 ].max + needed_kg[:ca] = 0 + end + end + + # 5) Mg via MgSO4 (accounts S) + if (mg_need = needed_kg[:mg]) > 0 + mgs = fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate") + if mgs&.mg.to_f > 0 + kg = kg_for(mg_need, mgs.mg) + recipe[mgs] += kg + needed_kg[:s] = [ needed_kg[:s] - kg * pct(mgs.s), 0 ].max + needed_kg[:mg] = 0 + end + end + + # 6) S via K2SO4 or MgSO4 (whichever we already used or find) + if (s_need = needed_kg[:s]) > 0 + sop = recipe.keys.find { |c| norm_formula(c) == "k2so4" } || + fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate") + mgs = recipe.keys.find { |c| norm_formula(c) == "mgso4" } || + fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate") + carrier = [ sop, mgs ].compact.find { |c| positive?(c.s) } + if carrier + kg = kg_for(s_need, carrier.s) + recipe[carrier] += kg + needed_kg[:k] = [ needed_kg[:k] - kg * pct(carrier.k), 0 ].max if positive?(carrier.k) + needed_kg[:mg] = [ needed_kg[:mg] - kg * pct(carrier.mg), 0 ].max if positive?(carrier.mg) + needed_kg[:s] = 0 + end + end + + recipe.delete_if { |_c, kg| kg < 0.01 } + recipe + end + + private + + def value(obj, key) = obj.public_send(key).to_f + + def fc_by_formula(formula) + FertilizerComponent.where("LOWER(formula) = ?", formula.to_s.downcase).first + end + + def fc_by_name_like(name) + FertilizerComponent.where("LOWER(name) LIKE ?", "%#{name.downcase}%").first + end + + def kg_for(need_kg_element, percent_in_product) + return 0.0 unless positive?(percent_in_product) + need_kg_element / pct(percent_in_product) + end + + def pct(v) = v.to_f / 100.0 + + def positive?(v) = v.to_f > 0.0 + + def norm_formula(c) = c.formula.to_s.downcase.gsub(/\s+/, "") +end diff --git a/app/services/target_nutrient_calculator.rb b/app/services/target_nutrient_calculator.rb new file mode 100644 index 0000000..e6cd378 --- /dev/null +++ b/app/services/target_nutrient_calculator.rb @@ -0,0 +1,33 @@ +class TargetNutrientCalculator + # Derive nutrient columns from the NutrientMeasurement table + NUTRIENT_COLUMNS = (NutrientMeasurement.column_names - %w[id measured_on created_at updated_at]) + .map!(&:to_sym) + .freeze + + # Returns an unsaved NutrientMeasurement with target concentrations (e.g., mg/L) + def self.call + rafts = Raft.includes(:crop).where.not(crop_id: nil) + total = rafts.count + return empty_measurement if total.zero? + + sums = Hash.new(0.0) + + rafts.each do |raft| + NUTRIENT_COLUMNS.each do |col| + v = raft.crop.public_send(col) + sums[col] += v.to_f if v + end + end + + targets = sums.transform_values { |s| s / total } + NutrientMeasurement.new({ measured_on: Date.current }.merge(targets)) + end + + private + + def empty_measurement + NutrientMeasurement.new( + { measured_on: Date.current }.merge(NUTRIENT_COLUMNS.index_with { 0.0 }) + ) + end +end diff --git a/app/views/application/_copyright_footer.html.erb b/app/views/application/_copyright_footer.html.erb new file mode 100644 index 0000000..4a6d240 --- /dev/null +++ b/app/views/application/_copyright_footer.html.erb @@ -0,0 +1,10 @@ + diff --git a/app/views/application/_navbar.html.erb b/app/views/application/_navbar.html.erb new file mode 100644 index 0000000..9fa250a --- /dev/null +++ b/app/views/application/_navbar.html.erb @@ -0,0 +1,33 @@ + diff --git a/app/views/crops/_crop.html.erb b/app/views/crops/_crop.html.erb new file mode 100644 index 0000000..e9b9b4d --- /dev/null +++ b/app/views/crops/_crop.html.erb @@ -0,0 +1,92 @@ +
+

+ Name: + <%= crop.name %> +

+ +

+ Crop type: + <%= crop.crop_type %> +

+ +

+ Nno3: + <%= crop.nno3 %> +

+ +

+ P: + <%= crop.p %> +

+ +

+ K: + <%= crop.k %> +

+ +

+ Ca: + <%= crop.ca %> +

+ +

+ Mg: + <%= crop.mg %> +

+ +

+ S: + <%= crop.s %> +

+ +

+ Na: + <%= crop.na %> +

+ +

+ Cl: + <%= crop.cl %> +

+ +

+ Si: + <%= crop.si %> +

+ +

+ Fe: + <%= crop.fe %> +

+ +

+ Zn: + <%= crop.zn %> +

+ +

+ B: + <%= crop.b %> +

+ +

+ Mn: + <%= crop.mn %> +

+ +

+ Cu: + <%= crop.cu %> +

+ +

+ Mo: + <%= crop.mo %> +

+ +

+ Nnh4: + <%= crop.nnh4 %> +

+ +
diff --git a/app/views/crops/_form.html.erb b/app/views/crops/_form.html.erb new file mode 100644 index 0000000..90226b6 --- /dev/null +++ b/app/views/crops/_form.html.erb @@ -0,0 +1,107 @@ +<%= form_with(model: crop) do |form| %> + <% if crop.errors.any? %> +
+

<%= pluralize(crop.errors.count, "error") %> prohibited this crop from being saved:

+ + +
+ <% end %> + +
+ <%= form.label :name, style: "display: block" %> + <%= form.text_field :name %> +
+ +
+ <%= form.label :crop_type, style: "display: block" %> + <%= form.number_field :crop_type %> +
+ +
+ <%= form.label :nno3, style: "display: block" %> + <%= form.text_field :nno3 %> +
+ +
+ <%= form.label :p, style: "display: block" %> + <%= form.text_field :p %> +
+ +
+ <%= form.label :k, style: "display: block" %> + <%= form.text_field :k %> +
+ +
+ <%= form.label :ca, style: "display: block" %> + <%= form.text_field :ca %> +
+ +
+ <%= form.label :mg, style: "display: block" %> + <%= form.text_field :mg %> +
+ +
+ <%= form.label :s, style: "display: block" %> + <%= form.text_field :s %> +
+ +
+ <%= form.label :na, style: "display: block" %> + <%= form.text_field :na %> +
+ +
+ <%= form.label :cl, style: "display: block" %> + <%= form.text_field :cl %> +
+ +
+ <%= form.label :si, style: "display: block" %> + <%= form.text_field :si %> +
+ +
+ <%= form.label :fe, style: "display: block" %> + <%= form.text_field :fe %> +
+ +
+ <%= form.label :zn, style: "display: block" %> + <%= form.text_field :zn %> +
+ +
+ <%= form.label :b, style: "display: block" %> + <%= form.text_field :b %> +
+ +
+ <%= form.label :mn, style: "display: block" %> + <%= form.text_field :mn %> +
+ +
+ <%= form.label :cu, style: "display: block" %> + <%= form.text_field :cu %> +
+ +
+ <%= form.label :mo, style: "display: block" %> + <%= form.text_field :mo %> +
+ +
+ <%= form.label :nnh4, style: "display: block" %> + <%= form.text_field :nnh4 %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/crops/edit.html.erb b/app/views/crops/edit.html.erb new file mode 100644 index 0000000..54c616c --- /dev/null +++ b/app/views/crops/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing crop" %> + +

Editing crop

+ +<%= render "form", crop: @crop %> + +
+ +
+ <%= link_to "Show this crop", @crop %> | + <%= link_to "Back to crops", crops_path %> +
diff --git a/app/views/crops/index.html.erb b/app/views/crops/index.html.erb new file mode 100644 index 0000000..bae09fa --- /dev/null +++ b/app/views/crops/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Crops" %> + +

Crops

+ +
+ <% @crops.each do |crop| %> + <%= render crop %> +

+ <%= link_to "Show this crop", crop %> +

+ <% end %> +
+ +<%= link_to "New crop", new_crop_path %> diff --git a/app/views/crops/new.html.erb b/app/views/crops/new.html.erb new file mode 100644 index 0000000..4ef4da5 --- /dev/null +++ b/app/views/crops/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New crop" %> + +

New crop

+ +<%= render "form", crop: @crop %> + +
+ +
+ <%= link_to "Back to crops", crops_path %> +
diff --git a/app/views/crops/show.html.erb b/app/views/crops/show.html.erb new file mode 100644 index 0000000..971f097 --- /dev/null +++ b/app/views/crops/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @crop %> + +
+ <%= link_to "Edit this crop", edit_crop_path(@crop) %> | + <%= link_to "Back to crops", crops_path %> + + <%= button_to "Destroy this crop", @crop, method: :delete %> +
diff --git a/app/views/dashboard/_raft_allocation.html.erb b/app/views/dashboard/_raft_allocation.html.erb new file mode 100644 index 0000000..ef95cdd --- /dev/null +++ b/app/views/dashboard/_raft_allocation.html.erb @@ -0,0 +1,9 @@ +
+
+
Raft Allocation
+ <%= link_to "Edit raft allocation", editor_rafts_path, class: "btn btn-sm btn-primary" %> +
+ + <%= bar_chart @raft_data, stacked: true %> +
+ diff --git a/app/views/dashboard/_recent_measurements.html.erb b/app/views/dashboard/_recent_measurements.html.erb new file mode 100644 index 0000000..bc63a60 --- /dev/null +++ b/app/views/dashboard/_recent_measurements.html.erb @@ -0,0 +1,22 @@ +
+
+
Nutrient Measurements
+
+ <%#= link_to "Add new measurement", editor_rafts_path, class: "btn btn-sm btn-primary" %> + <%#= link_to "View all", editor_rafts_path, class: "btn btn-sm btn-secondary" %> +
+
+ +
+
+ <%= line_chart @npk_measurement_data, + title: "NPK", + ytitle: "Concentration (mg/L)" %> +
+
+ <%= line_chart @ammonium_measurement_data, + title: "Ammonium", + ytitle: "Concentration (mg/L)" %> +
+
+
diff --git a/app/views/dashboard/_target_table.html.erb b/app/views/dashboard/_target_table.html.erb new file mode 100644 index 0000000..b8f7e66 --- /dev/null +++ b/app/views/dashboard/_target_table.html.erb @@ -0,0 +1,40 @@ +
+
+
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 new file mode 100644 index 0000000..2902ada --- /dev/null +++ b/app/views/dashboard/index.html.erb @@ -0,0 +1,7 @@ +

Ferti

+ +<%= render "raft_allocation" %> + +<%= render "target_table" %> + +<%= render "recent_measurements" %> diff --git a/app/views/fertilizer_products/_fertilizer_product.html.erb b/app/views/fertilizer_products/_fertilizer_product.html.erb new file mode 100644 index 0000000..d84a03d --- /dev/null +++ b/app/views/fertilizer_products/_fertilizer_product.html.erb @@ -0,0 +1,12 @@ +
+

+ Name: + <%= fertilizer_product.name %> +

+ +

+ Purity: + <%= fertilizer_product.purity %> +

+ +
diff --git a/app/views/fertilizer_products/_form.html.erb b/app/views/fertilizer_products/_form.html.erb new file mode 100644 index 0000000..517fe09 --- /dev/null +++ b/app/views/fertilizer_products/_form.html.erb @@ -0,0 +1,27 @@ +<%= form_with(model: fertilizer_product) do |form| %> + <% if fertilizer_product.errors.any? %> +
+

<%= pluralize(fertilizer_product.errors.count, "error") %> prohibited this fertilizer_product from being saved:

+ + +
+ <% end %> + +
+ <%= form.label :name, style: "display: block" %> + <%= form.text_field :name %> +
+ +
+ <%= form.label :purity, style: "display: block" %> + <%= form.text_field :purity %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/fertilizer_products/edit.html.erb b/app/views/fertilizer_products/edit.html.erb new file mode 100644 index 0000000..aa88dad --- /dev/null +++ b/app/views/fertilizer_products/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing fertilizer product" %> + +

Editing fertilizer product

+ +<%= render "form", fertilizer_product: @fertilizer_product %> + +
+ +
+ <%= link_to "Show this fertilizer product", @fertilizer_product %> | + <%= link_to "Back to fertilizer products", fertilizer_products_path %> +
diff --git a/app/views/fertilizer_products/index.html.erb b/app/views/fertilizer_products/index.html.erb new file mode 100644 index 0000000..f624ffc --- /dev/null +++ b/app/views/fertilizer_products/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Fertilizer products" %> + +

Fertilizer products

+ +
+ <% @fertilizer_products.each do |fertilizer_product| %> + <%= render fertilizer_product %> +

+ <%= link_to "Show this fertilizer product", fertilizer_product %> +

+ <% end %> +
+ +<%= link_to "New fertilizer product", new_fertilizer_product_path %> diff --git a/app/views/fertilizer_products/new.html.erb b/app/views/fertilizer_products/new.html.erb new file mode 100644 index 0000000..81aad79 --- /dev/null +++ b/app/views/fertilizer_products/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New fertilizer product" %> + +

New fertilizer product

+ +<%= render "form", fertilizer_product: @fertilizer_product %> + +
+ +
+ <%= link_to "Back to fertilizer products", fertilizer_products_path %> +
diff --git a/app/views/fertilizer_products/show.html.erb b/app/views/fertilizer_products/show.html.erb new file mode 100644 index 0000000..ea97041 --- /dev/null +++ b/app/views/fertilizer_products/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @fertilizer_product %> + +
+ <%= link_to "Edit this fertilizer product", edit_fertilizer_product_path(@fertilizer_product) %> | + <%= link_to "Back to fertilizer products", fertilizer_products_path %> + + <%= button_to "Destroy this fertilizer product", @fertilizer_product, method: :delete %> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..bf61c9d --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,46 @@ + + + + <%= content_for(:title) || "Ferti" %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + + + + + + + + + + <%= javascript_importmap_tags %> + + + + <%= render "application/navbar" %> + +
+ <%= yield %> +
+ + <%= render "application/copyright_footer" %> + + + + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/nutrient_measurement/index.html.erb b/app/views/nutrient_measurement/index.html.erb new file mode 100644 index 0000000..d9f522e --- /dev/null +++ b/app/views/nutrient_measurement/index.html.erb @@ -0,0 +1,27 @@ +

NutrientMeasurement#index

+

Find me in app/views/nutrient_measurement/index.html.erb

+ +
+ + + + + + + + + + + + <% @measurements.each do |m| %> + + + + + + + + <% end %> + +
DateN (total)PKNH₄‑N
<%= l(m.measured_on) %><%= fmt2(total_n(m)) %><%= fmt2(m.p) %><%= fmt2(m.k) %><%= fmt2(m.nnh4) %>
+
diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..f8908f0 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "Ferti", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "Ferti.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/app/views/rafts/editor.html.erb b/app/views/rafts/editor.html.erb new file mode 100644 index 0000000..cde56d8 --- /dev/null +++ b/app/views/rafts/editor.html.erb @@ -0,0 +1,108 @@ +
+

Rafts editor

+ <%= link_to "Back to dashboard", root_path, class: "btn btn-outline-secondary" %> +
+ + +
+
Assign ALL rafts
+
+ <%= form_with url: assign_all_rafts_path, method: :patch, class: "row g-2", data: { turbo: false } do %> +
+ +
+
+ +
+ <% end %> +
+
+ +
+ + + + + <% max_cols = @beds.map { |b| b.rafts.count }.max %> + <% (1..max_cols).each do |i| %> + + <% end %> + + + + <% @beds.each do |bed| %> + + + <% bed.rafts.order(:location).each do |raft| %> + + <% end %> + + <% end %> + +
BedR<%= i %>
<%= bed.location %> + <% if raft.crop %> + <%= raft.crop.name %> + <% else %> + — + <% end %> +
+
+ + +<% @beds.each do |bed| %> +
+
+ Bed #<%= bed.location %> + <%= form_with url: assign_bed_rafts_path, method: :patch, class: "d-flex gap-2 align-items-center", data: { turbo: false } do %> + + + + <% end %> +
+
+
+ + + + + + + + + + <% bed.rafts.order(:location).each do |raft| %> + + + + + + <% end %> + +
RaftCropActions
<%= raft.location %> + <%= form_with url: assign_one_raft_path(raft), method: :patch, class: "d-flex gap-2", data: { turbo: false } do %> + + + <% end %> + + <%= button_to "Clear", assign_one_raft_path(raft, crop_id: ""), method: :patch, + form: { data: { turbo: false } }, class: "btn btn-outline-secondary btn-sm" %> +
+
+
+
+<% end %> diff --git a/app/views/recipes/_table.html.erb b/app/views/recipes/_table.html.erb new file mode 100644 index 0000000..a42a0b9 --- /dev/null +++ b/app/views/recipes/_table.html.erb @@ -0,0 +1,32 @@ +
+ + + + + + + + + + + <% pcount = [[portions.to_i, 2].max, 10].min %> + <% recipe&.each do |component, kg| %> + <% prod_name = commercial_name_for(component) %> + + + + + + + <% end %> + <% if recipe.blank? %> + + <% end %> + +
Fertilizer productComponentQty (kg)Per portion (kg)
<%= prod_name %><%= component.name %><%= fmt_kg(kg) %><%= fmt_kg(kg / pcount.to_f) %>
No supplementation required.
+
+ + diff --git a/app/views/recipes/show.html.erb b/app/views/recipes/show.html.erb new file mode 100644 index 0000000..180fdae --- /dev/null +++ b/app/views/recipes/show.html.erb @@ -0,0 +1,108 @@ + +<%# Controls header %> +
+
+
Ferti© Recipe
+ +
+ <%= form_with url: ferti_recipe_path, method: :get, local: true, class: "d-flex flex-wrap gap-2 align-items-center", id: "recipe-form" do %> +
+ + +
+ +
+ +
+ + +
+ <% end %> + + <%= link_to "Back", root_path, class: "btn btn-sm btn-secondary" %> +
+
+ +
+ + + + + + + + + + + <% @recipe.each do |component, kg| %> + <% prod_name = commercial_name_for(component) %> + + + + + + + <% end %> + <% if @recipe.blank? %> + + <% end %> + +
ProductFertilizerQty (kg)Per portion (kg)
<%= prod_name %><%= component.name %><%= fmt_kg(kg) %><%= fmt_kg(kg / (params[:portions].presence || 2).to_f) %>
No supplementation required.
+
+ + +
+ +<%# Tiny vanilla JS: auto-submit on volume change; live per-portion update %> + -- cgit v1.2.3