concerns_on_rails 1.3.0 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d4b68b3625ea62546fa003654e7bd0618a56b09b615b406dcaed12f16560338
4
- data.tar.gz: 4d048584cdf09f1eb9859a4cca0ccd64182b331bfa8a6ffb897ff61ab1c4636a
3
+ metadata.gz: d359c073e004cdacffbfeaf9e588abb581bf6794a631211fc3eca309f9522e31
4
+ data.tar.gz: c686a9ef9f5fa323a7d59ba4786e58c6d0a78265e65e01526036350b1ec8bc26
5
5
  SHA512:
6
- metadata.gz: 8797f42f52e32309614fa95bf2a52b0a17e6adff54f07bbdcc9f9f27863a92fdfe0dfec1a14d7ad9b34285e25abc6a12cf37194a4da4532874a29d0fc7a13b01
7
- data.tar.gz: e60133f68c2b4778cdea6b34fdcddd279eee5076ef060c009734c9e42eda3d379d65c9adfa847dd30dcb47e06e4a39c88313644fa248e407a5d60e242e8e3756
6
+ metadata.gz: 85af55992f8dad780dcbe588df3b8df21f042d46337eb331b85638040a1c016be004d17387e894fca8f787d8c1995b0d89f694fd9af9315cb4743ff556799d94
7
+ data.tar.gz: cd515ebcec0029801f5483872d94d7e833bbba64b0cd00da2247744e78c8789178ce950b4f0a52023612e19f8e3b2e40e6198800291a5e42655198e585692288
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.5.0 (2026-05-16)
4
+
5
+ ### Added
6
+ - Expirable: Single-timestamp expiry for tokens, API keys, sessions, and similar records. Adds `expirable_by` macro, `.active` / `.expired` / `.expiring_within(duration)` scopes, predicates (`active?`, `expired?`), mutators (`expire!`, `extend_expiry!`), and `time_until_expiry`. `nil` expiry means "never expires"; the expiry boundary is exclusive.
7
+
8
+ ## 1.4.2 (2026-05-16)
9
+
10
+ ### Added
11
+ - Schedulable: Manage time-windowed records via `starts_at` / `ends_at` columns. Adds `schedulable_by` macro, scopes (`.current`, `.upcoming`, `.expired`, `.active_at(time)`), predicates (`current?`, `upcoming?`, `expired?`, `active_at?`), and mutators (`start!`, `finish!`, `reschedule!`). Supports custom column names and open-ended schedules (`starts_at: nil`).
12
+
13
+ ### Internal
14
+ - Refactored `active_at?` into two private predicate helpers (`schedulable_started_by?` / `schedulable_not_ended_at?`) to satisfy `Metrics/CyclomaticComplexity`.
15
+
16
+ ### Notes
17
+ - The `v1.4.0` and `v1.4.1` tags were created but never released to RubyGems (CI failed on `Gemfile.lock` regeneration and a RuboCop complexity check respectively). `1.4.2` is the first usable release of the Schedulable concern.
18
+
3
19
  ## 1.3.0 (2026-05-16)
4
20
 
5
21
  ### Added
data/README.md CHANGED
@@ -11,6 +11,8 @@ A simple collection of reusable Rails concerns to keep your models clean and DRY
11
11
  - 📤 `Publishable`: Easily manage published/unpublished records using a simple `published_at` field
12
12
  - ❌ `SoftDeletable`: Soft delete records using a configurable timestamp field (e.g., `deleted_at`) with automatic scoping
13
13
  - 🔐 `Hashable`: Auto-generate a random hex/UUID/integer/custom-alphabet value on create, with a `regenerate_<field>!` helper
14
+ - 🗓️ `Schedulable`: Manage time-windowed records via `starts_at` / `ends_at` with `.current`, `.upcoming`, `.expired`, and `.active_at(time)` scopes
15
+ - ⏳ `Expirable`: Single-timestamp expiry with `.active` / `.expired` / `.expiring_within(duration)` scopes and `expire!` / `extend_expiry!` / `time_until_expiry` helpers
14
16
 
15
17
  ---
16
18
 
@@ -238,6 +240,119 @@ hashable_by :code, type: :custom, length: 8,
238
240
 
239
241
  ---
240
242
 
