1
0

WIP: Targets configurable with Quantities

This commit is contained in:
cryptogopher 2021-02-07 11:02:41 +01:00
parent 316005bf1f
commit 8b17b33603
23 changed files with 218 additions and 108 deletions

View File

@ -28,19 +28,19 @@ class BodyTrackersController < ApplicationController
quantities_count = available_quantities.length
defaults = Quantity.defaults
Quantity.each_with_path(defaults) do |q, path|
unless available_quantities.has_key?(path)
attrs = q.attributes.except('id', 'project_id', 'parent_id', 'lft', 'rgt',
'created_at', 'updated_at')
if q.parent
attrs['parent'] = available_quantities[path.rpartition('::').first]
end
if q.formula
attrs['formula_attributes'] = q.formula.attributes
.except('id', 'quantity_id', 'unit_id', 'created_at', 'updated_at')
attrs['formula_attributes']['unit_id'] = available_units[q.formula.unit.shortname]
end
available_quantities[path] = @project.quantities.build(attrs)
next if available_quantities.has_key?(path)
attrs = q.attributes.except('id', 'project_id', 'parent_id', 'lft', 'rgt', 'depth',
'created_at', 'updated_at')
if q.parent
attrs['parent'] = available_quantities[path.rpartition('::').first]
end
if q.formula
attrs['formula_attributes'] = q.formula.attributes
.except('id', 'quantity_id', 'unit_id', 'created_at', 'updated_at')
attrs['formula_attributes']['unit_id'] = available_units[q.formula.unit&.shortname]
end
available_quantities[path] = @project.quantities.build(attrs)
end
Quantity.transaction do
failed_objects += available_quantities.values.reject { |o| o.persisted? || o.save }

View File

@ -6,6 +6,7 @@ class TargetsController < ApplicationController
include Concerns::Finders
before_action :find_binding_goal_by_project_id, only: [:index, :new, :edit]
before_action :find_project, only: [:subthresholds]
before_action :find_project_by_project_id, only: [:create]
before_action :find_quantity_by_quantity_id, only: [:toggle_exposure]
#, if: ->{ params[:project_id].present? }
@ -21,8 +22,9 @@ class TargetsController < ApplicationController
def new
target = @goal.targets.new
target.arity.times { target.thresholds.new }
target.thresholds.new(quantity: Quantity.target.roots.last)
@targets = [target]
@effective_from = target.effective_from
end
def create
@ -75,6 +77,15 @@ class TargetsController < ApplicationController
prepare_targets
end
def subthresholds
quantity_id = params[:goal][:targets_attributes]
.last[:thresholds_attributes][:quantity_id]
return if quantity_id.blank?
quantity = @project.quantities.find(quantity_id)
@threshold = Threshold.new(quantity: quantity)
end
private
def goal_params

View File

@ -36,17 +36,18 @@ module BodyTrackersHelper
options_for_select(options, disabled: 0)
end
def quantity_options(domain = :all)
def quantity_options(domain = :except_targets)
Quantity.each_with_ancestors(@project.quantities.send(domain)).map do |ancestors|
quantity = ancestors.last
[
raw("#{'&ensp;' * (ancestors.length-2)}#{quantity.name}"),
raw('&ensp;'*(ancestors.length-2) + quantity.name),
quantity.id,
{'data-path' => ancestors[1..-2].reduce('::') { |m, q| "#{m}#{q.try(:name)}::" }}
]
end
end
# TODO: replace with collection_select and remove
def unit_options
@project.units.map do |u|
[u.shortname, u.id]

View File

@ -1,8 +1,4 @@
module TargetsHelper
def condition_options
Target::CONDITIONS
end
def action_links(d)
link_to(l(:button_reapply), reapply_project_targets_path(@project, d, @view_params),
{remote: true, class: "icon icon-reload"}) +

View File

