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,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);
}
}