From fcdc2054b54cea73cb61e21acd0dda1c2cdb7bc8 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 15 Apr 2026 01:44:39 -0300 Subject: [PATCH] feat(lifecycle): add QuietHours guard with 2h staleness limit Co-Authored-By: Claude Sonnet 4.6 --- .../captain/lifecycle/guards/quiet_hours.rb | 52 +++++++++++++++ .../lifecycle/guards/quiet_hours_spec.rb | 66 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 enterprise/app/services/captain/lifecycle/guards/quiet_hours.rb create mode 100644 spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb diff --git a/enterprise/app/services/captain/lifecycle/guards/quiet_hours.rb b/enterprise/app/services/captain/lifecycle/guards/quiet_hours.rb new file mode 100644 index 000000000..7e2da1ef6 --- /dev/null +++ b/enterprise/app/services/captain/lifecycle/guards/quiet_hours.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class Captain::Lifecycle::Guards::QuietHours < Captain::Lifecycle::Guards::Base + MAX_DELAY = 2.hours + + def check + config = Captain::Lifecycle::Config.for_account(@account) + return pass unless config.quiet_hours_enabled + return pass unless in_quiet_window?(@delivery.fire_at, config) + + delayed_to = next_valid_time(@delivery.fire_at, config) + delay = delayed_to - @delivery.fire_at + return skip('too_stale') if delay > MAX_DELAY + + reschedule(delayed_to) + end + + private + + def in_quiet_window?(time, config) + from_min = to_minutes(config.quiet_hours_from) + to_min = to_minutes(config.quiet_hours_to) + cur_min = (time.hour * 60) + time.min + + if from_min < to_min + # Same-day window (e.g. 14:00–18:00) + cur_min >= from_min && cur_min < to_min + else + # Crosses midnight (e.g. 23:00–08:00) + cur_min >= from_min || cur_min < to_min + end + end + + def next_valid_time(fire_at, config) + to_min = to_minutes(config.quiet_hours_to) + from_min = to_minutes(config.quiet_hours_from) + fire_min = (fire_at.hour * 60) + fire_at.min + + # Base target: today at quiet_hours_to + target = fire_at.beginning_of_day + to_min.minutes + + # If window crosses midnight AND fire_at is in the evening half (>= from), + # the window end ("to") is tomorrow morning. + target += 1.day if from_min > to_min && fire_min >= from_min + + target + end + + def to_minutes(time_value) + (time_value.hour * 60) + time_value.min + end +end diff --git a/spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb b/spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb new file mode 100644 index 000000000..15137c3c1 --- /dev/null +++ b/spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Captain::Lifecycle::Guards::QuietHours do + subject(:guard) { described_class.new(delivery) } + + let(:account) { create(:account) } + let(:reservation) { create(:captain_reservation, account: account) } + let(:delivery) do + create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, fire_at: fire_at) + end + + context 'when quiet hours disabled' do + let(:fire_at) { Time.zone.parse('2026-04-15 03:00:00') } + + before { create(:captain_lifecycle_config, account: account, quiet_hours_enabled: false) } + + it 'passes regardless of fire_at' do + expect(guard.check).to eq(action: :pass) + end + end + + context 'when quiet hours enabled (23:00-08:00)' do + before do + create(:captain_lifecycle_config, + account: account, + quiet_hours_enabled: true, + quiet_hours_from: '23:00', + quiet_hours_to: '08:00') + end + + context 'when fire_at is outside the window (14:00)' do + let(:fire_at) { Time.zone.parse('2026-04-15 14:00:00') } + + it('passes') { expect(guard.check).to eq(action: :pass) } + end + + context 'when fire_at is inside the window but close to end (07:45, delay 15min)' do + let(:fire_at) { Time.zone.parse('2026-04-15 07:45:00') } + + it 'reschedules to 08:00' do + result = guard.check + expect(result[:action]).to eq(:reschedule) + expect(result[:fire_at]).to eq(Time.zone.parse('2026-04-15 08:00:00')) + end + end + + context 'when fire_at is deep inside the window (03:00, delay 5h)' do + let(:fire_at) { Time.zone.parse('2026-04-15 03:00:00') } + + it 'skips with too_stale' do + expect(guard.check).to eq(action: :skip, reason: 'too_stale') + end + end + + context 'when fire_at is at 23:30 (delay until next 08:00 = 8.5h)' do + let(:fire_at) { Time.zone.parse('2026-04-15 23:30:00') } + + it 'skips with too_stale' do + expect(guard.check).to eq(action: :skip, reason: 'too_stale') + end + end + end +end