@ -1,6 +1,7 @@
class Formula < ActiveRecord::Base
include BodyTracking::FormulaBuilder
# NOTE: check if model_deps used and merge with quantity_deps if not
attr_reader :parts, :quantity_deps, :model_deps
belongs_to :quantity, inverse_of: :formula, required: true
@ -107,18 +108,23 @@ class Formula < ActiveRecord::Base
end
quantities = []
identifiers.reject! do |i|
if q_paths.has_key?(i)
q = q_paths[i]
q.nil? ? errors << [:ambiguous_dependency, {identifier: i}] : quantities << q
models = []
identifiers.each do |i|
case
when q_paths.has_key?(i)
if q_paths[i].nil?
errors << [:ambiguous_dependency, {identifier: i}]
else
quantities << q_paths[i]
end
when quantity.target? && (i.casecmp('Value') == 0)
when model = i.safe_constantize
models << model
else
errors << [:unknown_dependency, {identifier: i}]
end
end
models = identifiers.map(&:safe_constantize).compact || []
(identifiers - models.map(&:class_name)).each do |i|
errors << [:unknown_dependency, {identifier: i}]
end
@parts, @quantity_deps, @model_deps = parts, quantities, models if errors.empty?
errors
end

View File

@ -1,11 +1,13 @@
class Goal < ActiveRecord::Base
belongs_to :project, required: true
has_many :targets, -> { order "effective_from DESC" }, inverse_of: :goal,
has_many :targets, -> { order effective_from: :desc }, inverse_of: :goal,
dependent: :destroy, extend: BodyTracking::ItemsWithQuantities
has_many :target_exposures, as: :view, dependent: :destroy,
class_name: 'Exposure', extend: BodyTracking::TogglableExposures
has_many :quantities, -> { order "lft" }, through: :target_exposures
accepts_nested_attributes_for :targets, allow_destroy: true,
reject_if: proc { |attrs| attrs['quantity_id'].blank? }
validates :target_exposures, presence: true, unless: :is_binding?
validates :is_binding, uniqueness: {scope: :project_id}, if: :is_binding?
validates :name, presence: true, uniqueness: {scope: :project_id},

View File

@ -2,18 +2,21 @@ class Quantity < ActiveRecord::Base
enum domain: {
diet: 0,
measurement: 1,
exercise: 2
exercise: 2,
target: 3
}
acts_as_nested_set dependent: :destroy, scope: :project
belongs_to :project, inverse_of: :quantities, required: false
has_many :nutrients, dependent: :restrict_with_error
has_many :readouts, dependent: :restrict_with_error
has_many :targets, dependent: :restrict_with_error
has_many :thresholds, dependent: :restrict_with_error
has_many :values, class_name: 'QuantityValue', dependent: :restrict_with_error
has_many :exposures, dependent: :destroy
scope :defaults, -> { where(project: nil) }
scope :except_targets, -> { where.not(domain: :target) }
has_one :formula, inverse_of: :quantity, dependent: :destroy, validate: true
accepts_nested_attributes_for :formula, allow_destroy: true,

View File

