feat: due resolution and cascade math

Also: StoresUtc trait normalizes datetime casts to UTC (controller ruling).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 06:19:00 -03:00
parent 774e0bce1a
commit e48e48aa4a
9 changed files with 297 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\Concerns;
use Carbon\CarbonImmutable;
use DateTimeInterface;
/**
* Eloquent форматирует datetime без смещения, поэтому Carbon в чужом поясе
* записался бы как «те же цифры в UTC». Приводим к UTC при присвоении.
*/
trait StoresUtc
{
public function setAttribute($key, $value)
{
if ($value instanceof DateTimeInterface
&& in_array($this->getCasts()[$key] ?? null, ['datetime', 'immutable_datetime'], true)) {
$value = CarbonImmutable::instance($value)->utc();
}
return parent::setAttribute($key, $value);
}
}

View File

@@ -3,11 +3,14 @@
namespace App\Models; namespace App\Models;
use App\Enums\DeliveryResult; use App\Enums\DeliveryResult;
use App\Models\Concerns\StoresUtc;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Delivery extends Model class Delivery extends Model
{ {
use StoresUtc;
protected $guarded = []; protected $guarded = [];
protected $attributes = ['meta' => '{}']; protected $attributes = ['meta' => '{}'];

View File

@@ -5,12 +5,15 @@ namespace App\Models;
use App\Enums\AfterDue; use App\Enums\AfterDue;
use App\Enums\DueMode; use App\Enums\DueMode;
use App\Enums\EventState; use App\Enums\EventState;
use App\Models\Concerns\StoresUtc;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class Event extends Model class Event extends Model
{ {
use StoresUtc;
protected $guarded = []; protected $guarded = [];
protected function casts(): array protected function casts(): array

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Scheduling;
use Carbon\CarbonImmutable;
final class Cascade
{
/**
* Точки каскада между стартом и днём срока: старт, затем каждый раз, когда
* до срока остаётся вдвое меньше дней, до одного дня. Обе даты локальные
* полуночи в одном поясе. Возвращает локальные полуночи по возрастанию.
*
* @return list<CarbonImmutable>
*/
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;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Scheduling;
use Carbon\CarbonImmutable;
/** Срок события, приведённый к поясу пользователя. */
final class Due
{
public function __construct(
/** Локальная полночь дня срока. */
public readonly CarbonImmutable $day,
/** Момент срока. Для события «весь день» — следующая полночь. */
public readonly CarbonImmutable $moment,
) {}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Scheduling;
use App\Enums\DueMode;
use App\Models\Event;
use App\Models\User;
use Carbon\CarbonImmutable;
final class DueResolver
{
public const WHOLE_DAY = '24:00:00';
public function resolve(Event $event, User $user): Due
{
return match ($event->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);
}
}

View File

@@ -75,6 +75,29 @@ class SchemaTest extends TestCase
$this->assertSame(ChannelType::Web, $user->channels()->first()->type); $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 public function test_state_helpers(): void
{ {
$this->assertTrue(EventState::Done->isTerminal()); $this->assertTrue(EventState::Done->isTerminal());

View File

@@ -0,0 +1,41 @@
<?php
namespace Tests\Unit\Scheduling;
use App\Scheduling\Cascade;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
class CascadeTest extends TestCase
{
private function daysBefore(array $points, CarbonImmutable $due): array
{
return array_map(fn (CarbonImmutable $p) => (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'));
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Tests\Unit\Scheduling;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
class DueResolverTest extends TestCase
{
private function user(string $tz = 'America/Montevideo'): User
{
return new User(['login' => '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'));
}
}