English | 日本語 | 中文 | Français | Deutsch
Are you writing this every time in Rails?
# Before
Event.where("start_at <= ? AND (end_at IS NULL OR end_at > ?)", Time.current, Time.current)
# After
class Event < ActiveRecord::Base
in_time_scope
end
Event.in_timeThat's it. One line of DSL, zero raw SQL in your models.
This is a simple, thin gem that just provides scopes. No learning curve required.
This gem exists to:
- Keep time-range logic consistent across your entire codebase
- Avoid copy-paste SQL that's easy to get wrong
- Make time a first-class domain concept with named scopes like
in_time_published - Auto-detect nullability from your schema for optimized queries
- New Rails applications with validity periods
- Models with
start_at/end_atcolumns - Teams that want consistent time logic without scattered
whereclauses
bundle add in_time_scopeclass Event < ActiveRecord::Base
in_time_scope
end
# Class scope
Event.in_time # Records active now
Event.in_time(Time.parse("2024-06-01")) # Records active at specific time
# Instance method
event.in_time? # Is this record active now?
event.in_time?(some_time) # Was it active at that time?The gem reads your schema and generates the right SQL:
# NULL-allowed columns → NULL-aware query
WHERE (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at > ?)
# NOT NULL columns → simple query
WHERE start_at <= ? AND end_at > ?Multiple time windows per model:
class Article < ActiveRecord::Base
in_time_scope :published # → Article.in_time_published
in_time_scope :featured # → Article.in_time_featured
endclass Campaign < ActiveRecord::Base
in_time_scope start_at: { column: :available_at },
end_at: { column: :expired_at }
endFor records where each row is valid until the next one:
| id | user_id | amount | start_at |
|---|---|---|---|
| 1 | 1 | 100 | 2024-10-01 |
| 2 | 1 | 120 | 2024-10-10 |
| 3 | 1 | 150 | 2024-10-15 |
# Time.current = 2024-10-20 → row id=3 (latest) is selected
Price.in_time
# 2024-10-12 → row id=2 (latest before 10/12) is selected
Price.in_time(Time.parse("2024-10-12"))class Price < ActiveRecord::Base
in_time_scope start_at: { null: false }, end_at: { column: nil }
end
# Bonus: efficient has_one with NOT EXISTS
class User < ActiveRecord::Base
has_one :current_price, -> { latest_in_time(:user_id) }, class_name: "Price"
end
User.includes(:current_price) # No N+1, fetches only latest per userlatest_in_time — latest record per FK before the given time:
flowchart LR
A["id=1\n10/01"] --> B["id=2\n10/10"] --> C["id=3\n10/15"]
T(["⏱ time=10/20"]) -.->|latest_in_time| C
──────●──────────●──────────●──────────▶ time
10/01 10/10 10/15 10/20(now)
id=1 id=2 id=3
↑
latest_in_time
earliest_in_time — earliest record per FK before the given time:
flowchart LR
A["id=1\n10/01"] --> B["id=2\n10/10"] --> C["id=3\n10/15"]
T(["⏱ time=10/20"]) -.->|earliest_in_time| A
──────●──────────●──────────●──────────▶ time
10/01 10/10 10/15 10/20(now)
id=1 id=2 id=3
↑
earliest_in_time
For records that are active until they expire:
| id | code | expired_at |
|---|---|---|
| 1 | ABC | 2024-11-30 |
| 2 | XYZ | 2024-10-05 |
| 3 | DEF | 2024-12-31 |
# Time.current = 2024-10-20 → id=1 (ABC) and id=3 (DEF) selected (not yet expired)
Coupon.in_time
# 2024-10-06 → id=1 (ABC) and id=3 (DEF) selected (XYZ expired on 10/05)
Coupon.in_time(Time.parse("2024-10-06"))class Coupon < ActiveRecord::Base
in_time_scope start_at: { column: nil }, end_at: { null: false }
endflowchart LR
A["before_in_time"] -->|"● start_at"| B["in_time"] -->|"○ end_at"| C["after_in_time"]
D["out_of_time"] -->|"● start_at"| B
B -->|"○ end_at"| E["out_of_time"]
before_in_time │ in_time │ after_in_time
───────────────●─────────────────────○──────────────▶ time
start_at end_at
out_of_time │ in_time │ out_of_time
───────────────●─────────────────────○──────────────▶ time
Query records outside the time window:
# Records not yet started (start_at > time)
Event.before_in_time
event.before_in_time?
# Records already ended (end_at <= time)
Event.after_in_time
event.after_in_time?
# Records outside time window (before OR after)
Event.out_of_time
event.out_of_time? # Logical inverse of in_time?Works with named scopes too:
Article.before_in_time_published # Not yet published
Article.after_in_time_published # Publication ended
Article.out_of_time_published # Not currently published| Option | Default | Description | Example |
|---|---|---|---|
scope_name (1st arg) |
:in_time |
Named scope like in_time_published |
in_time_scope :published |
start_at: { column: } |
:start_at |
Custom column name, nil to disable |
start_at: { column: :available_at } |
end_at: { column: } |
:end_at |
Custom column name, nil to disable |
end_at: { column: nil } |
start_at: { null: } |
auto-detect | Force NULL handling | start_at: { null: false } |
end_at: { null: } |
auto-detect | Force NULL handling | end_at: { null: true } |
Inspired by onk/shibaraku. This gem extends the concept with:
- Schema-aware NULL handling for optimized queries
- Multiple named scopes per model
- Start-only / End-only patterns
latest_in_time/earliest_in_timefor efficienthas_oneassociations- Inverse scopes:
before_in_time,after_in_time,out_of_time
# Install dependencies
bin/setup
# Run tests
bundle exec rspec
# Run linting
bundle exec rubocop
# Generate CLAUDE.md (for AI coding assistants)
npx rulesync generateThis project uses rulesync to manage AI assistant rules. Edit .rulesync/rules/*.md and run npx rulesync generate to update CLAUDE.md.
Bug reports and pull requests are welcome on GitHub.
MIT License