calc_kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +221 -0
  5. data/Rakefile +12 -0
  6. data/calc_kit.gemspec +37 -0
  7. data/lib/calc_kit/base.rb +61 -0
  8. data/lib/calc_kit/configuration.rb +21 -0
  9. data/lib/calc_kit/dsl.rb +99 -0
  10. data/lib/calc_kit/engine.rb +36 -0
  11. data/lib/calc_kit/input_definition.rb +63 -0
  12. data/lib/calc_kit/output_definition.rb +35 -0
  13. data/lib/calc_kit/rails/controller_helpers.rb +76 -0
  14. data/lib/calc_kit/rails/model.rb +48 -0
  15. data/lib/calc_kit/rails/view_helpers.rb +66 -0
  16. data/lib/calc_kit/registry.rb +37 -0
  17. data/lib/calc_kit/version.rb +5 -0
  18. data/lib/calc_kit.rb +42 -0
  19. data/lib/generators/calc_kit/calculator_generator.rb +39 -0
  20. data/lib/generators/calc_kit/install_generator.rb +68 -0
  21. data/lib/generators/calc_kit/templates/application_calculator.rb.tt +13 -0
  22. data/lib/generators/calc_kit/templates/calculation.rb.tt +9 -0
  23. data/lib/generators/calc_kit/templates/calculator.rb.tt +27 -0
  24. data/lib/generators/calc_kit/templates/calculator_test.rb.tt +32 -0
  25. data/lib/generators/calc_kit/templates/create_calculations.rb.tt +22 -0
  26. data/lib/generators/calc_kit/templates/initializer.rb.tt +27 -0
  27. data/lib/generators/calc_kit/templates/views/_form.html.erb +30 -0
  28. data/lib/generators/calc_kit/templates/views/_result.html.erb +17 -0
  29. data/lib/generators/calc_kit/templates/views/_saved_calculations.html.erb +36 -0
  30. data/lib/generators/calc_kit/templates/views/create.turbo_stream.erb +3 -0
  31. data/lib/generators/calc_kit/templates/views/index.html.erb +11 -0
  32. data/lib/generators/calc_kit/templates/views/show.html.erb +13 -0
  33. metadata +132 -0
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ # Controller helpers for calculator actions
5
+ module ControllerHelpers
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ helper_method :calc_kit_scope if respond_to?(:helper_method)
10
+ end
11
+
12
+ private
13
+
14
+ # Find a calculator class by slug, raises if not found
15
+ def find_calculator_class(slug)
16
+ CalcKit.find(slug) || raise(CalcKit::NotFoundError, "Calculator not found: #{slug}")
17
+ end
18
+
19
+ # Build a calculator instance from params
20
+ def build_calculator(calculator_class, params_hash = nil)
21
+ params_hash ||= calculator_params_for(calculator_class)
22
+ calculator_class.new(params_hash)
23
+ end
24
+
25
+ # Extract permitted params for a calculator
26
+ def calculator_params_for(calculator_class)
27
+ permitted = calculator_class.inputs.map(&:name)
28
+ params.permit(*permitted).to_h.reject { |_, v| v.blank? }
29
+ end
30
+
31
+ # Save a calculation result
32
+ def save_calculation(calculator, result, calculation_class: nil)
33
+ return unless CalcKit.configuration.save_calculations
34
+
35
+ calculation_class ||= "Calculation".safe_constantize
36
+ return unless calculation_class
37
+
38
+ scope = calc_kit_scope
39
+ attrs = {
40
+ calculator_type: calculator.class.calculator_slug.to_s,
41
+ calculator_version: calculator.class.version,
42
+ inputs: calculator.input_values,
43
+ outputs: result
44
+ }
45
+
46
+ if scope
47
+ scope.calculations.create!(attrs)
48
+ else
49
+ calculation_class.create!(attrs)
50
+ end
51
+ end
52
+
53
+ # Load saved calculations for a calculator
54
+ def load_saved_calculations(calculator_class, limit: 5, calculation_class: nil)
55
+ return [] unless CalcKit.configuration.save_calculations
56
+
57
+ calculation_class ||= "Calculation".safe_constantize
58
+ return [] unless calculation_class
59
+
60
+ scope = calc_kit_scope
61
+ relation = scope ? scope.calculations : calculation_class
62
+ relation
63
+ .where(calculator_type: calculator_class.calculator_slug.to_s)
64
+ .order(created_at: :desc)
65
+ .limit(limit)
66
+ end
67
+
68
+ # Returns the scope for multi-tenancy (e.g., current_account)
69
+ def calc_kit_scope
70
+ scope_method = CalcKit.configuration.scope_method
71
+ return nil unless scope_method
72
+
73
+ respond_to?(scope_method, true) ? send(scope_method) : nil
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ # Concern for the Calculation model that stores calculation results
5
+ module Model
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ validates :calculator_type, presence: true
10
+ validates :calculator_version, presence: true
11
+ validates :inputs, presence: true
12
+ validates :outputs, presence: true
13
+ end
14
+
15
+ # Returns the calculator class for this calculation
16
+ def calculator_class
17
+ CalcKit.find(calculator_type)
18
+ end
19
+
20
+ # Returns a new calculator instance populated with the saved inputs
21
+ def to_calculator
22
+ klass = calculator_class
23
+ return nil unless klass
24
+
25
+ klass.new(inputs.symbolize_keys)
26
+ end
27
+
28
+ # Check if the calculator version matches the current version
29
+ def version_current?
30
+ klass = calculator_class
31
+ return false unless klass
32
+
33
+ klass.version == calculator_version
34
+ end
35
+
36
+ # Check if there's a version mismatch
37
+ def version_mismatch?
38
+ !version_current?
39
+ end
40
+
41
+ module ClassMethods
42
+ # Scope to filter by calculator type
43
+ def for_calculator(slug)
44
+ where(calculator_type: slug.to_s)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ # View helpers for rendering calculator forms and results
5
+ module ViewHelpers
6
+ # Resolve a default value, calling it if it's a proc
7
+ def calc_kit_resolve_default(default)
8
+ default.respond_to?(:call) ? default.call : default
9
+ end
10
+
11
+ # Format an output value based on its type
12
+ def calc_kit_format_output(value, type)
13
+ return "" if value.nil?
14
+
15
+ case type
16
+ when :date
17
+ value&.respond_to?(:to_fs) ? value.to_fs(:long) : value.to_s
18
+ when :decimal
19
+ if respond_to?(:number_with_precision)
20
+ number_with_precision(value, precision: 2)
21
+ else
22
+ format("%.2f", value.to_f)
23
+ end
24
+ when :integer
25
+ value.to_i.to_s
26
+ when :currency
27
+ if respond_to?(:number_to_currency)
28
+ number_to_currency(value)
29
+ else
30
+ format("$%.2f", value.to_f)
31
+ end
32
+ when :percentage
33
+ if respond_to?(:number_to_percentage)
34
+ number_to_percentage(value, precision: 1)
35
+ else
36
+ format("%.1f%%", value.to_f)
37
+ end
38
+ else
39
+ value.to_s
40
+ end
41
+ end
42
+
43
+ KEYSTONE_TYPE_MAP = {
44
+ string: :text,
45
+ integer: :number,
46
+ decimal: :number,
47
+ date: :date,
48
+ select: :select,
49
+ boolean: :checkbox
50
+ }.freeze
51
+
52
+ # Map CalcKit input type to keystone_ui form field type
53
+ def calc_kit_keystone_type(type)
54
+ KEYSTONE_TYPE_MAP.fetch(type, :text)
55
+ end
56
+
57
+ # Returns a warning string if the calculation was made with a different
58
+ # calculator version, or nil if versions match or warnings are disabled.
59
+ def calc_kit_version_warning(calculation)
60
+ return nil unless CalcKit.configuration.warn_on_version_mismatch
61
+ return nil unless calculation.version_mismatch?
62
+
63
+ "This calculation was made with a different version of the calculator and may have outdated results."
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ # Registry for calculator lookup by slug
5
+ class Registry
6
+ def initialize
7
+ @calculators = {}
8
+ end
9
+
10
+ def register(calculator_class)
11
+ slug = calculator_class.calculator_slug
12
+ raise ArgumentError, "Calculator must define a slug" unless slug
13
+
14
+ @calculators[slug.to_sym] = calculator_class
15
+ end
16
+
17
+ def find(slug)
18
+ @calculators[slug.to_sym]
19
+ end
20
+
21
+ def all
22
+ @calculators.values
23
+ end
24
+
25
+ def slugs
26
+ @calculators.keys
27
+ end
28
+
29
+ def clear
30
+ @calculators.clear
31
+ end
32
+
33
+ def registered?(slug)
34
+ @calculators.key?(slug.to_sym)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ VERSION = "0.1.0"
5
+ end
data/lib/calc_kit.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "calc_kit/version"
4
+ require_relative "calc_kit/configuration"
5
+ require_relative "calc_kit/input_definition"
6
+ require_relative "calc_kit/output_definition"
7
+ require_relative "calc_kit/dsl"
8
+ require_relative "calc_kit/registry"
9
+ require_relative "calc_kit/base"
10
+
11
+ module CalcKit
12
+ class NotFoundError < StandardError; end
13
+
14
+ class << self
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def configure
20
+ yield(configuration)
21
+ end
22
+
23
+ def registry
24
+ @registry ||= Registry.new
25
+ end
26
+
27
+ def register(calculator_class)
28
+ registry.register(calculator_class)
29
+ end
30
+
31
+ def find(slug)
32
+ registry.find(slug)
33
+ end
34
+
35
+ def all
36
+ registry.all
37
+ end
38
+ end
39
+ end
40
+
41
+ # Load Rails engine if Rails is present
42
+ require_relative "calc_kit/engine" if defined?(Rails::Engine)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module CalcKit
7
+ module Generators
8
+ class CalculatorGenerator < Rails::Generators::NamedBase
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ class_option :skip_test, type: :boolean, default: false,
12
+ desc: "Skip generating test file"
13
+
14
+ def create_calculator
15
+ template "calculator.rb.tt", "app/calculators/#{file_name}_calculator.rb"
16
+ end
17
+
18
+ def create_test
19
+ return if options[:skip_test]
20
+
21
+ template "calculator_test.rb.tt", "test/calculators/#{file_name}_calculator_test.rb"
22
+ end
23
+
24
+ private
25
+
26
+ def class_name
27
+ file_name.camelize
28
+ end
29
+
30
+ def calculator_name
31
+ file_name.titleize
32
+ end
33
+
34
+ def calculator_slug
35
+ file_name.underscore
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module CalcKit
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ class_option :model, type: :boolean, default: false,
12
+ desc: "Generate Calculation model and migration"
13
+ class_option :views, type: :boolean, default: false,
14
+ desc: "Copy default views to app/views/calculators"
15
+ class_option :scope, type: :string, default: nil,
16
+ desc: "Multi-tenancy scope method (e.g., current_account)"
17
+
18
+ def create_initializer
19
+ template "initializer.rb.tt", "config/initializers/calc_kit.rb"
20
+ end
21
+
22
+ def create_calculators_directory
23
+ empty_directory "app/calculators"
24
+ end
25
+
26
+ def create_application_calculator
27
+ template "application_calculator.rb.tt", "app/calculators/application_calculator.rb"
28
+ end
29
+
30
+ def create_model
31
+ return unless options[:model]
32
+
33
+ template "calculation.rb.tt", "app/models/calculation.rb"
34
+ migration_template "create_calculations.rb.tt",
35
+ "db/migrate/create_calculations.rb",
36
+ migration_version: migration_version
37
+ end
38
+
39
+ def copy_views
40
+ return unless options[:views]
41
+
42
+ directory "views", "app/views/calculators"
43
+ end
44
+
45
+ def show_post_install_message
46
+ say ""
47
+ say "CalcKit installed successfully!", :green
48
+ say ""
49
+ say "Next steps:"
50
+ say " 1. Run `rails db:migrate` if you generated the model"
51
+ say " 2. Create a calculator: `rails g calc_kit:calculator my_calculator`"
52
+ say " 3. Define inputs, outputs, and the calculate method"
53
+ say " 4. Register your calculator at the bottom of the file"
54
+ say ""
55
+ end
56
+
57
+ private
58
+
59
+ def migration_version
60
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
61
+ end
62
+
63
+ def scope_method
64
+ options[:scope]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for all calculators in this application.
4
+ # Inherits from CalcKit::Base which provides the DSL.
5
+ #
6
+ # Add shared behavior, validations, or methods here.
7
+ #
8
+ class ApplicationCalculator < CalcKit::Base
9
+ # Example shared method:
10
+ # def formatted_currency(amount)
11
+ # "$%.2f" % amount
12
+ # end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Calculation < ApplicationRecord
4
+ include CalcKit::Model
5
+ <% if scope_method -%>
6
+
7
+ belongs_to :<%= scope_method.to_s.sub("current_", "") %>, optional: true
8
+ <% end -%>
9
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>Calculator < ApplicationCalculator
4
+ calculator_name "<%= calculator_name %>"
5
+ calculator_slug :<%= calculator_slug %>
6
+ version "1.0"
7
+
8
+ # Define inputs
9
+ # input :price, :decimal, label: "Price", required: true, min: 0.01
10
+ # input :quantity, :integer, label: "Quantity", required: true, min: 1
11
+
12
+ # Define outputs
13
+ # output :total, :decimal
14
+ # output :summary, :string
15
+
16
+ def calculate
17
+ # Return a hash with output values
18
+ # {
19
+ # total: price * quantity,
20
+ # summary: "#{quantity} items at $#{price} each"
21
+ # }
22
+ raise NotImplementedError, "Implement the calculate method"
23
+ end
24
+ end
25
+
26
+ # Register the calculator
27
+ CalcKit.register(<%= class_name %>Calculator)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class <%= class_name %>CalculatorTest < ActiveSupport::TestCase
6
+ def setup
7
+ @calculator = <%= class_name %>Calculator.new
8
+ end
9
+
10
+ test "calculator is registered" do
11
+ assert_equal <%= class_name %>Calculator, CalcKit.find(:<%= calculator_slug %>)
12
+ end
13
+
14
+ test "calculator has required metadata" do
15
+ assert_equal "<%= calculator_name %>", <%= class_name %>Calculator.calculator_name
16
+ assert_equal :<%= calculator_slug %>, <%= class_name %>Calculator.calculator_slug
17
+ assert_equal "1.0", <%= class_name %>Calculator.version
18
+ end
19
+
20
+ # test "validates required inputs" do
21
+ # assert_not @calculator.valid?
22
+ # assert_includes @calculator.errors[:price], "can't be blank"
23
+ # end
24
+
25
+ # test "calculates correctly with valid inputs" do
26
+ # calculator = <%= class_name %>Calculator.new(price: 10.0, quantity: 5)
27
+ # result = calculator.run
28
+ #
29
+ # assert_not_nil result
30
+ # assert_equal 50.0, result[:total]
31
+ # end
32
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateCalculations < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :calculations do |t|
6
+ <% if scope_method -%>
7
+ t.references :<%= scope_method.to_s.sub("current_", "") %>, null: true, foreign_key: true
8
+ <% end -%>
9
+ t.string :calculator_type, null: false
10
+ t.string :calculator_version, null: false
11
+ t.jsonb :inputs, null: false, default: {}
12
+ t.jsonb :outputs, null: false, default: {}
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :calculations, :calculator_type
18
+ <% if scope_method -%>
19
+ add_index :calculations, [:<%= scope_method.to_s.sub("current_", "") %>_id, :calculator_type]
20
+ <% end -%>
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ CalcKit.configure do |config|
4
+ # Path to calculator classes (relative to Rails.root)
5
+ # config.calculators_path = "app/calculators"
6
+
7
+ # Automatically register calculators on load
8
+ # config.auto_register = true
9
+ <% if scope_method -%>
10
+
11
+ # Multi-tenancy scope method
12
+ config.scope_method = :<%= scope_method %>
13
+ <% else -%>
14
+
15
+ # Multi-tenancy scope method (e.g., :current_account, :current_user)
16
+ # config.scope_method = nil
17
+ <% end -%>
18
+
19
+ # Enable Turbo Streams for form responses
20
+ # config.enable_turbo_streams = true
21
+
22
+ # Enable saving calculations to the database
23
+ # config.save_calculations = true
24
+
25
+ # Warn when loading a calculation with a different version
26
+ # config.warn_on_version_mismatch = true
27
+ end
@@ -0,0 +1,30 @@
1
+ <%%= ui_form action: calculator_path(slug: calculator.class.calculator_slug),
2
+ method: :post,
3
+ data: { turbo_stream: true } do %>
4
+
5
+ <%% calculator.class.inputs.each do |input| %>
6
+ <%%= ui_form_field attribute: input.name,
7
+ label: input.label,
8
+ type: calc_kit_keystone_type(input.type),
9
+ required: input.required?,
10
+ hint: input.hint,
11
+ placeholder: input.placeholder,
12
+ min: input.min,
13
+ max: input.max,
14
+ step: input.type == :decimal ? (input.step || "0.01") : (input.type == :integer ? 1 : nil),
15
+ value: calculator.send(input.name) || calc_kit_resolve_default(input.default),
16
+ options: input.respond_to?(:options_for_select) ? input.options_for_select : [],
17
+ errors: calculator.errors[input.name].to_a %>
18
+ <%% end %>
19
+
20
+ <div class="flex items-center gap-4">
21
+ <%%= ui_button label: "Calculate", type: :submit %>
22
+
23
+ <%% if defined?(user_signed_in?) && user_signed_in? %>
24
+ <label class="inline-flex items-center">
25
+ <%%= check_box_tag :save, "1", false, class: "form-checkbox rounded" %>
26
+ <span class="ml-2 text-sm">Save to my calculations</span>
27
+ </label>
28
+ <%% end %>
29
+ </div>
30
+ <%% end %>
@@ -0,0 +1,17 @@
1
+ <div id="calculator_result">
2
+ <%% if result %>
3
+ <div class="mt-6">
4
+ <%%= ui_panel do %>
5
+ <h3 class="text-lg font-semibold mb-4">Result</h3>
6
+ <dl class="space-y-2">
7
+ <%% calculator.class.outputs.each do |output| %>
8
+ <div class="flex justify-between">
9
+ <dt class="font-medium"><%%= output.label %>:</dt>
10
+ <dd><%%= calc_kit_format_output(result[output.name], output.type) %></dd>
11
+ </div>
12
+ <%% end %>
13
+ </dl>
14
+ <%% end %>
15
+ </div>
16
+ <%% end %>
17
+ </div>
@@ -0,0 +1,36 @@
1
+ <%% if saved_calculations&.any? %>
2
+ <div class="mt-6">
3
+ <h3 class="text-sm font-semibold text-gray-500 mb-3">Recent Calculations</h3>
4
+ <div class="space-y-2">
5
+ <%% saved_calculations.each do |calculation| %>
6
+ <%%= ui_panel padding: :sm do %>
7
+ <div class="flex justify-between items-start gap-4 text-sm">
8
+ <div class="flex-1 min-w-0">
9
+ <div class="text-xs text-gray-400 mb-1">
10
+ <%%= calculation.created_at.to_fs(:short) %>
11
+ <%% if (version_warning = calc_kit_version_warning(calculation)) %>
12
+ <%%= ui_badge label: "Outdated", variant: :warning %>
13
+ <%% end %>
14
+ </div>
15
+ <div class="flex flex-wrap gap-x-4 gap-y-1">
16
+ <%% calculation.outputs.each do |key, value| %>
17
+ <span class="text-gray-700 dark:text-gray-300">
18
+ <span class="text-gray-500"><%%= key.to_s.titleize %>:</span> <%%= value %>
19
+ </span>
20
+ <%% end %>
21
+ </div>
22
+ </div>
23
+ <%% if defined?(reopen_calculation_path) %>
24
+ <div class="flex gap-2 shrink-0">
25
+ <%%= ui_button label: "Use", href: reopen_calculation_path(calculation), variant: :secondary, size: :sm %>
26
+ </div>
27
+ <%% end %>
28
+ </div>
29
+ <%% end %>
30
+ <%% end %>
31
+ </div>
32
+ <%% if defined?(calculations_path) %>
33
+ <%%= ui_button label: "View all", href: calculations_path, variant: :secondary, size: :sm %>
34
+ <%% end %>
35
+ </div>
36
+ <%% end %>
@@ -0,0 +1,3 @@
1
+ <%%= turbo_stream.replace "calculator_result" do %>
2
+ <%%= render "result", calculator: @calculator, result: @result %>
3
+ <%% end %>
@@ -0,0 +1,11 @@
1
+ <%%= ui_page max_width: :lg do %>
2
+ <%%= ui_page_header title: "Calculators" %>
3
+
4
+ <%%= ui_grid cols: { default: 1, md: 2, lg: 3 } do %>
5
+ <%% CalcKit.all.each do |calculator_class| %>
6
+ <%%= ui_card_link href: calculator_path(slug: calculator_class.calculator_slug) do %>
7
+ <h2 class="text-lg font-semibold"><%%= calculator_class.calculator_name %></h2>
8
+ <%% end %>
9
+ <%% end %>
10
+ <%% end %>
11
+ <%% end %>
@@ -0,0 +1,13 @@
1
+ <%%= ui_page max_width: :lg do %>
2
+ <%%= ui_page_header title: @calculator_class.calculator_name %>
3
+
4
+ <%%= ui_panel do %>
5
+ <%%= render "form", calculator: @calculator %>
6
+ <%% end %>
7
+
8
+ <%%= render "result", calculator: @calculator, result: @result %>
9
+
10
+ <%% if defined?(@saved_calculations) && @saved_calculations&.any? %>
11
+ <%%= render "saved_calculations", saved_calculations: @saved_calculations %>
12
+ <%% end %>
13
+ <%% end %>