feat: due_at parser and event upserter

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:45:33 -03:00
parent c2fa8aa00d
commit 7d1df7828a
5 changed files with 427 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Ingest;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
final class DueAtParser
{
private const DATE = '/^(\d{4})-(\d{2})-(\d{2})$/';
private const LOCAL_DATETIME = '/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/';
private const FIXED_DATETIME = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(Z|[+-]\d{2}:\d{2})$/';
public static function parse(string $raw): ParsedDue
{
if (preg_match(self::DATE, $raw, $m)) {
self::assertDate((int) $m[1], (int) $m[2], (int) $m[3], $raw);
return ParsedDue::local($raw, DueResolver::WHOLE_DAY);
}
if (preg_match(self::LOCAL_DATETIME, $raw, $m)) {
self::assertDate((int) $m[1], (int) $m[2], (int) $m[3], $raw);
self::assertTime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), $raw);
return ParsedDue::local(
sprintf('%s-%s-%s', $m[1], $m[2], $m[3]),
sprintf('%s:%s:%02d', $m[4], $m[5], (int) ($m[6] ?? 0)),
);
}
if (preg_match(self::FIXED_DATETIME, $raw)) {
try {
return ParsedDue::fixed(CarbonImmutable::parse($raw));
} catch (\Throwable $e) {
throw new InvalidArgumentException("due_at: невалидная дата-время «{$raw}»", 0, $e);
}
}
throw new InvalidArgumentException("due_at: ожидается YYYY-MM-DD, YYYY-MM-DDTHH:MM или дата-время со смещением, получено «{$raw}»");
}
private static function assertDate(int $y, int $m, int $d, string $raw): void
{
if (! checkdate($m, $d, $y)) {
throw new InvalidArgumentException("due_at: несуществующая дата «{$raw}»");
}
}
private static function assertTime(int $h, int $i, int $s, string $raw): void
{
if ($h > 23 || $i > 59 || $s > 59) {
throw new InvalidArgumentException("due_at: невалидное время «{$raw}»");
}
}
}