243
+ ### 6. 🗓️ Schedulable
244
+
245
+ Manage records with a time window using `starts_at` / `ends_at` columns.
246
+
247
+ ```ruby
248
+ class Promotion < ApplicationRecord
249
+ include ConcernsOnRails::Schedulable
250
+
251
+ # Defaults: starts_at: :starts_at, ends_at: :ends_at
252
+ schedulable_by
253
+ end
254
+
255
+ promo = Promotion.create!(starts_at: 1.hour.ago, ends_at: 1.day.from_now)
256
+ promo.current? # => true
257
+ promo.upcoming? # => false
258
+ promo.expired? # => false
259
+
260
+ Promotion.current # currently active
261
+ Promotion.upcoming # starts_at in the future
262
+ Promotion.expired # ends_at in the past
263
+ Promotion.active_at(Time.zone.now) # active at any specific time
264
+ ```
265
+
266
+ #### Custom column names
267
+
268
+ ```ruby
269
+ class Event < ApplicationRecord
270
+ include ConcernsOnRails::Schedulable
271
+
272
+ schedulable_by starts_at: :starts_on, ends_at: :ends_on
273
+ end
274
+ ```
275
+
276
+ #### Open-ended start (only an expiry)
277
+
278
+ ```ruby
279
+ class Coupon < ApplicationRecord
280
+ include ConcernsOnRails::Schedulable
281
+
282
+ schedulable_by starts_at: nil, ends_at: :expires_at
283
+ end
284
+ ```
285
+
286
+ #### Mutators
287
+
288
+ ```ruby
289
+ promo.start! # sets starts_at to now
290
+ promo.finish! # sets ends_at to now
291
+ promo.reschedule!(starts_at: 1.day.from_now,
292
+ ends_at: 2.days.from_now) # sets both
293
+ ```
294
+
295
+ #### Notes
296
+ - Boundary semantics are **inclusive start, exclusive end**: a record is active at exactly `starts_at`, but not at exactly `ends_at`.
297
+ - A `nil` `ends_at` means "no end" — the record stays active forever once started.
298
+ - A `nil` `starts_at` means "not yet started" — the record is not active (unless `starts_at` is unconfigured).
299
+ - No `default_scope` is added; chain `.current` (or any other scope) explicitly to filter.
300
+ - `schedulable_by` validates that the configured columns exist and raises `ArgumentError` otherwise.
301
+
302
+ ---
303
+
304
+ ### 7. ⏳ Expirable
305
+
306
+ For records with a single expiry timestamp — auth tokens, API keys, sessions, password-reset links, invitations.
307
+
308
+ ```ruby
309
+ class ApiToken < ApplicationRecord
310
+ include ConcernsOnRails::Expirable
311
+
312
+ # Default field: :expires_at
313
+ expirable_by
314
+ end
315
+
316
+ token = ApiToken.create!(expires_at: 1.hour.from_now)
317
+ token.active? # => true
318
+ token.expired? # => false
319
+ token.time_until_expiry # => ActiveSupport::Duration (~1.hour)
320
+
321
+ ApiToken.active # nil expiry OR future expiry
322
+ ApiToken.expired # past expiry
323
+ ApiToken.expiring_within(1.day) # future expiry within the next 1.day
324
+ ```
325
+
326
+ #### Mutators
327
+
328
+ ```ruby
329
+ token.expire! # sets expires_at to now
330
+ token.expire!(2.hours.from_now) # sets to an explicit time
331
+ token.extend_expiry!(by: 1.day) # pushes expiry forward
332
+ ```
333
+
334
+ `extend_expiry!` is "smart" about the base:
335
+ - If `expires_at` is nil or already in the past, the new value is `now + by`.
336
+ - If `expires_at` is still in the future, `by` is added to the existing value.
337
+
338
+ #### Custom field name
339
+
340
+ ```ruby
341
+ class License < ApplicationRecord
342
+ include ConcernsOnRails::Expirable
343
+
344
+ expirable_by :valid_until
345
+ end
346
+ ```
347
+
348
+ #### Notes
349
+ - `nil` `expires_at` means **never expires** (the record stays `active?`).
350
+ - The expiry boundary is **exclusive**: at exactly `expires_at`, the record is `expired?`.
351
+ - No `default_scope` is added; chain `.active` explicitly to filter.
352
+ - `Expirable` overlaps with `Schedulable`'s open-ended pattern (`schedulable_by starts_at: nil, ends_at: :expires_at`). Use `Expirable` when the API ergonomics around expiry — `active?`, `expire!`, `extend_expiry!`, `expiring_within` — fit your domain better; use `Schedulable` when you also need a start time.
353
+
354
+ ---
355
+
241
356
  ## 🛠️ Development
242
357
 
243
358
  To build the gem:
