From c2fa8aa00d616f20bbff707c49c6e2fde3fd2e6a Mon Sep 17 00:00:00 2001 From: "nikita.hohlov" Date: Fri, 4 Sep 2026 06:39:23 -0300 Subject: [PATCH] feat: state, window and quiet rules Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw --- core/app/Providers/AppServiceProvider.php | 6 +- core/app/Scheduling/QuietRule.php | 36 +++++++ core/app/Scheduling/StateResolver.php | 39 ++++++++ core/app/Scheduling/Window.php | 18 ++++ core/app/Scheduling/WindowResolver.php | 55 +++++++++++ core/tests/Unit/Scheduling/QuietRuleTest.php | 83 ++++++++++++++++ .../Unit/Scheduling/StateResolverTest.php | 89 +++++++++++++++++ .../Unit/Scheduling/WindowResolverTest.php | 95 +++++++++++++++++++ 8 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 core/app/Scheduling/QuietRule.php create mode 100644 core/app/Scheduling/StateResolver.php create mode 100644 core/app/Scheduling/Window.php create mode 100644 core/app/Scheduling/WindowResolver.php create mode 100644 core/tests/Unit/Scheduling/QuietRuleTest.php create mode 100644 core/tests/Unit/Scheduling/StateResolverTest.php create mode 100644 core/tests/Unit/Scheduling/WindowResolverTest.php diff --git a/core/app/Providers/AppServiceProvider.php b/core/app/Providers/AppServiceProvider.php index 452e6b6..878c9b6 100644 --- a/core/app/Providers/AppServiceProvider.php +++ b/core/app/Providers/AppServiceProvider.php @@ -11,7 +11,11 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(\App\Scheduling\WindowResolver::class, fn () => new \App\Scheduling\WindowResolver( + config('hado.fallback_day_minutes'), + config('hado.fallback_hour_minutes'), + config('hado.fallback_hours'), + )); } /** diff --git a/core/app/Scheduling/QuietRule.php b/core/app/Scheduling/QuietRule.php new file mode 100644 index 0000000..a2de55b --- /dev/null +++ b/core/app/Scheduling/QuietRule.php @@ -0,0 +1,36 @@ + $this->nextCascadePoint($event, $user, $due, $now), + EventState::Today => $now->startOfHour()->addHours(2), + EventState::Overdue => $now->startOfDay()->addDays(2), + default => $now, + }; + } + + private function nextCascadePoint(Event $event, User $user, Due $due, CarbonImmutable $now): CarbonImmutable + { + $today = $now->startOfDay(); + foreach (Cascade::points($this->due->fireStart($event, $user), $due->day) as $point) { + if ($point->gt($today)) { + return $point; + } + } + + return $this->due->todayStart($due, $user); + } +} diff --git a/core/app/Scheduling/StateResolver.php b/core/app/Scheduling/StateResolver.php new file mode 100644 index 0000000..d9f41b8 --- /dev/null +++ b/core/app/Scheduling/StateResolver.php @@ -0,0 +1,39 @@ +due->resolve($event, $user); + + if ($now->gte($due->moment)) { + return $event->after_due === AfterDue::Keep ? EventState::Overdue : EventState::Expired; + } + + if ($now->gte($this->due->todayStart($due, $user))) { + return EventState::Today; + } + + $fireStart = $this->due->fireStart($event, $user); + if ($fireStart->lt($due->day) && $now->gte($fireStart)) { + return EventState::Preparing; + } + + return EventState::Scheduled; + } +} diff --git a/core/app/Scheduling/Window.php b/core/app/Scheduling/Window.php new file mode 100644 index 0000000..d0545e3 --- /dev/null +++ b/core/app/Scheduling/Window.php @@ -0,0 +1,18 @@ + $fallbackHours */ + public function __construct( + private readonly int $dayMarginMinutes, + private readonly int $hourMarginMinutes, + private readonly array $fallbackHours, + ) {} + + /** Окно, открытое в момент $now, или null (тихие часы / срок прошёл / состояние не напоминает). */ + public function current(EventState $state, User $user, Due $due, CarbonImmutable $now): ?Window + { + $activeStart = $user->activeStart($now); + $activeEnd = $user->activeEnd($now); + if ($now->lt($activeStart) || $now->gte($activeEnd)) { + return null; + } + + return match ($state) { + EventState::Preparing, EventState::Overdue => new Window( + $activeStart, $activeEnd, true, $activeEnd->subMinutes($this->dayMarginMinutes), + ), + EventState::Today => $this->hourly($due, $now, $activeEnd), + default => null, + }; + } + + private function hourly(Due $due, CarbonImmutable $now, CarbonImmutable $activeEnd): ?Window + { + $start = $now->startOfHour(); + $end = $start->addHour()->min($activeEnd); + + $truncated = false; + if ($due->moment->gt($start) && $due->moment->lt($end)) { + $end = $due->moment; + $truncated = true; + } + + if ($now->gte($end)) { + return null; + } + + $fallbackAllowed = $truncated || in_array((int) $start->format('G'), $this->fallbackHours, true); + + return new Window($start, $end, $fallbackAllowed, $end->subMinutes($this->hourMarginMinutes)); + } +} diff --git a/core/tests/Unit/Scheduling/QuietRuleTest.php b/core/tests/Unit/Scheduling/QuietRuleTest.php new file mode 100644 index 0000000..a38110b --- /dev/null +++ b/core/tests/Unit/Scheduling/QuietRuleTest.php @@ -0,0 +1,83 @@ +user = new User(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); + $this->due = new DueResolver; + $this->rule = new QuietRule($this->due); + } + + private function event(string $due, string $fireOn, string $time = '24:00:00'): Event + { + return new Event([ + 'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn, + 'after_due' => AfterDue::Keep, 'state' => EventState::Preparing, 'payload' => ['title' => 't'], + ]); + } + + private function at(string $s): CarbonImmutable + { + return CarbonImmutable::parse($s, 'UTC'); + } + + public function test_preparing_ack_is_quiet_until_next_cascade_point(): void + { + $e = $this->event('2026-09-30', '2026-08-31'); // точки: 30,15,7,3,1 → 31.08, 15.09, 23.09, 27.09, 29.09 + $due = $this->due->resolve($e, $this->user); + + $q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-02 10:00')); + $this->assertSame('2026-09-15 00:00', $q->format('Y-m-d H:i')); + + $q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-15 10:00')); + $this->assertSame('2026-09-23 00:00', $q->format('Y-m-d H:i'), 'на самой точке — до следующей'); + + $q = $this->rule->afterAck($e, $this->user, EventState::Preparing, $due, $this->at('2026-09-29 10:00')); + $this->assertSame('2026-09-30 00:00', $q->format('Y-m-d H:i'), 'точек больше нет — до старта дня срока'); + } + + public function test_today_ack_skips_the_next_hour(): void + { + $e = $this->event('2026-09-30', '2026-09-30'); + $due = $this->due->resolve($e, $this->user); + + $q = $this->rule->afterAck($e, $this->user, EventState::Today, $due, $this->at('2026-09-30 11:17')); + $this->assertSame('2026-09-30 13:00', $q->format('Y-m-d H:i')); + } + + public function test_overdue_ack_skips_tomorrow(): void + { + $e = $this->event('2026-09-30', '2026-09-30'); + $due = $this->due->resolve($e, $this->user); + + $q = $this->rule->afterAck($e, $this->user, EventState::Overdue, $due, $this->at('2026-10-05 15:00')); + $this->assertSame('2026-10-07 00:00', $q->format('Y-m-d H:i')); + } + + public function test_scheduled_ack_changes_nothing(): void + { + $e = $this->event('2026-09-30', '2026-09-30'); + $due = $this->due->resolve($e, $this->user); + $now = $this->at('2026-09-01 15:00'); + + $this->assertTrue($this->rule->afterAck($e, $this->user, EventState::Scheduled, $due, $now)->eq($now)); + } +} diff --git a/core/tests/Unit/Scheduling/StateResolverTest.php b/core/tests/Unit/Scheduling/StateResolverTest.php new file mode 100644 index 0000000..6983bd2 --- /dev/null +++ b/core/tests/Unit/Scheduling/StateResolverTest.php @@ -0,0 +1,89 @@ +user = new User(['login' => 'n', 'tz' => 'America/Montevideo', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); + $this->resolver = new StateResolver(new DueResolver); + } + + private function event(string $due, string $fireOn, AfterDue $afterDue = AfterDue::Keep, string $time = '24:00:00'): Event + { + return new Event([ + 'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn, + 'after_due' => $afterDue, 'state' => EventState::Scheduled, 'payload' => ['title' => 't'], + ]); + } + + private function at(string $local): CarbonImmutable + { + return CarbonImmutable::parse($local, 'America/Montevideo'); + } + + public function test_deadline_walks_through_states(): void + { + $e = $this->event('2026-09-30', '2026-08-31', AfterDue::Keep); + + $this->assertSame(EventState::Scheduled, $this->resolver->resolve($e, $this->user, $this->at('2026-08-30 23:59'))); + $this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-08-31 00:00'))); + $this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-09-29 23:59'))); + $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-30 00:00'))); + $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-30 23:59'))); + $this->assertSame(EventState::Overdue, $this->resolver->resolve($e, $this->user, $this->at('2026-10-01 00:00'))); + } + + public function test_day_of_never_prepares_and_expires(): void + { + $e = $this->event('2026-08-14', '2026-08-14', AfterDue::Expire); + + $this->assertSame(EventState::Scheduled, $this->resolver->resolve($e, $this->user, $this->at('2026-08-13 23:59'))); + $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-08-14 00:00'))); + $this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-08-15 00:00'))); + } + + public function test_timed_moment_ends_today_mid_day(): void + { + $e = $this->event('2026-09-10', '2026-09-03', AfterDue::Expire, '14:00:00'); + + $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 13:59'))); + $this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 14:00'))); + } + + public function test_early_moment_starts_today_the_evening_before(): void + { + $e = $this->event('2026-09-10', '2026-09-03', AfterDue::Expire, '07:00:00'); + + $this->assertSame(EventState::Preparing, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 19:59'))); + $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 20:00'))); + $this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 07:00'))); + } + + public function test_fixed_flight_uses_user_timezone(): void + { + $e = new Event([ + 'due_mode' => DueMode::Fixed, 'due_instant' => CarbonImmutable::parse('2026-09-10T08:15:00+02:00'), + 'fire_on' => '2026-09-03', 'after_due' => AfterDue::Expire, 'state' => EventState::Scheduled, 'payload' => ['title' => 't'], + ]); + // 03:15 по Монтевидео → раньше активных часов → today с 20:00 накануне + $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-09 20:00'))); + $this->assertSame(EventState::Today, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 03:14'))); + $this->assertSame(EventState::Expired, $this->resolver->resolve($e, $this->user, $this->at('2026-09-10 03:15'))); + } +} diff --git a/core/tests/Unit/Scheduling/WindowResolverTest.php b/core/tests/Unit/Scheduling/WindowResolverTest.php new file mode 100644 index 0000000..0c2a244 --- /dev/null +++ b/core/tests/Unit/Scheduling/WindowResolverTest.php @@ -0,0 +1,95 @@ +user = new User(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); + $this->windows = new WindowResolver(30, 10, [9, 14, 20]); + } + + private function at(string $s): CarbonImmutable + { + return CarbonImmutable::parse($s, 'UTC'); + } + + private function wholeDay(string $ymd): Due + { + $day = $this->at($ymd); + return new Due($day, $day->addDay()); + } + + public function test_outside_active_hours_there_is_no_window(): void + { + $due = $this->wholeDay('2026-09-30'); + $this->assertNull($this->windows->current(EventState::Preparing, $this->user, $due, $this->at('2026-09-20 08:59'))); + $this->assertNull($this->windows->current(EventState::Preparing, $this->user, $due, $this->at('2026-09-20 22:00'))); + $this->assertNull($this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 23:00'))); + } + + public function test_preparing_and_overdue_have_one_daily_window(): void + { + $due = $this->wholeDay('2026-09-30'); + foreach ([EventState::Preparing, EventState::Overdue] as $state) { + $w = $this->windows->current($state, $this->user, $due, $this->at('2026-09-20 12:34')); + $this->assertSame('2026-09-20 09:00', $w->start->format('Y-m-d H:i')); + $this->assertSame('2026-09-20 22:00', $w->end->format('Y-m-d H:i')); + $this->assertTrue($w->fallbackAllowed); + $this->assertSame('2026-09-20 21:30', $w->fallbackAt->format('Y-m-d H:i')); + } + } + + public function test_today_has_hourly_windows_with_fallback_only_in_listed_hours(): void + { + $due = $this->wholeDay('2026-09-30'); + + $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 09:20')); + $this->assertSame('2026-09-30 09:00', $w->start->format('Y-m-d H:i')); + $this->assertSame('2026-09-30 10:00', $w->end->format('Y-m-d H:i')); + $this->assertTrue($w->fallbackAllowed); + $this->assertSame('2026-09-30 09:50', $w->fallbackAt->format('Y-m-d H:i')); + + $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:05')); + $this->assertSame('2026-09-30 11:00', $w->start->format('Y-m-d H:i')); + $this->assertFalse($w->fallbackAllowed); + + $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 21:59')); + $this->assertSame('2026-09-30 22:00', $w->end->format('Y-m-d H:i')); + } + + public function test_today_window_is_truncated_by_due_moment_and_becomes_fallback(): void + { + $day = $this->at('2026-09-30'); + + $due = new Due($day, $day->setTime(9, 30)); + $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 09:05')); + $this->assertSame('2026-09-30 09:30', $w->end->format('Y-m-d H:i')); + $this->assertSame('2026-09-30 09:20', $w->fallbackAt->format('Y-m-d H:i')); + + $due = new Due($day, $day->setTime(11, 15)); + $w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:02')); + $this->assertSame('2026-09-30 11:15', $w->end->format('Y-m-d H:i')); + $this->assertTrue($w->fallbackAllowed, 'обрезанное окно всегда fallback-окно'); + $this->assertSame('2026-09-30 11:05', $w->fallbackAt->format('Y-m-d H:i')); + } + + public function test_no_window_once_due_moment_passed(): void + { + $day = $this->at('2026-09-30'); + $due = new Due($day, $day->setTime(11, 15)); + $this->assertNull($this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:20'))); + } +}