@ -1,37 +1,37 @@
class Target < ActiveRecord::Base
CONDITIONS = ['<', '<=', '>', '>=', '==']
belongs_to :goal, inverse_of: :targets, required: true
belongs_to :quantity, inverse_of: :targets, required: true
belongs_to :item, polymorphic: true, inverse_of: :targets
has_many :thresholds, as: :registry, inverse_of: :target, dependent: :destroy,
validate: true
has_many :thresholds, -> { joins(:quantity).order(:lft) },
as: :registry, inverse_of: :target, dependent: :destroy, validate: true
validates :thresholds, presence: true
accepts_nested_attributes_for :thresholds, allow_destroy: true,
reject_if: proc { |attrs| attrs['quantity_id'].blank? && attrs['value'].blank? }
validate do
errors.add(:thresholds, :count_mismatch) unless thresholds.length == arity
errors.add(:thresholds, :quantity_mismatch) if thresholds.to_a.uniq(&:quantity).length != 1
quantities = thresholds.map(&:quantity)
ancestors = quantities.max_by(:lft).self_and_ancestors
errors.add(:thresholds, :count_mismatch) unless quantities.length == ancestors.length
errors.add(:thresholds, :quantity_mismatch) unless quantities == ancestors
end
validates :condition, inclusion: {in: CONDITIONS}
validates :scope, inclusion: {in: [:ingredient, :meal, :day],
if: -> { thresholds.first.quantity.domain == :diet }}
validates :scope, inclusion: {in: [:ingredient, :meal, :day], if: -> { quantity.diet? }}
validates :effective_from, presence: {if: :is_binding?}, absence: {unless: :is_binding?}
after_initialize do
if new_record?
self.condition ||= CONDITIONS.first
# Target should be only instantiated through Goal, so :is_binding? will be available
self.effective_from ||= Date.current if is_binding?
end
end
delegate :is_binding?, to: :goal
# NOTE: remove if not used in controller
def arity
BigDecimal.method(condition).arity
thresholds.size
end
def to_s
"#{condition} #{thresholds.first.value} [#{thresholds.first.unit.shortname}]"
thresholds.last.quantity.description %
thresholds.map { |t| [t.quantity.name, "#{t.value} [#{t.unit.shortname}]"] }.to_h
end
end

View File

@ -1,21 +1,20 @@
<label for="goal_id"><%= l(:field_goal) %><span class="required"> *</span></label>
<%= select_tag :goal_id,
options_from_collection_for_select(@project.goals, :id, :name, @goal.id),
required: true, autocomplete: 'off',
onchange: "var goal_id = $('#target_goal_attributes_id').val();
$.ajax({
url: '#{goal_path(id: :goal_id)}'.replace('goal_id', goal_id),
<%= goal_f.collection_select :id, @project.goals, :id, :name,
{include_blank: false, required: true, label: :field_goal},
autocomplete: 'off',
onchange: "$.ajax({
url: '#{goal_path(id: :goal_id)}'
.replace('goal_id', $('#target_goal_attributes_id').val()),
dataType: 'script'
});
return false;" %>
<%= link_to l(:button_add), '#',
onclick: "var goal_id = $('#target_goal_attributes_id').val();
$.ajax({
url: '#{edit_goal_path(id: :goal_id)}'.replace('goal_id', goal_id),
onclick: "$.ajax({
url: '#{edit_goal_path(id: :goal_id)}'
.replace('goal_id', $('#target_goal_attributes_id').val()),
dataType: 'script'
});
return false;",
class: 'icon icon-add' %>
<% if @goal.description? %>
<p style='white-space: pre-wrap;' ><%= @goal.description %></p>
<% if goal_f.object.description? %>
<p style='white-space: pre-wrap;' ><%= goal_f.object.description %></p>
<% end %>

View File

@ -5,8 +5,7 @@
{autocomplete: 'off',
data: {remote: true,
url: parents_project_quantities_path(@project),
params: "form=#{f.options[:html][:id]}"}}
%></p>
params: "form=#{f.options[:html][:id]}"}} %></p>
<p><%= f.select :parent_id, parent_options(@quantity.domain),
{required: true, label: :field_parent_quantity, include_blank: t('.null_parent')} %></p>
<p><%= f.text_field :name, size: 25, required: true %></p>

View File