@@ -0,0 +1,85 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Expirable
5
+ extend ActiveSupport::Concern
6
+
7
+ DEFAULT_FIELD = :expires_at
8
+
9
+ included do
10
+ class_attribute :expirable_field, instance_accessor: false, default: DEFAULT_FIELD
11
+
12
+ scope :active, lambda {
13
+ column = arel_table[expirable_field]
14
+ where(column.eq(nil).or(column.gt(Time.zone.now)))
15
+ }
16
+
17
+ scope :expired, lambda {
18
+ where(arel_table[expirable_field].lteq(Time.zone.now))
19
+ }
20
+
21
+ scope :expiring_within, lambda { |duration|
22
+ column = arel_table[expirable_field]
23
+ now = Time.zone.now
24
+ where(column.gt(now)).where(column.lteq(now + duration))
25
+ }
26
+ end
27
+
28
+ class_methods do
29
+ # Configure the expiry column.
30
+ # Example:
31
+ # expirable_by # uses :expires_at
32
+ # expirable_by :valid_until
33
+ def expirable_by(field = DEFAULT_FIELD)
34
+ self.expirable_field = field.to_sym
35
+
36
+ return if column_names.include?(expirable_field.to_s)
37
+
38
+ raise ArgumentError, "ConcernsOnRails::Expirable: expirable_field '#{expirable_field}' does not exist in the database"
39
+ end
40
+ end
41
+
42
+ def active?
43
+ !expired?
44
+ end
45
+
46
+ # nil means never expires; equal-to-now is treated as expired (exclusive boundary).
47
+ def expired?
48
+ value = self[self.class.expirable_field]
49
+ return false if value.nil?
50
+
51
+ value <= Time.zone.now
52
+ end
53
+
54
+ def expire!(time = Time.zone.now)
55
+ update(self.class.expirable_field => time)
56
+ end
57
+
58
+ # Push expiry forward by `by:`. If the record has no expiry yet, or has
59
+ # already expired, the new expiry is `now + by`. Otherwise it's added to
60
+ # the existing expiry.
61
+ def extend_expiry!(by:)
62
+ update(self.class.expirable_field => expiry_extension_base + by)
63
+ end
64
+
65
+ # Returns an ActiveSupport::Duration of how long until expiry, or nil
66
+ # when there's no expiry set, or 0.seconds when already expired.
67
+ def time_until_expiry
68
+ value = self[self.class.expirable_field]
69
+ return nil if value.nil?
70
+
71
+ now = Time.zone.now
72
+ return 0.seconds if value <= now
73
+
74
+ (value - now).seconds
75
+ end
76
+
77
+ private
78
+
79
+ def expiry_extension_base
80
+ value = self[self.class.expirable_field]
81
+ now = Time.zone.now
82
+ value.nil? || value <= now ? now : value
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,128 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Schedulable
5
+ extend ActiveSupport::Concern
6
+
7
+ DEFAULT_STARTS_AT_FIELD = :starts_at
8
+ DEFAULT_ENDS_AT_FIELD = :ends_at
9
+
10
+ included do
11
+ class_attribute :schedulable_starts_at_field, instance_accessor: false, default: DEFAULT_STARTS_AT_FIELD
12
+ class_attribute :schedulable_ends_at_field, instance_accessor: false, default: DEFAULT_ENDS_AT_FIELD
13
+
14
+ scope :active_at, lambda { |time|
15
+ starts_field = schedulable_starts_at_field
16
+ ends_field = schedulable_ends_at_field
17
+ relation = all
18
+ relation = relation.where(arel_table[starts_field].lteq(time)) if starts_field
19
+ relation = relation.where(arel_table[ends_field].eq(nil).or(arel_table[ends_field].gt(time))) if ends_field
20
+ relation
21
+ }
22
+
23
+ scope :current, -> { active_at(Time.zone.now) }
24
+
25
+ scope :upcoming, lambda {
26
+ field = schedulable_starts_at_field
27
+ next none unless field
28
+
29
+ where(arel_table[field].gt(Time.zone.now))
30
+ }
31
+
32
+ scope :expired, lambda {
33
+ field = schedulable_ends_at_field
34
+ next none unless field
35
+
36
+ where(arel_table[field].lteq(Time.zone.now))
37
+ }
38
+ end
39
+
40
+ class_methods do
41
+ # Configure the start/end timestamp columns.
42
+ # Example:
43
+ # schedulable_by # uses :starts_at and :ends_at
44
+ # schedulable_by starts_at: :starts_on, ends_at: :ends_on
45
+ # schedulable_by starts_at: nil, ends_at: :expires_at # open-ended start
46
+ def schedulable_by(starts_at: DEFAULT_STARTS_AT_FIELD, ends_at: DEFAULT_ENDS_AT_FIELD)
47
+ self.schedulable_starts_at_field = starts_at&.to_sym
48
+ self.schedulable_ends_at_field = ends_at&.to_sym
49
+
50
+ if schedulable_starts_at_field.nil? && schedulable_ends_at_field.nil?
51
+ raise ArgumentError, "ConcernsOnRails::Schedulable: at least one of starts_at: or ends_at: must be configured"
52
+ end
53
+
54
+ [schedulable_starts_at_field, schedulable_ends_at_field].compact.each do |field|
55
+ next if column_names.include?(field.to_s)
56
+
57
+ raise ArgumentError, "ConcernsOnRails::Schedulable: field '#{field}' does not exist in the database"
58
+ end
59
+ end
60
+ end
61
+
62
+ # Is the record active at the given time? Inclusive start, exclusive end.
63
+ def active_at?(time)
64
+ schedulable_started_by?(time) && schedulable_not_ended_at?(time)
65
+ end
66
+
67
+ def current?
68
+ active_at?(Time.zone.now)
69
+ end
70
+
71
+ def upcoming?
72
+ field = self.class.schedulable_starts_at_field
73
+ value = field && self[field]
74
+ return false unless value
75
+
76
+ value > Time.zone.now
77
+ end
78
+
79
+ def expired?
80
+ field = self.class.schedulable_ends_at_field
81
+ value = field && self[field]
82
+ return false unless value
83
+
84
+ value <= Time.zone.now
85
+ end
86
+
87
+ def start!(time = Time.zone.now)
88
+ field = self.class.schedulable_starts_at_field
89
+ raise "ConcernsOnRails::Schedulable: starts_at field not configured" unless field
90
+
91
+ update(field => time)
92
+ end
93
+
94
+ def finish!(time = Time.zone.now)
95
+ field = self.class.schedulable_ends_at_field
96
+ raise "ConcernsOnRails::Schedulable: ends_at field not configured" unless field
97
+
98
+ update(field => time)
99
+ end
100
+
101
+ def reschedule!(starts_at:, ends_at:)
102
+ attrs = {}
103
+ starts_field = self.class.schedulable_starts_at_field
104
+ ends_field = self.class.schedulable_ends_at_field
105
+ attrs[starts_field] = starts_at if starts_field
106
+ attrs[ends_field] = ends_at if ends_field
107
+ update(attrs)
108
+ end
109
+
110
+ private
111
+
112
+ def schedulable_started_by?(time)
113
+ field = self.class.schedulable_starts_at_field
114
+ return true unless field
115
+
116
+ value = self[field]
117
+ !value.nil? && value <= time
118
+ end
119
+
120
+ def schedulable_not_ended_at?(time)
121
+ field = self.class.schedulable_ends_at_field
122
+ return true unless field
123
+
124
+ value = self[field]
125
+ value.nil? || value > time
126
+ end
127
+ end
128
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.3.0".freeze
2
+ VERSION = "1.5.0".freeze
3
3
  end
