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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 89540dec57ad5480d42f475262ad432f1a275b7d0519287c3e5e2092977d4399
4
+ data.tar.gz: e3ef5386ea201bc2e1d6d7a1bd267d569f78785a8ed6920c3d63fb8963601175
5
+ SHA512:
6
+ metadata.gz: 5ea9321a56fe0aa4b820ece61a754302a389831d66765c4dd4cbb6b1aa279f37d2d0a2b55553e67c15efbf1139539615be19aefd6a56bd45870a0daa3944b3ef
7
+ data.tar.gz: b99c06861ebb054cb65876689130a22f7a0818aa5eca7db59a89e6a955cc87ff7d49ebb589c23b84f5d3a2c0b412235894832ef3349d483f43ffe63629fd0a9b
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-06-01
9
+
10
+ ### Added
11
+
12
+ - Declarative DSL for defining calculator inputs and outputs
13
+ - Input types: string, integer, decimal, date, select, boolean
14
+ - Output types: string, integer, decimal, date, currency, percentage
15
+ - ActiveModel validations (required, min, max)
16
+ - Calculator registry for lookup by slug
17
+ - Rails engine with controller helpers and view helpers
18
+ - Generators for install, calculator scaffolding
19
+ - Optional persistence via Calculation model concern
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Tyler Schneider
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # CalcKit
2
+
3
+ A Ruby gem providing a declarative DSL for building calculators with automatic form generation, validation, and optional persistence. Works standalone with ActiveModel or as a Rails engine.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "calc_kit"
11
+ ```
12
+
13
+ Then run the install generator:
14
+
15
+ ```bash
16
+ rails g calc_kit:install
17
+ ```
18
+
19
+ Options:
20
+ - `--model` - Generate a Calculation model for persistence
21
+ - `--views` - Copy default views to your app
22
+ - `--scope=current_account` - Configure multi-tenancy scope
23
+
24
+ ## Usage
25
+
26
+ ### Defining a Calculator
27
+
28
+ ```ruby
29
+ # app/calculators/price_calculator.rb
30
+ class PriceCalculator < ApplicationCalculator
31
+ calculator_name "Price Calculator"
32
+ calculator_slug :price
33
+ version "1.0"
34
+
35
+ input :price, :decimal, label: "Unit Price", required: true, min: 0.01
36
+ input :quantity, :integer, label: "Quantity", required: true, min: 1
37
+ input :discount, :decimal, label: "Discount %", default: 0, min: 0, max: 100
38
+
39
+ output :subtotal, :decimal
40
+ output :total, :decimal
41
+
42
+ def calculate
43
+ sub = price * quantity
44
+ disc = sub * (discount / 100.0)
45
+ {
46
+ subtotal: sub,
47
+ total: sub - disc
48
+ }
49
+ end
50
+ end
51
+
52
+ CalcKit.register(PriceCalculator)
53
+ ```
54
+
55
+ ### Input Types
56
+
57
+ - `:string` - Text input
58
+ - `:integer` - Whole number input
59
+ - `:decimal` - Decimal number input
60
+ - `:date` - Date picker
61
+ - `:select` - Dropdown (use `options:` to provide choices)
62
+ - `:boolean` - Checkbox
63
+
64
+ ### Input Options
65
+
66
+ ```ruby
67
+ input :name, :type,
68
+ label: "Display Label", # Form label
69
+ required: true, # Adds presence validation
70
+ min: 0, # Minimum value (numeric)
71
+ max: 100, # Maximum value (numeric)
72
+ step: 0.01, # Step increment (decimal)
73
+ default: 10, # Default value (can be a proc)
74
+ placeholder: "Enter value", # Placeholder text
75
+ hint: "Help text", # Help text below input
76
+ options: [["Label", "value"], ...] # For :select type
77
+ ```
78
+
79
+ ### Output Types
80
+
81
+ - `:string` - Plain text
82
+ - `:integer` - Formatted integer
83
+ - `:decimal` - Formatted decimal (2 places)
84
+ - `:date` - Formatted date
85
+ - `:currency` - Currency formatted
86
+ - `:percentage` - Percentage formatted
87
+
88
+ ### Running Calculations
89
+
90
+ ```ruby
91
+ calc = PriceCalculator.new(price: 10, quantity: 5, discount: 10)
92
+
93
+ if calc.valid?
94
+ result = calc.run
95
+ # => { subtotal: 50.0, total: 45.0 }
96
+ end
97
+
98
+ # Or use run! to raise on validation errors
99
+ result = calc.run!
100
+ ```
101
+
102
+ ### Controller Integration
103
+
104
+ ```ruby
105
+ class CalculatorsController < ApplicationController
106
+ include CalcKit::ControllerHelpers
107
+
108
+ def show
109
+ @calculator_class = find_calculator_class(params[:slug])
110
+ @calculator = build_calculator(@calculator_class)
111
+ @saved_calculations = load_saved_calculations(@calculator_class)
112
+ end
113
+
114
+ def create
115
+ @calculator_class = find_calculator_class(params[:slug])
116
+ @calculator = build_calculator(@calculator_class)
117
+ @result = @calculator.run
118
+
119
+ if @result
120
+ save_calculation(@calculator, @result) if params[:save].present?
121
+ # ...
122
+ end
123
+ end
124
+ end
125
+ ```
126
+
127
+ ### View Helpers
128
+
129
+ ```erb
130
+ <%# Resolve callable defaults %>
131
+ <%= calc_kit_resolve_default(input.default) %>
132
+
133
+ <%# Format output values %>
134
+ <%= calc_kit_format_output(result[:total], :currency) %>
135
+
136
+ <%# CSS classes %>
137
+ <%= calc_kit_input_class(calculator, input) %>
138
+ <%= calc_kit_label_class %>
139
+ <%= calc_kit_error_class %>
140
+ ```
141
+
142
+ ## Configuration
143
+
144
+ ```ruby
145
+ # config/initializers/calc_kit.rb
146
+ CalcKit.configure do |config|
147
+ # Path to calculator classes
148
+ config.calculators_path = "app/calculators"
149
+
150
+ # Auto-register calculators on load
151
+ config.auto_register = true
152
+
153
+ # Multi-tenancy scope method
154
+ config.scope_method = :current_account
155
+
156
+ # Enable Turbo Streams
157
+ config.enable_turbo_streams = true
158
+
159
+ # Enable calculation persistence
160
+ config.save_calculations = true
161
+
162
+ # Default CSS classes
163
+ config.default_form_classes = {
164
+ input: "form-control",
165
+ label: "block text-sm font-medium mb-1",
166
+ error: "text-red-500 text-sm mt-1",
167
+ submit: "btn btn-primary"
168
+ }
169
+ end
170
+ ```
171
+
172
+ ## Generators
173
+
174
+ ```bash
175
+ # Install calc_kit
176
+ rails g calc_kit:install
177
+
178
+ # Generate a new calculator
179
+ rails g calc_kit:calculator shipping
180
+ ```
181
+
182
+ ## Roadmap
183
+
184
+ Potential future enhancements:
185
+
186
+ ### Input Enhancements
187
+ - [ ] Input groups for organizing related fields
188
+ - [ ] Conditional inputs (show/hide based on other values)
189
+ - [ ] Custom validators beyond min/max
190
+ - [ ] More types: `:textarea`, `:radio`, `:checkbox_group`, `:range`
191
+
192
+ ### Output Enhancements
193
+ - [ ] Custom formatters
194
+ - [ ] Conditional outputs (only show if value present)
195
+ - [ ] Output groups/sections
196
+
197
+ ### Calculator Features
198
+ - [ ] Calculator descriptions for index pages
199
+ - [ ] Categories/tags for organizing calculators
200
+ - [ ] Comparison mode (side-by-side with different inputs)
201
+ - [ ] Calculator dependencies (one calculator feeds into another)
202
+
203
+ ### Export & Sharing
204
+ - [ ] PDF export of results
205
+ - [ ] CSV export for saved calculations
206
+ - [ ] Shareable result links
207
+ - [ ] Embeddable calculator widgets
208
+
209
+ ### Developer Experience
210
+ - [ ] Controller scaffold generator
211
+ - [ ] API endpoint generator
212
+ - [ ] JavaScript client for real-time calculation preview
213
+ - [ ] Form builder integration (SimpleForm, Formtastic)
214
+
215
+ ### Testing
216
+ - [ ] Calculator test helpers
217
+ - [ ] Shared examples for common patterns
218
+
219
+ ## License
220
+
221
+ MIT License
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
data/calc_kit.gemspec ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/calc_kit/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "calc_kit"
7
+ spec.version = CalcKit::VERSION
8
+ spec.authors = ["Tyler Schneider"]
9
+ spec.email = ["tylercschneider@gmail.com"]
10
+
11
+ spec.summary = "A DSL for building calculators with automatic form generation"
12
+ spec.description = "CalcKit provides a declarative DSL for defining calculators with inputs, outputs, validation, and optional persistence. Works standalone or as a Rails engine."
13
+ spec.homepage = "https://github.com/tylercschneider/calc_kit"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.1.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (File.expand_path(f) == __FILE__) ||
25
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor docs/ Gemfile CLAUDE.md])
26
+ end
27
+ end
28
+ spec.bindir = "exe"
29
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.add_dependency "activemodel", ">= 7.0"
33
+
34
+ spec.add_development_dependency "rake", "~> 13.0"
35
+ spec.add_development_dependency "minitest", "~> 5.0"
36
+ spec.add_development_dependency "railties", ">= 7.0"
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module CalcKit
6
+ # Base class for all calculators with a declarative DSL
7
+ #
8
+ # Responsibilities (Single Responsibility):
9
+ # - Defines DSL for declaring calculator metadata, inputs, and outputs
10
+ # - Provides validation through ActiveModel
11
+ #
12
+ # Open/Closed: New calculators extend this class without modifying it
13
+ # Liskov Substitution: All subclasses can be used interchangeably via #run
14
+ # Interface Segregation: InputDefinition and OutputDefinition are separate classes
15
+ # Dependency Inversion: Uses ActiveModel abstractions, not concrete implementations
16
+ #
17
+ class Base
18
+ include ActiveModel::Model
19
+ include ActiveModel::Attributes
20
+ include DSL
21
+
22
+ # Run calculation if valid, return nil if invalid
23
+ def run
24
+ return nil unless valid?
25
+ calculate
26
+ end
27
+
28
+ # Run calculation, raise if invalid
29
+ def run!
30
+ raise ActiveModel::ValidationError, self unless valid?
31
+ calculate
32
+ end
33
+
34
+ # Subclasses must implement this
35
+ def calculate
36
+ raise NotImplementedError, "#{self.class.name} must implement #calculate"
37
+ end
38
+
39
+ # Returns input values as a hash
40
+ def input_values
41
+ self.class.inputs.each_with_object({}) do |input, hash|
42
+ hash[input.name] = send(input.name)
43
+ end
44
+ end
45
+
46
+ # Returns the calculator name
47
+ def calculator_name
48
+ self.class.calculator_name
49
+ end
50
+
51
+ # Returns the calculator slug
52
+ def calculator_slug
53
+ self.class.calculator_slug
54
+ end
55
+
56
+ # Returns the calculator version
57
+ def version
58
+ self.class.version
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ class Configuration
5
+ attr_accessor :calculators_path,
6
+ :auto_register,
7
+ :scope_method,
8
+ :enable_turbo_streams,
9
+ :save_calculations,
10
+ :warn_on_version_mismatch
11
+
12
+ def initialize
13
+ @calculators_path = "app/calculators"
14
+ @auto_register = true
15
+ @scope_method = nil
16
+ @enable_turbo_streams = true
17
+ @save_calculations = true
18
+ @warn_on_version_mismatch = true
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ # DSL module providing class-level macros for calculator definitions
5
+ module DSL
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ base.class_eval do
9
+ class_attribute :_calculator_name, :_calculator_slug, :_calculator_version,
10
+ :_inputs, :_outputs, instance_writer: false
11
+
12
+ self._inputs = []
13
+ self._outputs = []
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ def calculator_name(name = nil)
19
+ if name
20
+ self._calculator_name = name
21
+ else
22
+ _calculator_name
23
+ end
24
+ end
25
+
26
+ def calculator_slug(slug = nil)
27
+ if slug
28
+ self._calculator_slug = slug.to_sym
29
+ else
30
+ _calculator_slug
31
+ end
32
+ end
33
+
34
+ def version(v = nil)
35
+ if v
36
+ self._calculator_version = v
37
+ else
38
+ _calculator_version
39
+ end
40
+ end
41
+
42
+ def input(name, type, **options)
43
+ self._inputs = _inputs + [InputDefinition.new(name, type, options)]
44
+
45
+ # Define attribute using ActiveModel::Attributes
46
+ attribute name, type_for_attribute(type)
47
+
48
+ # Add validations based on options
49
+ validates name, presence: true if options[:required]
50
+
51
+ if options[:min]
52
+ validates name, numericality: { greater_than_or_equal_to: options[:min] }, allow_blank: true
53
+ end
54
+
55
+ if options[:max]
56
+ validates name, numericality: { less_than_or_equal_to: options[:max] }, allow_blank: true
57
+ end
58
+ end
59
+
60
+ def output(name, type, **options)
61
+ self._outputs = _outputs + [OutputDefinition.new(name, type, options)]
62
+ end
63
+
64
+ def inputs
65
+ _inputs
66
+ end
67
+
68
+ def outputs
69
+ _outputs
70
+ end
71
+
72
+ def input_for(name)
73
+ _inputs.find { |i| i.name == name }
74
+ end
75
+
76
+ def output_for(name)
77
+ _outputs.find { |o| o.name == name }
78
+ end
79
+
80
+ private
81
+
82
+ VALID_INPUT_TYPES = %i[string integer decimal date boolean select].freeze
83
+
84
+ def type_for_attribute(type)
85
+ unless VALID_INPUT_TYPES.include?(type)
86
+ raise ArgumentError, "Unknown input type: #{type.inspect}. Valid types: #{VALID_INPUT_TYPES.join(", ")}"
87
+ end
88
+
89
+ case type
90
+ when :date then :date
91
+ when :integer then :integer
92
+ when :decimal then :decimal
93
+ when :boolean then :boolean
94
+ when :string, :select then :string
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace CalcKit
6
+
7
+ config.autoload_paths << root.join("lib")
8
+
9
+ initializer "calc_kit.eager_load_calculators" do |app|
10
+ app.config.to_prepare do
11
+ if CalcKit.configuration.auto_register
12
+ calculators_path = ::Rails.root.join(CalcKit.configuration.calculators_path)
13
+ if calculators_path.exist?
14
+ ::Rails.autoloaders.main.eager_load_dir(calculators_path)
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ initializer "calc_kit.view_helpers" do
21
+ ActiveSupport.on_load(:action_view) do
22
+ include CalcKit::ViewHelpers
23
+ end
24
+ end
25
+
26
+ initializer "calc_kit.controller_helpers" do
27
+ ActiveSupport.on_load(:action_controller_base) do
28
+ include CalcKit::ControllerHelpers
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ require_relative "rails/controller_helpers"
35
+ require_relative "rails/view_helpers"
36
+ require_relative "rails/model"
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ # Value object for input field definitions
5
+ class InputDefinition
6
+ attr_reader :name, :type, :options
7
+
8
+ def initialize(name, type, options = {})
9
+ @name = name
10
+ @type = type
11
+ @options = options
12
+ end
13
+
14
+ def label
15
+ options[:label] || name.to_s.tr("_", " ").capitalize
16
+ end
17
+
18
+ def required?
19
+ options[:required] == true
20
+ end
21
+
22
+ def default
23
+ options[:default]
24
+ end
25
+
26
+ def placeholder
27
+ options[:placeholder]
28
+ end
29
+
30
+ def min
31
+ options[:min]
32
+ end
33
+
34
+ def max
35
+ options[:max]
36
+ end
37
+
38
+ def step
39
+ options[:step]
40
+ end
41
+
42
+ def options_for_select
43
+ options[:options]
44
+ end
45
+
46
+ def hint
47
+ options[:hint]
48
+ end
49
+
50
+ def ==(other)
51
+ other.is_a?(InputDefinition) &&
52
+ name == other.name &&
53
+ type == other.type &&
54
+ options == other.options
55
+ end
56
+
57
+ alias_method :eql?, :==
58
+
59
+ def hash
60
+ [name, type, options].hash
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalcKit
4
+ # Value object for output field definitions
5
+ class OutputDefinition
6
+ attr_reader :name, :type, :options
7
+
8
+ def initialize(name, type, options = {})
9
+ @name = name
10
+ @type = type
11
+ @options = options
12
+ end
13
+
14
+ def label
15
+ options[:label] || name.to_s.tr("_", " ").capitalize
16
+ end
17
+
18
+ def format
19
+ options[:format]
20
+ end
21
+
22
+ def ==(other)
23
+ other.is_a?(OutputDefinition) &&
24
+ name == other.name &&
25
+ type == other.type &&
26
+ options == other.options
27
+ end
28
+
29
+ alias_method :eql?, :==
30
+
31
+ def hash
32
+ [name, type, options].hash
33
+ end
34
+ end
35
+ end