@ -2,9 +2,10 @@
<div class="box">
<div id='goal-form' class="tabular">
<% if @goal.persisted? %>
<p><%= render partial: 'goals/show_form' %></p>
<p><%= f.date_field :effective_from, disabled: !@goal.is_binding? %></p>
<% if goal_f.object.persisted? %>
<p><%= render partial: 'goals/show_form', locals: {goal_f: goal_f} %></p>
<p><%= goal_f.date_field :effective_from, value: @effective_from,
disabled: !goal_f.object.is_binding? %></p>
<% else %>
<%= render partial: 'goals/form' %>
<% end %>
@ -13,34 +14,30 @@
<hr style="width: 95%;">
<div class="tabular">
<% @targets.each do |target| %>
<p class="target">
<%= f.fields_for 'targets', target, index: '' do |target_f| %>
<%= target_f.hidden_field :id %>
<%= target_f.hidden_field :_destroy %>
<em class="info"><%= t ".choose_quantity" %></em>
<% target.thresholds.each_with_index do |thr, index| %>
<%= target_f.fields_for 'thresholds_attributes', thr, index: '' do |threshold_f| %>
<%= threshold_f.hidden_field :id %>
<% if index == 0 %>
<%= threshold_f.select :quantity_id, quantity_options,
{include_blank: true, required: true, label: :field_target},
onchange: "showQuantityPath(event);" %>
<%= target_f.select :condition, condition_options, required: true,
label: '' %>
<% end %>
<%= threshold_f.number_field :value, {size: 8, step: :any, label: ''} %>
<% if index == 0 %>
<%= threshold_f.select :unit_id, unit_options, {label: ''} %>
<% end %>
<% end %>
<% end %>
<%= link_to t(".button_delete_target"), '#', class: 'icon icon-del',
style: (@targets.many? ? "" : "display:none"),
onclick: "deleteTarget(); return false;" %>
<p class="target">
<%= goal_f.fields_for :targets, @targets, child_index: '' do |target_f| %>
<%= target_f.hidden_field :_destroy %>
<em class="info"><%= t ".choose_quantity" %></em>
<%= target_f.select :quantity_id, quantity_options,
{include_blank: true, required: true, label: :field_target},
onchange: "showQuantityPath(event);" %>
<%= target_f.fields_for :thresholds do |threshold_f| %>
<%= render partial: 'thresholds/form', locals: {threshold_f: threshold_f} %>
<% end %>
</p>
<% last_quantity = target_f.object.thresholds.last.quantity %>
<% unless last_quantity.leaf? %>
<%= target_f.fields_for 'thresholds_attributes', Threshold.new do |threshold_f| %>
<%= render partial: 'thresholds/form',
locals: {threshold_f: threshold_f, parent: last_quantity} %>
<% end %>
<% end %>
<%= link_to t(".button_delete_target"), '#', class: 'icon icon-del',
style: (@targets.many? ? "" : "display:none"),
onclick: "deleteTarget(); return false;" %>
<% end %>
</p>
<p>
<%= link_to t(".button_new_target"), '#', class: 'icon icon-add',
onclick: 'newTarget(); return false;' %>
@ -50,10 +47,11 @@
<%= javascript_tag do %>
function showQuantityPath(event) {
$(event.target).prevAll('em').text($('option:selected', event.target).attr('data-path'));
$(event.target).prevAll('em').text($('option:selected', event.target)
.attr('data-path'));
}
$(document).ajaxComplete(function() {
$('select[id$=__quantity_id]').trigger(jQuery.Event('change'));
$('p.target select:first-child[id$=__quantity_id]').trigger(jQuery.Event('change'));
})
function newTarget() {

View File

@ -1,11 +1,11 @@
<h2><%= t ".heading_new_target" %></h2>
<%= labelled_form_for @targets,
<%= labelled_form_for @goal,
url: project_targets_path(@project, @view_params),
remote: true,
html: {id: 'new-target-form', name: 'new-target-form'} do |f| %>
html: {id: 'new-target-form', name: 'new-target-form'} do |goal_f| %>
<%= render partial: 'targets/form', locals: {f: f} %>
<%= render partial: 'targets/form', locals: {goal_f: goal_f} %>
<div class="tabular">
<p>

View File

@ -0,0 +1,5 @@
<% if @threshold %>
$('#targets').html('<%= j render partial: 'targets/index' %>');
<% else %>
$(event.target).empty();
<% end %>

View File

@ -0,0 +1,15 @@
<% threshold_q = threshold_f.object.quantity %>
<% parent_id = defined?(parent) ? parent.id : threshold_q.parent_id %>
<%= threshold_f.collection_select :quantity_id,
@project.quantities.target.children_of(parent_id),
:id, :name,
{prompt: parent_id.nil? ? false : '.', required: true, no_label: true},
{autocomplete: 'off', data: {remote: true,
url: subthresholds_quantity_path(@project)}} %>
<% unless threshold_q.nil? %>
<%= threshold_f.hidden_field :_destroy %>
<%= threshold_f.number_field :value, {size: 8, step: :any, no_label: true} %>
<%= threshold_f.collection_select :unit_id, @project.units, :id, :shortname,
{no_label: true} %>
<% end %>

View File

@ -176,9 +176,10 @@ en:
link_new_quantity: 'New quantity'
form:
domains:
diet: 'diet'
measurement: 'measurement'
exercise: 'exercise'
diet: 'diet'
target: 'target'
null_parent: '- none -'
formula_placeholder: 'provide if value of quantity has to be computed in terms of
other quantities'

View File

@ -9,18 +9,20 @@ resources :projects, shallow: true do
end
resources :goals, only: [:show, :edit] do
member do
post 'toggle_exposure', to: 'targets#toggle_exposure'
post 'toggle_exposure', controller: :targets
end
end
resources :targets, except: [:show, :edit, :update] do
collection do
get 'edit/:date', to: 'targets#edit', as: :edit
get 'edit/:date', action: :edit, as: :edit
patch :update
post 'reapply/:date', to: 'targets#reapply', as: :reapply
post 'reapply/:date', action: :reapply, as: :reapply
end
end
resources :ingredients, only: [] do
post 'adjust/:adjustment', to: 'meals#adjust', as: :adjust, on: :member
member do
post 'adjust/:adjustment', controller: :meals, action: :adjust, as: :adjust
end
end
resources :meals, except: [:show] do
member do
@ -34,8 +36,8 @@ resources :projects, shallow: true do
end
resources :measurement_routines, only: [:show, :edit] do
member do
get 'readouts', to: 'measurements#readouts'
post 'toggle_exposure', to: 'measurements#toggle_exposure'
get 'readouts', controller: :measurements
post 'toggle_exposure', controller: :measurements
end
end
resources :measurements, except: [:show] do
@ -47,7 +49,9 @@ resources :projects, shallow: true do
end
end
resources :foods, except: [:show] do
post 'toggle', on: :member
member do
post 'toggle'
end
collection do
get 'nutrients'
post 'toggle_exposure'
@ -60,8 +64,9 @@ resources :projects, shallow: true do
resources :quantities, except: [:show] do
member do
get 'new_child'
get 'subthresholds', controller: :targets
post 'create_child'
post 'move/:direction', to: 'quantities#move', as: :move
post 'move/:direction', action: :move, as: :move
end
collection do
get 'parents'

View File

@ -10,6 +10,8 @@ class CreateSchema <
t.references :parent
t.integer :lft, null: false, index: true
t.integer :rgt, null: false, index: true
# TODO: remove depth (seems to be replaceable by lft)
t.integer :depth, null: false, default: 0
t.timestamps null: false
end
@ -105,8 +107,8 @@ class CreateSchema <
create_table :targets do |t|
t.references :goal
t.references :quantity
t.references :item, polymorphic: true
t.string :condition
t.string :scope
t.date :effective_from
t.timestamps null: false

View File

@ -168,6 +168,18 @@ b_ac = Quantity.create name: "RM", domain: :measurement, p
b_ad = Quantity.create name: "VF", domain: :measurement, parent: b_a,
description: "Visceral fat"
# -> Target conditions
t_a = Quantity.create name: "below", domain: :target, parent: nil,
description: "Upper bound"
t_b = Quantity.create name: "above", domain: :target, parent: nil,
description: "Lower bound"
t_ba = Quantity.create name: "and below", domain: :target, parent: t_b,
description: "Range"
t_c = Quantity.create name: "equal", domain: :target, parent: nil,
description: "Exact value"
t_ca = Quantity.create name: "with accuracy of", domain: :target, parent: t_c,
description: "Point range"
# Formulas go at the and to make sure dependencies exist
e_aa.create_formula zero_nil: true, unit: u_b,
code: "4*Proteins + 9*Fats + 4*Carbs + 2*Fibre"
@ -189,6 +201,13 @@ e_aea.create_formula zero_nil: true, unit: u_c,
b_aaa.create_formula zero_nil: true, unit: u_ac,
code: "'% fat' * Weight"
t_a.create_formula zero_nil: false, code: "value <= below"
t_b.create_formula zero_nil: false, code: "value >= above"
t_ba.create_formula zero_nil: false, code: "(value >= above) && (value <= 'and below')"
t_c.create_formula zero_nil: false, code: "value == equal"
t_ca.create_formula zero_nil: false, code: "(value >= (equal - 'with accuracy of')) && " \
"(value <= (equal + 'with accuracy of'))"
# Sources
s_a = Source.create name: "nutrition label",
description: "nutrition facts taken from package nutrition label"

View File

@ -28,7 +28,8 @@ Redmine::Plugin.register :body_tracking do
permission :manage_body_trackers, {
body_trackers: [:defaults],
goals: [:edit],
targets: [:new, :create, :edit, :update, :destroy, :reapply, :toggle_exposure],
targets: [:new, :create, :edit, :update, :destroy, :reapply, :toggle_exposure,
:subthresholds],
meals: [:new, :create, :edit, :update, :destroy, :edit_notes, :update_notes,
:toggle_eaten, :toggle_exposure, :adjust],
measurement_routines: [:edit],
@ -36,7 +37,8 @@ Redmine::Plugin.register :body_tracking do
foods: [:new, :create, :edit, :update, :destroy, :toggle, :toggle_exposure,
:import],
sources: [:create, :destroy],
quantities: [:new, :create, :edit, :update, :destroy, :move, :new_child, :create_child],
quantities: [:new, :create, :edit, :update, :destroy, :move, :new_child,
:create_child],
units: [:create, :destroy],
}, require: :loggedin
end

View File

@ -0,0 +1,14 @@
formulas_01:
quantity: quantities_target_above
zero_nil: false
code: value >= above
formulas_02:
quantity: quantities_target_range
zero_nil: false
code: (value >= above) && (value <= 'and below')
formulas_03:
quantity: quantities_target_equal
zero_nil: false
code: value == equal

View File

@ -1,17 +1,49 @@
quantities_energy:
DEFAULTS: &DEFAULTS
project_id: 1
domain: diet
parent: null
quantities_energy:
<<: *DEFAULTS
domain: diet
lft: 1
rgt: 2
depth: 0
name: Energy
description: Total energy
quantities_proteins:
project_id: 1
<<: *DEFAULTS
domain: diet
parent: null
lft: 3
rgt: 4
depth: 0
name: Proteins
description: Total amount of proteins
quantities_target_above:
<<: *DEFAULTS
domain: target
lft: 5
rgt: 8
depth: 0
name: above
description: Lower bound
quantities_target_range:
<<: *DEFAULTS
parent: quantities_target_above
domain: target
lft: 6
rgt: 7
depth: 1
name: and below
description: Range
quantities_target_equal:
<<: *DEFAULTS
domain: target
lft: 9
rgt: 10
depth: 0
name: equal
description: Exact value

View File

@ -2,6 +2,6 @@ quantity_values_001:
type: Threshold
registry_type: Target
registry: targets_01
quantity: quantities_energy
quantity: quantities_target_equal
value: 2500
unit: units_kcal

View File

@ -1,4 +1,4 @@
targets_01:
goal: goals_binding
condition: '=='
quantity: quantities_energy
effective_from: <%= 1.week.ago.to_s(:db) %>