Timezone-aware moon phase lookup for any date between 2000-01-06 and 2049-12-24.
Add to your Gemfile:
gem 'lunar_phases', git: 'https://github.com/rehali/lunar_phases.git'Two lookup methods; which one you want depends on what you are holding.
require 'lunar_phases'
# You have a UTC timestamp (a database column) plus a timezone:
result = LunarPhases.for_datetime(catch.catch_date, "Australia/Brisbane")
# You already have a local date:
result = LunarPhases.for_date(Date.today, "Australia/Brisbane")
result.name # => "Full Moon+2"
result.short_name # => "FM+2"
result.primary_phase # => :full_moon
result.offset # => 2
result.phase_time # => 2025-01-13 22:27:00 UTC
result.id # => 16 (legacy id — see below)
# Check for specific phases
LunarPhases.full_moon?(Date.today, "Australia/Brisbane") # => false
LunarPhases.new_moon?(Date.today, "Australia/Brisbane") # => false
# Is a date supported? Never raises, unlike the lookups.
LunarPhases.covers?(Date.new(2025, 1, 1)) # => true
LunarPhases.covers?(nil) # => false
# Get all primary phases in a range
phases = LunarPhases.phases_in_range(Date.new(2025, 1, 1), Date.new(2025, 12, 31), "Australia/Brisbane")
phases.each { |p| puts "#{p.date}: #{p.name}" }
# Resolve a legacy id held in an existing database
LunarPhases.for_id(14) # => "Full Moon"
LunarPhases.for_id(999) # => nil
# For select boxes
LunarPhases.collection # => [["New Moon", :new_moon], ["1st Quarter", :first_quarter], ...]
LunarPhases.detailed_collection # => [["New Moon-4", "New Moon-4"], ...] (36 entries)
# Supported range
LunarPhases.valid_range # => Date(2000-01-06)..Date(2049-12-24)The timezone argument to for_date, full_moon? and new_moon? is optional,
but omitting it measures the offset in UTC — which is off by a day whenever
the phase instant and the local day fall either side of midnight UTC. Pass the
zone your date belongs to unless you genuinely mean UTC.
date— the queried datetimezone— the timezone used ("UTC"when none was given)primary_phase—:new_moon,:first_quarter,:full_moon,:third_quarteroffset— days from the primary phase, -4 to +4phase_time— UTC Time of the primary phase eventname— human readable, like"Full Moon+2"short_name— abbreviated, like"FM+2"id— legacy integer id for this phase/offset, orniloutside -4..+4primary?— true when offset is 0waxing?— true for New Moon and 1st Quarter phaseswaning?— true for Full Moon and 3rd Quarter phases
Note the offset range is ±4, not ±3. Quarter intervals run from about 6.3 to
8.2 days, so a date can sit four days from the nearest primary phase — over
2006–2049 that happens on 6.4% of days. Code that enumerates phase names must
cover all 36 combinations; detailed_collection returns them.
Moon phases are global events occurring at specific UTC instants, so the same phase falls on different calendar dates depending on the timezone:
# Full Moon at 2025-01-13 22:27 UTC
LunarPhases.for_date(Date.new(2025, 1, 13), "Europe/London").name # => "Full Moon"
LunarPhases.for_date(Date.new(2025, 1, 13), "Australia/Brisbane").name # => "Full Moon-1"
LunarPhases.for_date(Date.new(2025, 1, 14), "Australia/Brisbane").name # => "Full Moon"for_date and for_datetime raise ArgumentError for a date outside the
supported range; for_datetime also raises for an unknown timezone. When
calling from somewhere that must not raise — a model callback, a bulk backfill —
guard with covers? first, or rescue.
for_id resolves the integer phase ids used by existing CatchLog / FleetManager
databases (0 New Moon … 14 Full Moon … 38 New Moon-4), covering all 36
phase/offset combinations. Aggregate filter values (32–35 — "All Full Moon" and
friends) are query-side concepts and are deliberately not included.
Moon phase data derived from tables published by the Astronomical Applications Department of the U.S. Naval Observatory.
MIT