eventuale 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 85ab77a3a97e7307b74eb8d11c38940bf57cb3f8ed291631d5919ca5fcd30391
4
+ data.tar.gz: e85009280853829b859f8e6c013f57867a606ee8dd63f38091c8f48e53383a56
5
+ SHA512:
6
+ metadata.gz: cb765edefa9e13eace6608de79faa8e4689e7bb215b785c70d06328360d1a054a6a93e8bba21a2cb1b02f01870b640f00252875fb6f976b49f0e497a2695cff7
7
+ data.tar.gz: 4d67fa6f008bb63a2c6dbc1f4b338bd1f70afa05ab2d92190218dedab9efdc1d7727bc11449bd3ef6c8d627e373b04b70c64d12e951dbf1a8fc6f3fe4545b044
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-08-26
4
+
5
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "eventuale" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["giuseppe.bertini@gmail.com"](mailto:"giuseppe.bertini@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Giuseppe
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,68 @@
1
+ # Eventuale
2
+
3
+ Eventuale manages simple, independent daily calendars for Active Record models.
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ ```ruby
10
+ gem "eventuale"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ The model needs `start:datetime`, `duration:integer` (minutes), and
16
+ `position:integer`. Declare any additional calendar scope just as you would
17
+ with `positioning`:
18
+
19
+ If the model does not have those columns yet, generate a migration first:
20
+
21
+ ```sh
22
+ bin/rails generate eventuale:install Appointment
23
+ ```
24
+
25
+ ```ruby
26
+ class Appointment < ApplicationRecord
27
+ belongs_to :doctor
28
+ eventualmente on: :doctor
29
+ end
30
+ ```
31
+
32
+ Each doctor's events are positioned and timed independently for each calendar
33
+ date. The basic API is `advance`, `postpone`, `advance_by(int)`,
34
+ `postpone_by(int)`, `move_to(position_or_datetime)`,
35
+ `reschedule(start:, duration:)`, `append(attributes)`, and
36
+ `prepend(attributes)`. Scheduling operations recalculate positions and
37
+ contiguous start times together, so position order always matches the
38
+ timeline.
39
+
40
+ Use `reschedule` when changing an event's `start` and/or `duration`:
41
+
42
+ ```ruby
43
+ appointment.reschedule(start: new_start, duration: 30)
44
+ ```
45
+
46
+ Use the dedicated scheduling methods instead of updating `start`, `duration`,
47
+ or `position` directly through Active Record. Ordinary model attributes can
48
+ still be updated normally.
49
+
50
+ Eventuale deliberately does not define multi-day event behavior yet.
51
+
52
+ ## Development
53
+
54
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
55
+
56
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
57
+
58
+ ## Contributing
59
+
60
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/eventuale. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/eventuale/blob/main/CODE_OF_CONDUCT.md).
61
+
62
+ ## License
63
+
64
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
65
+
66
+ ## Code of Conduct
67
+
68
+ Everyone interacting in the Eventuale project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/eventuale/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eventuale
4
+ VERSION = "0.1.0"
5
+ end
data/lib/eventuale.rb ADDED
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "eventuale/version"
4
+ require "active_support/concern"
5
+ require "active_support/core_ext/date_and_time/calculations"
6
+ require "active_support/time"
7
+ require "positioning"
8
+
9
+ module Eventuale
10
+ class Error < StandardError; end
11
+
12
+ # Daily scheduling behavior added to Active Record models.
13
+ # rubocop:disable Metrics/ModuleLength
14
+ module Calendar
15
+ extend ActiveSupport::Concern
16
+
17
+ class_methods do
18
+ # Declares independent calendars for every combination of +on+ values.
19
+ def eventualmente(on: [])
20
+ @eventuale_calendar_scope = Array(on).map(&:to_sym)
21
+ end
22
+
23
+ def eventuale_calendar_scope
24
+ @eventuale_calendar_scope || []
25
+ end
26
+ end
27
+
28
+ def advance(slots = 1)
29
+ advance_by(slots)
30
+ end
31
+
32
+ def advance_by(slots)
33
+ move_to(position - integer_slots!(slots))
34
+ end
35
+
36
+ def postpone(slots = 1)
37
+ postpone_by(slots)
38
+ end
39
+
40
+ def postpone_by(slots)
41
+ move_to(position + integer_slots!(slots))
42
+ end
43
+
44
+ def reschedule(start: self.start, duration: self.duration)
45
+ ensure_persisted!
46
+ self.duration = duration
47
+
48
+ if start == self.start
49
+ self.class.transaction do
50
+ save!
51
+ normalize_current_day!
52
+ end
53
+ reload
54
+ else
55
+ move_to(start)
56
+ end
57
+ end
58
+
59
+ # Moves to a one-based position or inserts at a time on a day.
60
+ def move_to(destination)
61
+ case destination
62
+ when Integer
63
+ move_to_position(destination, calendar_day)
64
+ when Time, DateTime, ActiveSupport::TimeWithZone
65
+ move_to_time(destination)
66
+ else
67
+ raise ArgumentError, "destination must be an Integer or a datetime"
68
+ end
69
+ end
70
+
71
+ def append(attributes = {})
72
+ values = attributes.merge(start: start + duration_in_seconds, position: position + 1).compact
73
+ self.class.create!(values).tap do |record|
74
+ record.move_to(record.position)
75
+ end
76
+ end
77
+
78
+ def prepend(attributes = {})
79
+ self.class.create!(attributes.merge(start: start, position: position).compact).tap do |record|
80
+ record.move_to(record.position)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def integer_slots!(slots)
87
+ Integer(slots).tap { |value| raise ArgumentError, "slots must be positive" unless value.positive? }
88
+ rescue ArgumentError, TypeError
89
+ raise ArgumentError, "slots must be a positive integer"
90
+ end
91
+
92
+ def move_to_time(time)
93
+ day = time.to_date
94
+ target = calendar_records(day).where.not(id: id).where("start >= ?", time).order(:start, :position).first
95
+ move_to_position(target ? target.position : calendar_records(day).count + 1, day, start_time: time)
96
+ end
97
+
98
+ def move_to_position(target_position, day, start_time: nil)
99
+ ensure_persisted!
100
+ self.class.transaction { reposition!(target_position, day, start_time:) }
101
+ reload
102
+ end
103
+
104
+ def ensure_persisted!
105
+ raise Error, "event must be persisted" unless persisted?
106
+ end
107
+
108
+ def reposition!(target_position, day, start_time: nil)
109
+ old_day, records, anchor = repositioning_context(day)
110
+ insert_self(records, target_position)
111
+ truncate_preceding_record(records, start_time)
112
+ save_at_day(day) if old_day != day
113
+ save!
114
+ normalize_day!(records, anchor)
115
+ normalize_previous_day(old_day) if old_day != day
116
+ end
117
+
118
+ def truncate_preceding_record(records, start_time)
119
+ return unless start_time
120
+
121
+ preceding = records[records.index(self) - 1]
122
+ return unless preceding && preceding.start < start_time
123
+
124
+ elapsed_minutes = ((start_time - preceding.start) / 60).to_i
125
+ return unless elapsed_minutes.positive? && elapsed_minutes < preceding.duration.to_i
126
+
127
+ preceding.update_columns(duration: elapsed_minutes)
128
+ end
129
+
130
+ def repositioning_context(day)
131
+ old_day = calendar_day
132
+ records = calendar_records(day).lock.order(:position, :id).to_a
133
+ anchor = records.map(&:start).min || (old_day == day ? start : day.beginning_of_day)
134
+ [old_day, records, anchor]
135
+ end
136
+
137
+ def save_at_day(day)
138
+ self.start = day.beginning_of_day
139
+ end
140
+
141
+ def insert_self(records, target_position)
142
+ records.delete_if { |record| record.id == id }
143
+ target = [[Integer(target_position), 1].max, records.length + 1].min
144
+ records.insert(target - 1, self)
145
+ end
146
+
147
+ def normalize_previous_day(day)
148
+ records = calendar_records(day).lock.order(:position, :id).to_a
149
+ normalize_day!(records, records.map(&:start).min) unless records.empty?
150
+ end
151
+
152
+ def normalize_current_day!
153
+ records = calendar_records(calendar_day).lock.order(:position, :id).to_a
154
+ normalize_day!(records, records.map(&:start).min) unless records.empty?
155
+ end
156
+
157
+ def normalize_day!(records, anchor)
158
+ return if records.empty?
159
+
160
+ cursor = anchor
161
+ records.each_with_index do |record, index|
162
+ record.update_columns(position: index + 1, start: cursor)
163
+ cursor += record.duration.to_i * 60
164
+ end
165
+ end
166
+
167
+ def calendar_records(day)
168
+ scope = self.class.where(start: day.beginning_of_day...day.next_day.beginning_of_day)
169
+ calendar_scope_values.each { |column, value| scope = scope.where(column => value) }
170
+ scope
171
+ end
172
+
173
+ def calendar_scope_values
174
+ self.class.eventuale_calendar_scope.to_h do |name|
175
+ reflection = self.class.reflect_on_association(name)
176
+ reflection ? [reflection.foreign_key, public_send(reflection.foreign_key)] : [name, public_send(name)]
177
+ end
178
+ end
179
+
180
+ def calendar_day = start.to_date
181
+ def duration_in_seconds = duration.to_i * 60
182
+ end
183
+ # rubocop:enable Metrics/ModuleLength
184
+ end
185
+
186
+ ActiveSupport.on_load(:active_record) { include Eventuale::Calendar }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Eventuale
6
+ # Adds Eventuale's required columns to a model.
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+ desc "Creates a migration with the columns Eventuale needs"
10
+
11
+ argument :model, type: :string, required: true, banner: "ModelName"
12
+
13
+ def create_migration
14
+ migration_template "add_eventuale_to_model.rb.erb", "db/migrate/add_eventuale_to_#{table_name}.rb"
15
+ end
16
+
17
+ private
18
+
19
+ def table_name
20
+ model.underscore.pluralize
21
+ end
22
+
23
+ def migration_class_name
24
+ "AddEventualeTo#{model.camelize.pluralize}"
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= Rails::VERSION::STRING.to_f %>]
2
+ def change
3
+ change_table :<%= table_name %>, bulk: true do |table|
4
+ table.datetime :start, null: false
5
+ table.integer :duration, null: false
6
+ table.integer :position, null: false
7
+ end
8
+ end
9
+ end
data/sig/eventuale.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Eventuale
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: eventuale
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Giuseppe Bertini
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: positioning
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.4'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '1.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '0.4'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '1.0'
32
+ description: Keeps positioned Active Record records sequenced in independent daily
33
+ calendars.
34
+ email:
35
+ - giuseppe.bertini@gmail.com
36
+ executables: []
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - CHANGELOG.md
41
+ - CODE_OF_CONDUCT.md
42
+ - LICENSE.txt
43
+ - README.md
44
+ - Rakefile
45
+ - lib/eventuale.rb
46
+ - lib/eventuale/version.rb
47
+ - lib/generators/eventuale/install_generator.rb
48
+ - lib/generators/eventuale/templates/add_eventuale_to_model.rb.erb
49
+ - sig/eventuale.rbs
50
+ homepage: https://github.com/giuseppe/eventuale
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/giuseppe/eventuale
55
+ source_code_uri: https://github.com/giuseppe/eventuale
56
+ changelog_uri: https://github.com/giuseppe/eventuale/blob/main/CHANGELOG.md
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.2.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 4.0.18
72
+ specification_version: 4
73
+ summary: Daily calendars for Active Record models
74
+ test_files: []