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:
23
core/app/Models/Concerns/StoresUtc.php
Normal file
23
core/app/Models/Concerns/StoresUtc.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' => '{}'];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
27
core/app/Scheduling/Cascade.php
Normal file
27
core/app/Scheduling/Cascade.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
core/app/Scheduling/Due.php
Normal file
16
core/app/Scheduling/Due.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
62
core/app/Scheduling/DueResolver.php
Normal file
62
core/app/Scheduling/DueResolver.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
41
core/tests/Unit/Scheduling/CascadeTest.php
Normal file
41
core/tests/Unit/Scheduling/CascadeTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
99
core/tests/Unit/Scheduling/DueResolverTest.php
Normal file
99
core/tests/Unit/Scheduling/DueResolverTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user