summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/.keep0
-rw-r--r--app/assets/stylesheets/application.css10
-rw-r--r--app/controllers/application_controller.rb4
-rw-r--r--app/controllers/concerns/.keep0
-rw-r--r--app/controllers/crops_controller.rb70
-rw-r--r--app/controllers/dashboard_controller.rb45
-rw-r--r--app/controllers/fertilizer_products_controller.rb70
-rw-r--r--app/controllers/fertilizers_controller.rb70
-rw-r--r--app/controllers/nutrient_measurement_controller.rb4
-rw-r--r--app/controllers/rafts_controller.rb54
-rw-r--r--app/controllers/recipes_controller.rb8
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/helpers/dashboard_helper.rb8
-rw-r--r--app/helpers/nutrients_helper.rb21
-rw-r--r--app/helpers/recipes_helper.rb16
-rw-r--r--app/javascript/application.js1
-rw-r--r--app/jobs/application_job.rb7
-rw-r--r--app/mailers/application_mailer.rb4
-rw-r--r--app/models/application_record.rb3
-rw-r--r--app/models/bed.rb4
-rw-r--r--app/models/concerns/.keep0
-rw-r--r--app/models/crop.rb4
-rw-r--r--app/models/fertilizer_component.rb3
-rw-r--r--app/models/fertilizer_composition.rb6
-rw-r--r--app/models/fertilizer_product.rb7
-rw-r--r--app/models/nutrient.rb7
-rw-r--r--app/models/nutrient_measurement.rb4
-rw-r--r--app/models/raft.rb5
-rw-r--r--app/services/fertilizer_recipe_calculator.rb136
-rw-r--r--app/services/target_nutrient_calculator.rb33
-rw-r--r--app/views/application/_copyright_footer.html.erb10
-rw-r--r--app/views/application/_navbar.html.erb33
-rw-r--r--app/views/crops/_crop.html.erb92
-rw-r--r--app/views/crops/_form.html.erb107
-rw-r--r--app/views/crops/edit.html.erb12
-rw-r--r--app/views/crops/index.html.erb16
-rw-r--r--app/views/crops/new.html.erb11
-rw-r--r--app/views/crops/show.html.erb10
-rw-r--r--app/views/dashboard/_raft_allocation.html.erb9
-rw-r--r--app/views/dashboard/_recent_measurements.html.erb22
-rw-r--r--app/views/dashboard/_target_table.html.erb40
-rw-r--r--app/views/dashboard/index.html.erb7
-rw-r--r--app/views/fertilizer_products/_fertilizer_product.html.erb12
-rw-r--r--app/views/fertilizer_products/_form.html.erb27
-rw-r--r--app/views/fertilizer_products/edit.html.erb12
-rw-r--r--app/views/fertilizer_products/index.html.erb16
-rw-r--r--app/views/fertilizer_products/new.html.erb11
-rw-r--r--app/views/fertilizer_products/show.html.erb10
-rw-r--r--app/views/layouts/application.html.erb46
-rw-r--r--app/views/layouts/mailer.html.erb13
-rw-r--r--app/views/layouts/mailer.text.erb1
-rw-r--r--app/views/nutrient_measurement/index.html.erb27
-rw-r--r--app/views/pwa/manifest.json.erb22
-rw-r--r--app/views/pwa/service-worker.js26
-rw-r--r--app/views/rafts/editor.html.erb108
-rw-r--r--app/views/recipes/_table.html.erb32
-rw-r--r--app/views/recipes/show.html.erb108
57 files changed, 1446 insertions, 0 deletions
diff --git a/app/assets/images/.keep b/app/assets/images/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/assets/images/.keep
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
--- /dev/null
+++ b/app/controllers/concerns/.keep
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
--- /dev/null
+++ b/app/models/concerns/.keep
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 @@
+<footer class="footer mt-auto py-3">
+ <div class="container d-flex justify-content-between">
+ <span class="text-muted">&copy; 2025 FAPG. All rights reserved.</span>
+ <ul class="list-inline">
+ <li class="list-inline-item"><a href="#" class="text-muted">Privacy Policy</a></li>
+ <li class="list-inline-item"><a href="#" class="text-muted">Terms of Use</a></li>
+ <li class="list-inline-item"><a href="#" class="text-muted">Contact Us</a></li>
+ </ul>
+ </div>
+</footer>
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 @@
+<nav class="navbar navbar-expand-lg navbar-light bg-light">
+ <div class="container-fluid">
+ <span class="navbar-brand">FAPG</span>
+
+ <button class="navbar-toggler"
+ type="button"
+ data-bs-toggle="collapse"
+ data-bs-target="#navbarSupportedContent"
+ aria-controls="navbarSupportedContent"
+ aria-expanded="false"
+ aria-label="Toggle navigation">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+
+ <div class="collapse navbar-collapse" id="navbarSupportedContent">
+ <ul class="navbar-nav me-auto mb-2 mb-lg-0">
+ <li class="nav-item">
+ <%= link_to "Home", root_path, class: "nav-link" %>
+ </li>
+ <li class="nav-item">
+ <%= link_to "Ferti", root_path, class: "nav-link" %>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Inventory</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Billing</a>
+ </li>
+ </ul>
+
+ </div>
+ </div>
+</nav>
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 @@
+<div id="<%= dom_id crop %>">
+ <p>
+ <strong>Name:</strong>
+ <%= crop.name %>
+ </p>
+
+ <p>
+ <strong>Crop type:</strong>
+ <%= crop.crop_type %>
+ </p>
+
+ <p>
+ <strong>Nno3:</strong>
+ <%= crop.nno3 %>
+ </p>
+
+ <p>
+ <strong>P:</strong>
+ <%= crop.p %>
+ </p>
+
+ <p>
+ <strong>K:</strong>
+ <%= crop.k %>
+ </p>
+
+ <p>
+ <strong>Ca:</strong>
+ <%= crop.ca %>
+ </p>
+
+ <p>
+ <strong>Mg:</strong>
+ <%= crop.mg %>
+ </p>
+
+ <p>
+ <strong>S:</strong>
+ <%= crop.s %>
+ </p>
+
+ <p>
+ <strong>Na:</strong>
+ <%= crop.na %>
+ </p>
+
+ <p>
+ <strong>Cl:</strong>
+ <%= crop.cl %>
+ </p>
+
+ <p>
+ <strong>Si:</strong>
+ <%= crop.si %>
+ </p>
+
+ <p>
+ <strong>Fe:</strong>
+ <%= crop.fe %>
+ </p>
+
+ <p>
+ <strong>Zn:</strong>
+ <%= crop.zn %>
+ </p>
+
+ <p>
+ <strong>B:</strong>
+ <%= crop.b %>
+ </p>
+
+ <p>
+ <strong>Mn:</strong>
+ <%= crop.mn %>
+ </p>
+
+ <p>
+ <strong>Cu:</strong>
+ <%= crop.cu %>
+ </p>
+
+ <p>
+ <strong>Mo:</strong>
+ <%= crop.mo %>
+ </p>
+
+ <p>
+ <strong>Nnh4:</strong>
+ <%= crop.nnh4 %>
+ </p>
+
+</div>
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? %>
+ <div style="color: red">
+ <h2><%= pluralize(crop.errors.count, "error") %> prohibited this crop from being saved:</h2>
+
+ <ul>
+ <% crop.errors.each do |error| %>
+ <li><%= error.full_message %></li>
+ <% end %>
+ </ul>
+ </div>
+ <% end %>
+
+ <div>
+ <%= form.label :name, style: "display: block" %>
+ <%= form.text_field :name %>
+ </div>
+
+ <div>
+ <%= form.label :crop_type, style: "display: block" %>
+ <%= form.number_field :crop_type %>
+ </div>
+
+ <div>
+ <%= form.label :nno3, style: "display: block" %>
+ <%= form.text_field :nno3 %>
+ </div>
+
+ <div>
+ <%= form.label :p, style: "display: block" %>
+ <%= form.text_field :p %>
+ </div>
+
+ <div>
+ <%= form.label :k, style: "display: block" %>
+ <%= form.text_field :k %>
+ </div>
+
+ <div>
+ <%= form.label :ca, style: "display: block" %>
+ <%= form.text_field :ca %>
+ </div>
+
+ <div>
+ <%= form.label :mg, style: "display: block" %>
+ <%= form.text_field :mg %>
+ </div>
+
+ <div>
+ <%= form.label :s, style: "display: block" %>
+ <%= form.text_field :s %>
+ </div>
+
+ <div>
+ <%= form.label :na, style: "display: block" %>
+ <%= form.text_field :na %>
+ </div>
+
+ <div>
+ <%= form.label :cl, style: "display: block" %>
+ <%= form.text_field :cl %>
+ </div>
+
+ <div>
+ <%= form.label :si, style: "display: block" %>
+ <%= form.text_field :si %>
+ </div>
+
+ <div>
+ <%= form.label :fe, style: "display: block" %>
+ <%= form.text_field :fe %>
+ </div>
+
+ <div>
+ <%= form.label :zn, style: "display: block" %>
+ <%= form.text_field :zn %>
+ </div>
+
+ <div>
+ <%= form.label :b, style: "display: block" %>
+ <%= form.text_field :b %>
+ </div>
+
+ <div>
+ <%= form.label :mn, style: "display: block" %>
+ <%= form.text_field :mn %>
+ </div>
+
+ <div>
+ <%= form.label :cu, style: "display: block" %>
+ <%= form.text_field :cu %>
+ </div>
+
+ <div>
+ <%= form.label :mo, style: "display: block" %>
+ <%= form.text_field :mo %>
+ </div>
+
+ <div>
+ <%= form.label :nnh4, style: "display: block" %>
+ <%= form.text_field :nnh4 %>
+ </div>
+
+ <div>
+ <%= form.submit %>
+ </div>
+<% 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" %>
+
+<h1>Editing crop</h1>
+
+<%= render "form", crop: @crop %>
+
+<br>
+
+<div>
+ <%= link_to "Show this crop", @crop %> |
+ <%= link_to "Back to crops", crops_path %>
+</div>
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 @@
+<p style="color: green"><%= notice %></p>
+
+<% content_for :title, "Crops" %>
+
+<h1>Crops</h1>
+
+<div id="crops">
+ <% @crops.each do |crop| %>
+ <%= render crop %>
+ <p>
+ <%= link_to "Show this crop", crop %>
+ </p>
+ <% end %>
+</div>
+
+<%= 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" %>
+
+<h1>New crop</h1>
+
+<%= render "form", crop: @crop %>
+
+<br>
+
+<div>
+ <%= link_to "Back to crops", crops_path %>
+</div>
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 @@
+<p style="color: green"><%= notice %></p>
+
+<%= render @crop %>
+
+<div>
+ <%= link_to "Edit this crop", edit_crop_path(@crop) %> |
+ <%= link_to "Back to crops", crops_path %>
+
+ <%= button_to "Destroy this crop", @crop, method: :delete %>
+</div>
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 @@
+<div class="card shadow mb-4">
+ <div class="card-header d-flex justify-content-between align-items-center">
+ <h5 class="mb-0">Raft Allocation</h5>
+ <%= link_to "Edit raft allocation", editor_rafts_path, class: "btn btn-sm btn-primary" %>
+ </div>
+
+ <%= bar_chart @raft_data, stacked: true %>
+</div>
+
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 @@
+<div class="card shadow mb-4">
+ <div class="card-header d-flex justify-content-between align-items-center">
+ <h5 class="mb-0">Nutrient Measurements</h5>
+ <div class="btn-group">
+ <%#= 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" %>
+ </div>
+ </div>
+
+ <div class="card-body p-0">
+ <div class="container mb-3">
+ <%= line_chart @npk_measurement_data,
+ title: "NPK",
+ ytitle: "Concentration (mg/L)" %>
+ </div>
+ <div class="container mb-3">
+ <%= line_chart @ammonium_measurement_data,
+ title: "Ammonium",
+ ytitle: "Concentration (mg/L)" %>
+ </div>
+ </div>
+</div>
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 @@
+<div class="card shadow mb-4">
+ <div class="card-header d-flex justify-content-between align-items-center">
+ <h5 class="mb-0">Target nutrient concentrations</h5>
+ <%= link_to "Get Ferti© recipe", ferti_recipe_path, class: "btn btn-sm btn-primary" %>
+ </div>
+
+ <div class="table-responsive">
+ <table class="table table-sm align-middle mb-0">
+ <thead class="table-light">
+ <tr>
+ <th scope="col" class="text-nowrap">Nutrient</th>
+ <th scope="col" class="text-end"> Latest (mg/L)
+ </th>
+ <th scope="col" class="text-end">Target (mg/L)</th>
+ <th scope="col" class="text-end">Δ %</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% NutrientsHelper::NUTRIENTS.each do |n| %>
+ <% latest = @latest_measurement[n] || 0 %>
+ <% target = @target[n] || 0 %>
+ <% delta = target - latest %>
+ <tr>
+ <th scope="row" class="text-nowrap"><%= n.upcase %></th>
+ <td class="text-end"><%= fmt(latest) %></td>
+ <td class="text-end"><%= fmt(target) %></td>
+ <td class="text-end">
+ <span class="badge <%= delta_badge_class(delta) %>">
+ <%= fmt(delta) %>%
+ </span>
+ </td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+ </div>
+ <div class="card-footer">
+ Latest measurement: <%= @latest_measurement.measured_on || "none yet" %>
+ </div>
+</div>
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 @@
+<h1 class="display-1">Ferti</h1>
+
+<%= 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 @@
+<div id="<%= dom_id fertilizer_product %>">
+ <p>
+ <strong>Name:</strong>
+ <%= fertilizer_product.name %>
+ </p>
+
+ <p>
+ <strong>Purity:</strong>
+ <%= fertilizer_product.purity %>
+ </p>
+
+</div>
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? %>
+ <div style="color: red">
+ <h2><%= pluralize(fertilizer_product.errors.count, "error") %> prohibited this fertilizer_product from being saved:</h2>
+
+ <ul>
+ <% fertilizer_product.errors.each do |error| %>
+ <li><%= error.full_message %></li>
+ <% end %>
+ </ul>
+ </div>
+ <% end %>
+
+ <div>
+ <%= form.label :name, style: "display: block" %>
+ <%= form.text_field :name %>
+ </div>
+
+ <div>
+ <%= form.label :purity, style: "display: block" %>
+ <%= form.text_field :purity %>
+ </div>
+
+ <div>
+ <%= form.submit %>
+ </div>
+<% 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" %>
+
+<h1>Editing fertilizer product</h1>
+
+<%= render "form", fertilizer_product: @fertilizer_product %>
+
+<br>
+
+<div>
+ <%= link_to "Show this fertilizer product", @fertilizer_product %> |
+ <%= link_to "Back to fertilizer products", fertilizer_products_path %>
+</div>
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 @@
+<p style="color: green"><%= notice %></p>
+
+<% content_for :title, "Fertilizer products" %>
+
+<h1>Fertilizer products</h1>
+
+<div id="fertilizer_products">
+ <% @fertilizer_products.each do |fertilizer_product| %>
+ <%= render fertilizer_product %>
+ <p>
+ <%= link_to "Show this fertilizer product", fertilizer_product %>
+ </p>
+ <% end %>
+</div>
+
+<%= 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" %>
+
+<h1>New fertilizer product</h1>
+
+<%= render "form", fertilizer_product: @fertilizer_product %>
+
+<br>
+
+<div>
+ <%= link_to "Back to fertilizer products", fertilizer_products_path %>
+</div>
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 @@
+<p style="color: green"><%= notice %></p>
+
+<%= render @fertilizer_product %>
+
+<div>
+ <%= 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 %>
+</div>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title><%= content_for(:title) || "Ferti" %></title>
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <meta name="apple-mobile-web-app-capable" content="yes">
+ <meta name="mobile-web-app-capable" content="yes">
+ <%= 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) %>
+
+ <link rel="icon" href="/icon.png" type="image/png">
+ <link rel="icon" href="/icon.svg" type="image/svg+xml">
+ <link rel="apple-touch-icon" href="/icon.png">
+
+ <%# Includes all stylesheet files in app/assets/stylesheets %>
+ <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
+
+ <!-- Bootstrap 5 CSS -->
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
+
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+ <script src="https://cdn.jsdelivr.net/npm/chartkick@5"></script>
+
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
+
+ <%= javascript_importmap_tags %>
+ </head>
+
+ <body>
+ <%= render "application/navbar" %>
+
+ <div class="container">
+ <%= yield %>
+ </div>
+
+ <%= render "application/copyright_footer" %>
+
+ <!-- Bootstrap JS (optional, for dropdowns, etc.) -->
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <style>
+ /* Email styles need to be inline */
+ </style>
+ </head>
+
+ <body>
+ <%= yield %>
+ </body>
+</html>
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 @@
+<h1>NutrientMeasurement#index</h1>
+<p>Find me in app/views/nutrient_measurement/index.html.erb</p>
+
+<div class="table-responsive">
+ <table class="table table-sm table-striped table-hover align-middle table-nutrient mb-0">
+ <thead class="table-light">
+ <tr>
+ <th>Date</th>
+ <th class="numeric" title="Total N = NO₃‑N + NH₄‑N">N (total)</th>
+ <th class="numeric">P</th>
+ <th class="numeric">K</th>
+ <th class="numeric" title="Ammonia nitrogen">NH₄‑N</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% @measurements.each do |m| %>
+ <tr>
+ <td><%= l(m.measured_on) %></td>
+ <td class="numeric"><%= fmt2(total_n(m)) %></td>
+ <td class="numeric"><%= fmt2(m.p) %></td>
+ <td class="numeric"><%= fmt2(m.k) %></td>
+ <td class="numeric"><%= fmt2(m.nnh4) %></td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+</div>
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 @@
+<div class="d-flex justify-content-between align-items-center mb-3">
+ <h1 class="display-1">Rafts editor</h1>
+ <%= link_to "Back to dashboard", root_path, class: "btn btn-outline-secondary" %>
+</div>
+
+<!-- Bulk assign all rafts -->
+<div class="card">
+ <div class="card-header">Assign ALL rafts</div>
+ <div class="card-body">
+ <%= form_with url: assign_all_rafts_path, method: :patch, class: "row g-2", data: { turbo: false } do %>
+ <div class="col-12 col-md-6">
+ <select class="form-select" name="crop_id">
+ <option value="">— Unassigned (clear) —</option>
+ <% @crops.each do |crop| %>
+ <option value="<%= crop.id %>"><%= crop.name %></option>
+ <% end %>
+ </select>
+ </div>
+ <div class="col-12 col-md-auto">
+ <button class="btn btn-primary">Apply to all</button>
+ </div>
+ <% end %>
+ </div>
+</div>
+
+<div class="table-responsive">
+ <table class="table table-sm table-striped text-center align-middle my-3">
+ <thead>
+ <tr>
+ <th class="text-start">Bed</th>
+ <% max_cols = @beds.map { |b| b.rafts.count }.max %>
+ <% (1..max_cols).each do |i| %>
+ <th>R<%= i %></th>
+ <% end %>
+ </tr>
+ </thead>
+ <tbody>
+ <% @beds.each do |bed| %>
+ <tr>
+ <th><%= bed.location %></th>
+ <% bed.rafts.order(:location).each do |raft| %>
+ <td>
+ <% if raft.crop %>
+ <%= raft.crop.name %>
+ <% else %>
+ —
+ <% end %>
+ </td>
+ <% end %>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+</div>
+
+<!-- Per-bed tables -->
+<% @beds.each do |bed| %>
+ <div class="card mb-3" id="bed-<%= bed.id %>">
+ <div class="card-header d-flex flex-wrap gap-2 align-items-center">
+ <span class="me-auto">Bed <strong>#<%= bed.location %></strong></span>
+ <%= form_with url: assign_bed_rafts_path, method: :patch, class: "d-flex gap-2 align-items-center", data: { turbo: false } do %>
+ <input type="hidden" name="bed_id" value="<%= bed.id %>">
+ <select class="form-select" name="crop_id">
+ <option value="">— Unassigned (clear) —</option>
+ <% @crops.each do |crop| %>
+ <option value="<%= crop.id %>"><%= crop.name %></option>
+ <% end %>
+ </select>
+ <button class="btn btn-primary">Apply</button>
+ <% end %>
+ </div>
+ <div class="card-body p-0">
+ <div class="table-responsive">
+ <table class="table table-sm mb-0 align-middle">
+ <thead class="table-light">
+ <tr>
+ <th class="text-nowrap text-center">Raft</th>
+ <th>Crop</th>
+ <th class="text-end">Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% bed.rafts.order(:location).each do |raft| %>
+ <tr>
+ <td class="text-center"><strong><%= raft.location %></strong></td>
+ <td style="max-width: 280px;">
+ <%= form_with url: assign_one_raft_path(raft), method: :patch, class: "d-flex gap-2", data: { turbo: false } do %>
+ <select class="form-select" name="crop_id">
+ <option value="">— Unassigned (clear) —</option>
+ <% @crops.each do |crop| %>
+ <option value="<%= crop.id %>" <%= "selected" if raft.crop_id == crop.id %>><%= crop.name %></option>
+ <% end %>
+ </select>
+ <button class="btn btn-outline-primary">Save</button>
+ <% end %>
+ </td>
+ <td class="text-end">
+ <%= button_to "Clear", assign_one_raft_path(raft, crop_id: ""), method: :patch,
+ form: { data: { turbo: false } }, class: "btn btn-outline-secondary btn-sm" %>
+ </td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+<% 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 @@
+<div class="table-responsive">
+ <table class="table table-sm align-middle mb-0">
+ <thead class="table-light">
+ <tr>
+ <th>Fertilizer product</th>
+ <th class="text-muted">Component</th>
+ <th class="text-end">Qty (kg)</th>
+ <th class="text-end">Per portion (kg)</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% pcount = [[portions.to_i, 2].max, 10].min %>
+ <% recipe&.each do |component, kg| %>
+ <% prod_name = commercial_name_for(component) %>
+ <tr>
+ <td><strong><%= prod_name %></strong></td>
+ <td class="text-muted"><small><%= component.name %></small></td>
+ <td class="text-end fw-semibold"><%= fmt_kg(kg) %></td>
+ <td class="text-end"><%= fmt_kg(kg / pcount.to_f) %></td>
+ </tr>
+ <% end %>
+ <% if recipe.blank? %>
+ <tr><td colspan="4" class="text-center text-muted">No supplementation required.</td></tr>
+ <% end %>
+ </tbody>
+ </table>
+</div>
+
+<div class="card-footer small text-muted">
+ Volume: <%= number_with_delimiter(volume) %> L —
+ Portions: <%= pcount %>
+</div>
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 @@
+<!-- app/views/recipes/show.html.erb -->
+<%# Controls header %>
+<div class="card shadow my-4">
+ <div class="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
+ <h5 class="mb-0">Ferti© Recipe</h5>
+
+ <div class="d-flex flex-wrap gap-2 align-items-center">
+ <%= form_with url: ferti_recipe_path, method: :get, local: true, class: "d-flex flex-wrap gap-2 align-items-center", id: "recipe-form" do %>
+ <div class="d-flex align-items-center gap-2">
+ <label class="mb-0 small text-nowrap" for="volume_select">Volume</label>
+ <select id="volume_select" name="volume" class="form-select form-select-sm">
+ <% options = [["100 000 L", 100_000], ["200 000 L", 200_000], ["300 000 L", 300_000]] %>
+ <% current_volume = (params[:volume].presence || 100_000).to_i %>
+ <% options.each do |label, val| %>
+ <option value="<%= val %>" <%= 'selected' if val == current_volume %>><%= label %></option>
+ <% end %>
+ </select>
+ </div>
+
+ <div class="vr" style="height: 24px;"></div>
+
+ <div class="d-flex align-items-center gap-2">
+ <label class="mb-0 small text-nowrap" for="portions_input">Portions</label>
+ <input id="portions_input" type="number" min="2" max="10" step="1"
+ class="form-control form-control-sm" value="<%= params[:portions].presence || 2 %>">
+ </div>
+ <% end %>
+
+ <%= link_to "Back", root_path, class: "btn btn-sm btn-secondary" %>
+ </div>
+ </div>
+
+ <div class="table-responsive">
+ <table class="table table-sm align-middle mb-0" id="recipe-table">
+ <thead class="table-light">
+ <tr>
+ <th>Product</th>
+ <th class="text-muted">Fertilizer</th>
+ <th class="text-end">Qty (kg)</th>
+ <th class="text-end">Per portion (kg)</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% @recipe.each do |component, kg| %>
+ <% prod_name = commercial_name_for(component) %>
+ <tr data-total-kg="<%= kg %>">
+ <td><strong><%= prod_name %></strong></td>
+ <td class="text-muted"><small><%= component.name %></small></td>
+ <td class="text-end fw-semibold"><%= fmt_kg(kg) %></td>
+ <td class="text-end per-portion-cell"><%= fmt_kg(kg / (params[:portions].presence || 2).to_f) %></td>
+ </tr>
+ <% end %>
+ <% if @recipe.blank? %>
+ <tr><td colspan="4" class="text-center text-muted">No supplementation required.</td></tr>
+ <% end %>
+ </tbody>
+ </table>
+ </div>
+
+ <div class="card-footer small text-muted">
+ Recipe based on latest measurement: <%= @latest.measured_on %>
+ </div>
+</div>
+
+<%# Tiny vanilla JS: auto-submit on volume change; live per-portion update %>
+<script>
+ (function() {
+ const form = document.getElementById('recipe-form');
+ const volumeSelect = document.getElementById('volume_select');
+ const portionsInput = document.getElementById('portions_input');
+ const rows = document.querySelectorAll('#recipe-table tbody tr[data-total-kg]');
+
+ function clampPortions(n) {
+ n = parseInt(n || 2, 10);
+ if (isNaN(n)) n = 2;
+ return Math.min(10, Math.max(2, n));
+ }
+
+ function updatePerPortion() {
+ const n = clampPortions(portionsInput.value);
+ portionsInput.value = n;
+ rows.forEach(row => {
+ const total = parseFloat(row.getAttribute('data-total-kg') || '0');
+ const cell = row.querySelector('.per-portion-cell');
+ const per = (n > 0) ? (total / n) : 0;
+ cell.textContent = formatKg(per);
+ });
+ }
+
+ function formatKg(v) {
+ // match Rails number_with_precision(... precision: 2, strip zeros-ish)
+ const s = (Math.round(v * 100) / 100).toFixed(2);
+ return s.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
+ }
+
+ volumeSelect.addEventListener('change', () => {
+ // submit with selected volume, keeping portions as a query param
+ const portions = clampPortions(portionsInput.value);
+ const url = new URL(form.action, window.location.origin);
+ url.searchParams.set('volume', volumeSelect.value);
+ url.searchParams.set('portions', portions);
+ window.location.assign(url.toString());
+ });
+
+ portionsInput.addEventListener('input', updatePerPortion);
+ updatePerPortion();
+ })();
+</script>
Copyright 2019--2025 Marius PETER