diff --git a/core/app/Models/Concerns/StoresUtc.php b/core/app/Models/Concerns/StoresUtc.php new file mode 100644 index 0000000..7a08b04 --- /dev/null +++ b/core/app/Models/Concerns/StoresUtc.php @@ -0,0 +1,23 @@ +getCasts()[$key] ?? null, ['datetime', 'immutable_datetime'], true)) { + $value = CarbonImmutable::instance($value)->utc(); + } + + return parent::setAttribute($key, $value); + } +} diff --git a/core/app/Models/Delivery.php b/core/app/Models/Delivery.php index 2f19e63..766bfd3 100644 --- a/core/app/Models/Delivery.php +++ b/core/app/Models/Delivery.php @@ -3,11 +3,14 @@ namespace App\Models; use App\Enums\DeliveryResult; +use App\Models\Concerns\StoresUtc; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Delivery extends Model { + use StoresUtc; + protected $guarded = []; protected $attributes = ['meta' => '{}']; diff --git a/core/app/Models/Event.php b/core/app/Models/Event.php index d17c244..d74ee4b 100644 --- a/core/app/Models/Event.php +++ b/core/app/Models/Event.php @@ -5,12 +5,15 @@ namespace App\Models; use App\Enums\AfterDue; use App\Enums\DueMode; use App\Enums\EventState; +use App\Models\Concerns\StoresUtc; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class Event extends Model { + use StoresUtc; + protected $guarded = []; protected function casts(): array diff --git a/core/app/Scheduling/Cascade.php b/core/app/Scheduling/Cascade.php new file mode 100644 index 0000000..61e58c1 --- /dev/null +++ b/core/app/Scheduling/Cascade.php @@ -0,0 +1,27 @@ + + */ + public static function points(CarbonImmutable $fireStart, CarbonImmutable $dueDay): array + { + $d = (int) round($fireStart->diffInDays($dueDay)); + $points = []; + while ($d >= 1) { + $points[] = $dueDay->subDays($d); + $d = intdiv($d, 2); + } + + return $points; + } +} diff --git a/core/app/Scheduling/Due.php b/core/app/Scheduling/Due.php new file mode 100644 index 0000000..5148de9 --- /dev/null +++ b/core/app/Scheduling/Due.php @@ -0,0 +1,16 @@ +due_mode) { + DueMode::Fixed => $this->fixed($event, $user), + DueMode::Local => $this->local($event, $user), + }; + } + + public function fireStart(Event $event, User $user): CarbonImmutable + { + return self::localMidnight($event->fire_on->format('Y-m-d'), $user->tz); + } + + /** + * Старт дня срока. Обычно локальная полночь дня срока. Если момент срока не + * позже начала активных часов, первое окно дня уже опоздало бы — тогда старт + * за два часа до тихих часов накануне (два последних часовых окна вечера). + */ + public function todayStart(Due $due, User $user): CarbonImmutable + { + if ($due->moment->lte($user->activeStart($due->day))) { + return $user->activeEnd($due->day->subDay())->subHours(2); + } + + return $due->day; + } + + public static function localMidnight(string $ymd, string $tz): CarbonImmutable + { + return CarbonImmutable::createFromFormat('Y-m-d', $ymd, $tz)->startOfDay(); + } + + private function local(Event $event, User $user): Due + { + $day = self::localMidnight($event->due_date->format('Y-m-d'), $user->tz); + $moment = $event->due_time === self::WHOLE_DAY + ? $day->addDay() + : $day->setTimeFromTimeString($event->due_time); + + return new Due($day, $moment); + } + + private function fixed(Event $event, User $user): Due + { + $moment = $event->due_instant->setTimezone($user->tz); + + return new Due($moment->startOfDay(), $moment); + } +} diff --git a/core/tests/Feature/SchemaTest.php b/core/tests/Feature/SchemaTest.php index 182c030..455bde0 100644 --- a/core/tests/Feature/SchemaTest.php +++ b/core/tests/Feature/SchemaTest.php @@ -75,6 +75,29 @@ class SchemaTest extends TestCase $this->assertSame(ChannelType::Web, $user->channels()->first()->type); } + public function test_datetimes_are_stored_as_utc(): void + { + $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); + $user = User::create(['login' => 'nikita', 'tz' => 'Asia/Shanghai', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); + + $event = Event::create([ + 'source_id' => $source->id, + 'user_id' => $user->id, + 'source_ref' => 'person:42', + 'topic' => 'birthday:2026', + 'due_mode' => DueMode::Local, + 'due_date' => '2026-08-14', + 'due_time' => '24:00:00', + 'fire_on' => '2026-08-14', + 'after_due' => AfterDue::Expire, + 'payload' => ['title' => 'ДР'], + 'state' => EventState::Scheduled, + 'quiet_until' => CarbonImmutable::parse('2026-09-10T10:00:00', 'Asia/Shanghai'), + ])->fresh(); + + $this->assertSame('2026-09-10T02:00:00+00:00', $event->quiet_until->utc()->toIso8601String()); + } + public function test_state_helpers(): void { $this->assertTrue(EventState::Done->isTerminal()); diff --git a/core/tests/Unit/Scheduling/CascadeTest.php b/core/tests/Unit/Scheduling/CascadeTest.php new file mode 100644 index 0000000..dbb9d98 --- /dev/null +++ b/core/tests/Unit/Scheduling/CascadeTest.php @@ -0,0 +1,41 @@ + (int) round($p->diffInDays($due)), $points); + } + + public function test_no_preparation_gives_no_points(): void + { + $due = CarbonImmutable::parse('2026-08-14', 'UTC'); + $this->assertSame([], Cascade::points($due, $due)); + } + + public function test_halving_points(): void + { + $due = CarbonImmutable::parse('2026-09-30', 'UTC'); + + $this->assertSame([1], $this->daysBefore(Cascade::points($due->subDay(), $due), $due)); + $this->assertSame([7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(7), $due), $due)); + $this->assertSame([30, 15, 7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(30), $due), $due)); + $this->assertSame([60, 30, 15, 7, 3, 1], $this->daysBefore(Cascade::points($due->subDays(60), $due), $due)); + $this->assertSame([180, 90, 45, 22, 11, 5, 2, 1], $this->daysBefore(Cascade::points($due->subDays(180), $due), $due)); + } + + public function test_points_are_local_midnights_ascending(): void + { + $due = CarbonImmutable::parse('2026-09-30', 'Asia/Shanghai'); + $points = Cascade::points($due->subDays(7), $due); + + $this->assertSame('2026-09-23 00:00 Asia/Shanghai', $points[0]->format('Y-m-d H:i e')); + $this->assertSame('2026-09-29 00:00 Asia/Shanghai', $points[2]->format('Y-m-d H:i e')); + } +} diff --git a/core/tests/Unit/Scheduling/DueResolverTest.php b/core/tests/Unit/Scheduling/DueResolverTest.php new file mode 100644 index 0000000..4b5d9db --- /dev/null +++ b/core/tests/Unit/Scheduling/DueResolverTest.php @@ -0,0 +1,99 @@ + 'n', 'tz' => $tz, 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); + } + + private function local(string $date, string $time = '24:00:00', ?string $fireOn = null): Event + { + return new Event([ + 'due_mode' => DueMode::Local, 'due_date' => $date, 'due_time' => $time, + 'fire_on' => $fireOn ?? $date, 'after_due' => AfterDue::Expire, + 'state' => EventState::Scheduled, 'payload' => ['title' => 't'], + ]); + } + + private function fixed(string $instant, string $fireOn): Event + { + return new Event([ + 'due_mode' => DueMode::Fixed, 'due_instant' => CarbonImmutable::parse($instant), + 'fire_on' => $fireOn, 'after_due' => AfterDue::Expire, + 'state' => EventState::Scheduled, 'payload' => ['title' => 't'], + ]); + } + + public function test_whole_day_local_event(): void + { + $due = (new DueResolver)->resolve($this->local('2026-08-14'), $this->user()); + + $this->assertSame('2026-08-14 00:00 America/Montevideo', $due->day->format('Y-m-d H:i e')); + $this->assertSame('2026-08-15 00:00 America/Montevideo', $due->moment->format('Y-m-d H:i e')); + } + + public function test_timed_local_event(): void + { + $due = (new DueResolver)->resolve($this->local('2026-09-10', '14:00:00'), $this->user()); + + $this->assertSame('2026-09-10 00:00', $due->day->format('Y-m-d H:i')); + $this->assertSame('2026-09-10 14:00', $due->moment->format('Y-m-d H:i')); + } + + public function test_local_event_follows_user_timezone(): void + { + $event = $this->local('2026-09-10', '14:00:00'); + + $uy = (new DueResolver)->resolve($event, $this->user('America/Montevideo')); + $cn = (new DueResolver)->resolve($event, $this->user('Asia/Shanghai')); + + $this->assertSame('2026-09-10 14:00', $uy->moment->format('Y-m-d H:i')); + $this->assertSame('2026-09-10 14:00', $cn->moment->format('Y-m-d H:i')); + $this->assertNotEquals($uy->moment->utc()->toIso8601String(), $cn->moment->utc()->toIso8601String()); + } + + public function test_fixed_event_is_converted_into_user_timezone(): void + { + // Вылет из Мадрида 08:15+02:00 = 06:15Z = 03:15 в Монтевидео (-03:00) + $due = (new DueResolver)->resolve($this->fixed('2026-09-10T08:15:00+02:00', '2026-09-03'), $this->user()); + + $this->assertSame('2026-09-10 03:15 America/Montevideo', $due->moment->format('Y-m-d H:i e')); + $this->assertSame('2026-09-10 00:00', $due->day->format('Y-m-d H:i')); + } + + public function test_fire_start_is_local_midnight(): void + { + $start = (new DueResolver)->fireStart($this->local('2026-09-30', '24:00:00', '2026-08-31'), $this->user()); + $this->assertSame('2026-08-31 00:00 America/Montevideo', $start->format('Y-m-d H:i e')); + } + + public function test_today_starts_at_midnight_for_normal_moment(): void + { + $r = new DueResolver; + $due = $r->resolve($this->local('2026-09-10', '14:00:00'), $this->user()); + $this->assertSame('2026-09-10 00:00', $r->todayStart($due, $this->user())->format('Y-m-d H:i')); + } + + public function test_today_starts_the_evening_before_for_early_moment(): void + { + $r = new DueResolver; + $due = $r->resolve($this->local('2026-09-10', '07:00:00'), $this->user()); + // quiet_start 22:00 − 2h = 20:00 накануне + $this->assertSame('2026-09-09 20:00', $r->todayStart($due, $this->user())->format('Y-m-d H:i')); + + $exactlyAtActiveStart = $r->resolve($this->local('2026-09-10', '09:00:00'), $this->user()); + $this->assertSame('2026-09-09 20:00', $r->todayStart($exactlyAtActiveStart, $this->user())->format('Y-m-d H:i')); + } +}