@@ -5,6 +5,8 @@ require "concerns_on_rails/publishable"
5
5
  require "concerns_on_rails/sluggable"
6
6
  require "concerns_on_rails/soft_deletable"
7
7
  require "concerns_on_rails/hashable"
8
+ require "concerns_on_rails/schedulable"
9
+ require "concerns_on_rails/expirable"
8
10
 
9
11
  module ConcernsOnRails
10
12
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-05-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -70,8 +70,10 @@ files:
70
70
  - CODE_OF_CONDUCT.md
71
71
  - README.md
72
72
  - lib/concerns_on_rails.rb
73
+ - lib/concerns_on_rails/expirable.rb
73
74
  - lib/concerns_on_rails/hashable.rb
74
75
  - lib/concerns_on_rails/publishable.rb
76
+ - lib/concerns_on_rails/schedulable.rb
75
77
  - lib/concerns_on_rails/sluggable.rb
76
78
  - lib/concerns_on_rails/soft_deletable.rb
77
79
  - lib/concerns_on_rails/sortable.rb
@@ -91,7 +93,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
91
93
  requirements:
92
94
  - - ">="
93
95
  - !ruby/object:Gem::Version
94
- version: 2.7.0
96
+ version: 3.2.0
95
97
  required_rubygems_version: !ruby/object:Gem::Requirement
96
98
  requirements:
97
99
  - - ">="