moon_phase_tracker 1.3.2

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.
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Add the lib directory to the load path
5
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
+
7
+ require 'moon_phase_tracker'
8
+
9
+ puts '=== Moon Phase Tracker - 8 Phases Example ==='
10
+ puts 'Demonstrating all 8 lunar phases (4 major + 4 intermediate)'
11
+ puts ''
12
+
13
+ begin
14
+ # Example 1: Get all 8 phases for August 2025
15
+ puts '🌙 All phases for August 2025:'
16
+ puts '=' * 40
17
+ phases = MoonPhaseTracker.all_phases_for_month(2025, 8)
18
+
19
+ if phases.any?
20
+ phases.each do |phase|
21
+ prefix = phase.interpolated ? '~ ' : ' '
22
+ puts "#{prefix}#{phase}"
23
+ end
24
+
25
+ major_count = phases.count { |p| !p.interpolated }
26
+ interpolated_count = phases.count(&:interpolated)
27
+
28
+ puts ''
29
+ puts "Total: #{phases.size} phases (#{major_count} major, #{interpolated_count} interpolated)"
30
+ puts '~ indicates interpolated phases'
31
+ else
32
+ puts 'No phases found for this period.'
33
+ end
34
+
35
+ puts ''
36
+ puts '-' * 50
37
+ puts ''
38
+
39
+ # Example 2: Compare 4 vs 8 phases
40
+ puts '🔍 Comparison: 4 Major Phases vs 8 Complete Phases'
41
+ puts '=' * 55
42
+
43
+ major_phases = MoonPhaseTracker.phases_for_month(2025, 8)
44
+ all_phases = MoonPhaseTracker.all_phases_for_month(2025, 8)
45
+
46
+ puts "Major phases only (#{major_phases.size}):"
47
+ major_phases.each { |phase| puts " #{phase}" }
48
+
49
+ puts ''
50
+ puts "All phases including interpolated (#{all_phases.size}):"
51
+ all_phases.each do |phase|
52
+ prefix = phase.interpolated ? '~ ' : ' '
53
+ puts "#{prefix}#{phase}"
54
+ end
55
+
56
+ puts ''
57
+ puts '-' * 50
58
+ puts ''
59
+
60
+ # Example 3: Show phase symbols
61
+ puts '🎭 Phase Symbols Reference'
62
+ puts '=' * 30
63
+ MoonPhaseTracker::Phase::PHASE_SYMBOLS.each do |type, symbol|
64
+ name = type.to_s.split('_').map(&:capitalize).join(' ')
65
+ puts "#{symbol} #{name}"
66
+ end
67
+
68
+ puts ''
69
+ puts '-' * 50
70
+ puts ''
71
+
72
+ # Example 4: Get phases from a specific date with 8 phases
73
+ puts '📅 8 Phases from a specific date (2025-08-01, 2 cycles)'
74
+ puts '=' * 60
75
+ date_phases = MoonPhaseTracker.all_phases_from_date('2025-08-01', 2)
76
+
77
+ if date_phases.any?
78
+ date_phases.each do |phase|
79
+ prefix = phase.interpolated ? '~ ' : ' '
80
+ puts "#{prefix}#{phase}"
81
+ end
82
+
83
+ major_count = date_phases.count { |p| !p.interpolated }
84
+ interpolated_count = date_phases.count(&:interpolated)
85
+
86
+ puts ''
87
+ puts "Total: #{date_phases.size} phases over 2 lunar cycles"
88
+ puts "(#{major_count} major, #{interpolated_count} interpolated)"
89
+ end
90
+ rescue MoonPhaseTracker::Error => e
91
+ puts "Error: #{e.message}"
92
+ rescue StandardError => e
93
+ puts "Unexpected error: #{e.message}"
94
+ puts 'This example requires an active internet connection to fetch moon phase data.'
95
+ end
96
+
97
+ puts ''
98
+ puts 'Note: This example uses real astronomical data from the US Naval Observatory.'
99
+ puts 'Interpolated phases (~) are calculated estimates between official phases.'
100
+ puts 'All dates are in ISO 8601 format (YYYY-MM-DD) and times are in UTC.'
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example demonstrating rate limiting functionality in MoonPhaseTracker
5
+ # This example shows different ways to configure and use rate limiting
6
+
7
+ require_relative "../lib/moon_phase_tracker"
8
+
9
+ puts "=== MoonPhaseTracker Rate Limiting Examples ==="
10
+ puts
11
+
12
+ puts "1. Default Rate Limiting (1 request/second):"
13
+ puts "=" * 50
14
+
15
+ tracker = MoonPhaseTracker::Tracker.new
16
+ puts "Rate limit configuration: #{tracker.rate_limit_info}"
17
+
18
+ start_time = Time.now
19
+ puts "Making first request..."
20
+ phases1 = tracker.phases_for_year(2025)
21
+ first_request_time = Time.now - start_time
22
+
23
+ puts "Making second request (should be rate limited)..."
24
+ phases2 = tracker.phases_for_year(2024)
25
+ total_time = Time.now - start_time
26
+
27
+ puts "First request took: #{first_request_time.round(2)}s"
28
+ puts "Total time for both requests: #{total_time.round(2)}s"
29
+ puts "Rate limiting delay: #{(total_time - first_request_time).round(2)}s"
30
+ puts
31
+
32
+ puts "2. Custom Rate Limiting (3 requests/second, burst size 2):"
33
+ puts "=" * 55
34
+
35
+ custom_rate_limiter = MoonPhaseTracker::RateLimiter.new(
36
+ requests_per_second: 3.0,
37
+ burst_size: 2
38
+ )
39
+
40
+ custom_tracker = MoonPhaseTracker::Tracker.new(rate_limiter: custom_rate_limiter)
41
+ puts "Rate limit configuration: #{custom_tracker.rate_limit_info}"
42
+
43
+ start_time = Time.now
44
+ puts "Making burst requests (should be immediate)..."
45
+ custom_tracker.phases_for_year(2025)
46
+ custom_tracker.phases_for_year(2024)
47
+ burst_time = Time.now - start_time
48
+
49
+ puts "Making third request (should be rate limited)..."
50
+ custom_tracker.phases_for_year(2023)
51
+ total_time = Time.now - start_time
52
+
53
+ puts "Burst requests (2) took: #{burst_time.round(2)}s"
54
+ puts "Total time for 3 requests: #{total_time.round(2)}s"
55
+ puts "Rate limiting delay for 3rd request: #{(total_time - burst_time).round(2)}s"
56
+ puts
57
+
58
+ puts "3. Environment Variable Configuration:"
59
+ puts "=" * 42
60
+
61
+ ENV["MOON_PHASE_RATE_LIMIT"] = "2.5"
62
+ ENV["MOON_PHASE_BURST_SIZE"] = "3"
63
+
64
+ env_tracker = MoonPhaseTracker::Tracker.new
65
+ puts "Rate limit configuration from ENV: #{env_tracker.rate_limit_info}"
66
+ puts
67
+
68
+ puts "4. Disabled Rate Limiting:"
69
+ puts "=" * 27
70
+
71
+ ENV["MOON_PHASE_RATE_LIMIT"] = "0"
72
+
73
+ disabled_tracker = MoonPhaseTracker::Tracker.new
74
+ puts "Rate limit configuration: #{disabled_tracker.rate_limit_info || 'DISABLED'}"
75
+
76
+ start_time = Time.now
77
+ puts "Making multiple requests without rate limiting..."
78
+ 5.times do |i|
79
+ disabled_tracker.phases_for_year(2025 - i)
80
+ end
81
+ total_time = Time.now - start_time
82
+
83
+ puts "5 requests took: #{total_time.round(2)}s (no rate limiting)"
84
+ puts
85
+
86
+ puts "5. Rate Limit Status Monitoring:"
87
+ puts "=" * 34
88
+
89
+ rate_limiter = MoonPhaseTracker::RateLimiter.new(
90
+ requests_per_second: 1.0,
91
+ burst_size: 2
92
+ )
93
+
94
+ puts "Initial status: #{rate_limiter.configuration}"
95
+ puts "Can proceed? #{rate_limiter.can_proceed?}"
96
+
97
+ puts "\nMaking first request..."
98
+ rate_limiter.throttle
99
+ puts "After 1st request: #{rate_limiter.configuration}"
100
+ puts "Can proceed? #{rate_limiter.can_proceed?}"
101
+
102
+ puts "\nMaking second request..."
103
+ rate_limiter.throttle
104
+ puts "After 2nd request: #{rate_limiter.configuration}"
105
+ puts "Can proceed? #{rate_limiter.can_proceed?}"
106
+
107
+ puts "\nWaiting for token replenishment..."
108
+ sleep(1.1)
109
+ puts "After waiting: #{rate_limiter.configuration}"
110
+ puts "Can proceed? #{rate_limiter.can_proceed?}"
111
+
112
+ puts
113
+ puts "=== Rate Limiting Examples Complete ==="
114
+
115
+ # Clean up environment variables
116
+ ENV.delete("MOON_PHASE_RATE_LIMIT")
117
+ ENV.delete("MOON_PHASE_BURST_SIZE")
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/moon_phase_tracker'
5
+
6
+ puts '=== Moon Phase Tracker - Usage Examples =='
7
+ puts
8
+
9
+ tracker = MoonPhaseTracker::Tracker.new
10
+
11
+ begin
12
+ puts '1. Moon phases for August 2025:'
13
+ puts '-' * 40
14
+ august_phases = MoonPhaseTracker.phases_for_month(2025, 8)
15
+ puts tracker.format_phases(august_phases, "#{MoonPhaseTracker::Tracker.month_name(8)} 2025 Phases")
16
+ puts
17
+
18
+ puts '2. All moon phases in 2025 (first 8):'
19
+ puts '-' * 50
20
+ year_phases = MoonPhaseTracker.phases_for_year(2025)
21
+ puts tracker.format_phases(year_phases.first(8), 'First 8 phases of 2025')
22
+ puts
23
+
24
+ puts '3. Next 6 phases starting from 2025-08-01:'
25
+ puts '-' * 45
26
+ future_phases = MoonPhaseTracker.phases_from_date('2025-08-01', 6)
27
+ puts tracker.format_phases(future_phases, 'Upcoming phases')
28
+ puts
29
+
30
+ puts '4. Current month phases:'
31
+ puts '-' * 25
32
+ current_phases = tracker.current_month_phases
33
+ if current_phases.any?
34
+ puts tracker.format_phases(current_phases, 'Current month phases')
35
+ else
36
+ puts 'No phases found for the current month.'
37
+ end
38
+ puts
39
+
40
+ puts '5. Next moon phase:'
41
+ puts '-' * 25
42
+ next_phase = tracker.next_phase
43
+ if next_phase
44
+ puts next_phase
45
+ puts "Details: #{next_phase.to_h}"
46
+ else
47
+ puts 'Could not retrieve the next phase.'
48
+ end
49
+ puts
50
+
51
+ puts '6. Different phase representations:'
52
+ puts '-' * 42
53
+ if august_phases.any?
54
+ phase = august_phases.first
55
+ puts "String: #{phase}"
56
+ puts "Hash: #{phase.to_h}"
57
+ puts "Symbol: #{phase.symbol}"
58
+ puts "Formatted date: #{phase.formatted_date}"
59
+ puts "Formatted time: #{phase.formatted_time}"
60
+ end
61
+ rescue MoonPhaseTracker::NetworkError => e
62
+ puts "Network error: #{e.message}"
63
+ puts 'Please check your internet connection.'
64
+ rescue MoonPhaseTracker::APIError => e
65
+ puts "API error: #{e.message}"
66
+ puts 'The service may be temporarily unavailable.'
67
+ rescue MoonPhaseTracker::InvalidDateError => e
68
+ puts "Date error: #{e.message}"
69
+ rescue StandardError => e
70
+ puts "Unexpected error: #{e.message}"
71
+ end
72
+
73
+ puts
74
+ puts '=== End of Examples ==='
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "rate_limiter"
7
+
8
+ module MoonPhaseTracker
9
+ class Client
10
+ BASE_URL = "https://aa.usno.navy.mil/api/moon/phases"
11
+ TIMEOUT = 10
12
+
13
+ def initialize(rate_limiter: nil)
14
+ @uri_cache = {}
15
+ @rate_limiter = rate_limiter || create_default_rate_limiter
16
+ end
17
+
18
+ def phases_from_date(date, num_phases = 12)
19
+ validate_date_format!(date)
20
+ validate_num_phases!(num_phases)
21
+
22
+ params = { date: date, nump: num_phases }
23
+ make_request("#{BASE_URL}/date", params)
24
+ end
25
+
26
+ def phases_for_year(year)
27
+ validate_year!(year)
28
+
29
+ params = { year: year }
30
+ make_request("#{BASE_URL}/year", params)
31
+ end
32
+
33
+ def rate_limit_info
34
+ @rate_limiter&.configuration
35
+ end
36
+
37
+ private
38
+
39
+ def create_default_rate_limiter
40
+ return nil if ENV["MOON_PHASE_RATE_LIMIT"] == "0" || ENV["MOON_PHASE_RATE_LIMIT"] == "false"
41
+
42
+ RateLimiter.new
43
+ end
44
+
45
+ def make_request(endpoint, params = {})
46
+ uri = build_uri(endpoint, params)
47
+
48
+ @rate_limiter&.throttle
49
+
50
+ begin
51
+ response = fetch_with_timeout(uri)
52
+ parse_response(response)
53
+ rescue Net::TimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
54
+ raise NetworkError, "Request timeout: #{e.message}"
55
+ rescue Net::HTTPError, SocketError => e
56
+ raise NetworkError, "Network error: #{e.message}"
57
+ rescue JSON::ParserError => e
58
+ raise APIError, "Invalid JSON response: #{e.message}"
59
+ end
60
+ end
61
+
62
+ def build_uri(endpoint, params)
63
+ cache_key = "#{endpoint}_#{params.hash}"
64
+
65
+ @uri_cache[cache_key] ||= begin
66
+ uri = URI(endpoint)
67
+ uri.query = URI.encode_www_form(params) unless params.empty?
68
+ uri
69
+ end
70
+ end
71
+
72
+ def fetch_with_timeout(uri)
73
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
74
+ open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
75
+ request = Net::HTTP::Get.new(uri)
76
+ request["User-Agent"] = "MoonPhaseTracker/#{MoonPhaseTracker::VERSION}"
77
+
78
+ response = http.request(request)
79
+
80
+ unless response.is_a?(Net::HTTPSuccess)
81
+ raise APIError, "API request failed: #{response.code} - #{response.message}"
82
+ end
83
+
84
+ response
85
+ end
86
+ end
87
+
88
+ def parse_response(response)
89
+ data = JSON.parse(response.body)
90
+
91
+ raise APIError, "API error: #{data['error']}" if data["error"]
92
+
93
+ data
94
+ end
95
+
96
+ def validate_date_format!(date)
97
+ return if date.match?(/^\d{4}-\d{1,2}-\d{1,2}$/)
98
+
99
+ raise InvalidDateError, "Date must be in YYYY-MM-DD format"
100
+ end
101
+
102
+ def validate_num_phases!(num_phases)
103
+ return if num_phases.is_a?(Integer) && num_phases.between?(1, 99)
104
+
105
+ raise InvalidDateError, "Number of phases must be between 1 and 99"
106
+ end
107
+
108
+ def validate_year!(year)
109
+ current_year = Date.today.year
110
+
111
+ return if year.is_a?(Integer) && year.between?(1700, current_year + 10)
112
+
113
+ raise InvalidDateError, "Year must be between 1700 and #{current_year + 10}"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoonPhaseTracker
4
+ class Phase
5
+ class Comparator
6
+ def self.compare_phases(phase_a, phase_b)
7
+ return nil unless phases_comparable?(phase_a, phase_b)
8
+
9
+ date_comparison = compare_dates(phase_a.date, phase_b.date)
10
+ return date_comparison unless date_comparison.zero?
11
+
12
+ compare_times(phase_a.time, phase_b.time)
13
+ end
14
+
15
+ def self.in_month?(phase_date, year, month)
16
+ return false unless phase_date
17
+
18
+ phase_date.year == year && phase_date.month == month
19
+ end
20
+
21
+ def self.in_year?(phase_date, year)
22
+ return false unless phase_date
23
+
24
+ phase_date.year == year
25
+ end
26
+
27
+ def self.phases_comparable?(phase_a, phase_b)
28
+ phase_a.is_a?(Phase) && phase_b.is_a?(Phase)
29
+ end
30
+
31
+ def self.compare_dates(date_a, date_b)
32
+ date_a <=> date_b
33
+ end
34
+
35
+ def self.compare_times(time_a, time_b)
36
+ time_a <=> time_b
37
+ end
38
+
39
+ private_class_method :phases_comparable?, :compare_dates, :compare_times
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoonPhaseTracker
4
+ class Phase
5
+ class Formatter
6
+ INVALID_DATE_MESSAGE = "Invalid date"
7
+ INVALID_TIME_MESSAGE = "Invalid time"
8
+ DATE_FORMAT = "%Y-%m-%d"
9
+ TIME_FORMAT = "%H:%M"
10
+
11
+ def self.format_date(date)
12
+ return INVALID_DATE_MESSAGE unless date
13
+
14
+ date.strftime(DATE_FORMAT)
15
+ end
16
+
17
+ def self.format_time(time)
18
+ return INVALID_TIME_MESSAGE unless time
19
+
20
+ time.strftime(TIME_FORMAT)
21
+ end
22
+
23
+ def self.format_phase_description(name, symbol, date, time)
24
+ formatted_date = format_date(date)
25
+ formatted_time = format_time(time)
26
+
27
+ "#{symbol} #{name} - #{formatted_date} at #{formatted_time}"
28
+ end
29
+
30
+ def self.build_hash_representation(phase_attributes)
31
+ {
32
+ name: phase_attributes[:name],
33
+ phase_type: phase_attributes[:phase_type],
34
+ date: format_date(phase_attributes[:date]),
35
+ time: format_time(phase_attributes[:time]),
36
+ symbol: phase_attributes[:symbol],
37
+ iso_date: phase_attributes[:date]&.iso8601,
38
+ utc_time: phase_attributes[:time]&.utc&.iso8601,
39
+ interpolated: phase_attributes[:interpolated]
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoonPhaseTracker
4
+ class Phase
5
+ class Mapper
6
+ PHASE_NAMES = {
7
+ "New Moon" => :new_moon,
8
+ "First Quarter" => :first_quarter,
9
+ "Full Moon" => :full_moon,
10
+ "Last Quarter" => :last_quarter,
11
+ "Waxing Crescent" => :waxing_crescent,
12
+ "Waxing Gibbous" => :waxing_gibbous,
13
+ "Waning Gibbous" => :waning_gibbous,
14
+ "Waning Crescent" => :waning_crescent
15
+ }.freeze
16
+
17
+ PHASE_SYMBOLS = {
18
+ new_moon: "🌑",
19
+ waxing_crescent: "🌒",
20
+ first_quarter: "🌓",
21
+ waxing_gibbous: "🌔",
22
+ full_moon: "🌕",
23
+ waning_gibbous: "🌖",
24
+ last_quarter: "🌗",
25
+ waning_crescent: "🌘"
26
+ }.freeze
27
+
28
+ DEFAULT_PHASE_TYPE = :unknown
29
+ DEFAULT_SYMBOL = "🌘"
30
+
31
+ def self.map_phase_type(phase_name)
32
+ PHASE_NAMES[phase_name] || DEFAULT_PHASE_TYPE
33
+ end
34
+
35
+ def self.get_phase_symbol(phase_type)
36
+ PHASE_SYMBOLS[phase_type] || DEFAULT_SYMBOL
37
+ end
38
+
39
+ def self.available_phase_names
40
+ PHASE_NAMES.keys
41
+ end
42
+
43
+ def self.available_phase_types
44
+ PHASE_NAMES.values
45
+ end
46
+
47
+ def self.valid_phase_name?(phase_name)
48
+ PHASE_NAMES.key?(phase_name)
49
+ end
50
+
51
+ def self.valid_phase_type?(phase_type)
52
+ PHASE_SYMBOLS.key?(phase_type)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module MoonPhaseTracker
7
+ class Phase
8
+ class Parser
9
+ def self.build_date(phase_data)
10
+ year = phase_data["year"]
11
+ month = phase_data["month"]
12
+ day = phase_data["day"]
13
+
14
+ return nil unless valid_date_components?(year, month, day)
15
+
16
+ Date.new(year.to_i, month.to_i, day.to_i)
17
+ rescue Date::Error
18
+ nil
19
+ end
20
+
21
+ def self.parse_time(time_string)
22
+ return nil unless time_string
23
+
24
+ Time.parse("#{time_string} UTC")
25
+ rescue ArgumentError
26
+ nil
27
+ end
28
+
29
+ def self.valid_date_components?(year, month, day)
30
+ year && month && day
31
+ end
32
+
33
+ private_class_method :valid_date_components?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "phase/parser"
4
+ require_relative "phase/formatter"
5
+ require_relative "phase/comparator"
6
+ require_relative "phase/mapper"
7
+
8
+ module MoonPhaseTracker
9
+ class Phase
10
+ include Comparable
11
+
12
+ attr_reader :name, :date, :time, :phase_type, :interpolated
13
+
14
+ def initialize(phase_data, interpolated: false)
15
+ @name = phase_data["phase"]
16
+ @phase_type = Mapper.map_phase_type(@name)
17
+ @date = Parser.build_date(phase_data)
18
+ @time = Parser.parse_time(phase_data["time"])
19
+ @interpolated = interpolated
20
+ end
21
+
22
+ def formatted_date
23
+ Formatter.format_date(@date)
24
+ end
25
+
26
+ def formatted_time
27
+ Formatter.format_time(@time)
28
+ end
29
+
30
+ def symbol
31
+ Mapper.get_phase_symbol(@phase_type)
32
+ end
33
+
34
+ def to_s
35
+ Formatter.format_phase_description(@name, symbol, @date, @time)
36
+ end
37
+
38
+ def to_h
39
+ phase_attributes = {
40
+ name: @name,
41
+ phase_type: @phase_type,
42
+ date: @date,
43
+ time: @time,
44
+ symbol: symbol,
45
+ interpolated: @interpolated
46
+ }
47
+
48
+ Formatter.build_hash_representation(phase_attributes)
49
+ end
50
+
51
+ def <=>(other)
52
+ Comparator.compare_phases(self, other)
53
+ end
54
+
55
+ def in_month?(year, month)
56
+ Comparator.in_month?(@date, year, month)
57
+ end
58
+
59
+ def in_year?(year)
60
+ Comparator.in_year?(@date, year)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../phase"
4
+
5
+ module MoonPhaseTracker
6
+ class PhaseCalculator
7
+ class CycleEstimator
8
+ LUNAR_CYCLE_DAYS = 29.530588853
9
+ PHASE_INTERVAL_DAYS = LUNAR_CYCLE_DAYS / 4.0
10
+ DEFAULT_TIME = "12:00"
11
+
12
+ def estimate_next_cycle_phase(last_phase)
13
+ return nil unless valid_phase_for_estimation?(last_phase)
14
+
15
+ estimated_date = calculate_estimated_date(last_phase)
16
+ phase_data = build_estimated_phase_data(estimated_date, last_phase)
17
+
18
+ Phase.new(phase_data)
19
+ rescue Date::Error, ArgumentError => e
20
+ warn "Failed to estimate next cycle phase: #{e.class}"
21
+ nil
22
+ end
23
+
24
+ private
25
+
26
+ def valid_phase_for_estimation?(phase)
27
+ phase && phase.date
28
+ end
29
+
30
+ def calculate_estimated_date(last_phase)
31
+ last_phase.date + PHASE_INTERVAL_DAYS
32
+ end
33
+
34
+ def build_estimated_phase_data(estimated_date, reference_phase)
35
+ {
36
+ "phase" => "New Moon",
37
+ "year" => estimated_date.year,
38
+ "month" => estimated_date.month,
39
+ "day" => estimated_date.day,
40
+ "time" => determine_estimated_time(reference_phase)
41
+ }
42
+ end
43
+
44
+ def determine_estimated_time(reference_phase)
45
+ return reference_phase.time.strftime("%H:%M") if reference_phase.time
46
+
47
+ DEFAULT_TIME
48
+ end
49
+ end
50
+ end
51
+ end