feat: web inbox per Sekai design handoff

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 08:16:04 -03:00
parent 75ac268c53
commit 027c9f7784
6 changed files with 484 additions and 223 deletions

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EventState;
use App\Http\Controllers\Api\EventsController as Presenter;
use App\Http\Controllers\Me\ChannelsController;
use App\Http\Controllers\Me\ProfileController;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
class InboxController extends Controller
{
private const MONTHS = ['ЯНВ', 'ФЕВ', 'МАР', 'АПР', 'МАЙ', 'ИЮН', 'ИЮЛ', 'АВГ', 'СЕН', 'ОКТ', 'НОЯ', 'ДЕК'];
public function __construct(private readonly DueResolver $due) {}
public function __invoke(Request $request): View
{
/** @var User $user */
$user = $request->attributes->get('user');
$today = $user->now()->startOfDay();
$live = $user->events()->with('source')
->whereIn('state', EventState::nonTerminalValues())
->get()
->map(fn (Event $e) => $this->card($e, $user, $today))
->sortBy('sort')->values();
$groups = [];
foreach (['today' => ['今日', 'СЕГОДНЯ'], 'overdue' => ['遅延', 'ПРОСРОЧЕНО'], 'preparing' => ['近日', 'ГРЯДЁТ'], 'scheduled' => ['予定', 'ЗАПЛАНИРОВАНО']] as $state => [$jp, $ru]) {
$groups[] = ['state' => $state, 'jp' => $jp, 'ru' => $ru, 'events' => $live->where('state', $state)->values()->all()];
}
$archive = $user->events()->with('source')
->whereIn('state', [EventState::Done->value, EventState::Expired->value])
->orderByDesc('updated_at')->limit(100)->get()
->map(fn (Event $e) => [
'title' => $e->payload['title'],
'source' => self::sourceLabel($e->source->name),
'due' => Presenter::present($e)['due_at'],
'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'),
])->values()->all();
$profile = ProfileController::present($user);
return view('inbox', [
'profile' => $profile,
'title' => $profile['badge'] > 0 ? "({$profile['badge']}) Hado" : 'Hado',
'groups' => $groups,
'archive' => $archive,
'channels' => $user->channels()->orderBy('id')->get()->map(ChannelsController::present(...))->values()->all(),
'timezones' => \DateTimeZone::listIdentifiers(),
]);
}
/** @return array<string,mixed> */
private function card(Event $e, User $user, CarbonImmutable $today): array
{
$due = $this->due->resolve($e, $user);
$wholeDay = $due->moment->eq($due->day->addDay());
$diff = (int) round($today->diffInDays($due->day));
return [
'id' => $e->id,
'state' => $e->state->value,
'title' => $e->payload['title'],
'subtitle' => $e->payload['subtitle'] ?? null,
'link' => $e->payload['deep_link'] ?? null,
'done_label' => $e->payload['done_label'] ?? 'Сделано',
'can_remind' => $e->state !== EventState::Scheduled,
'source' => self::sourceLabel($e->source->name),
'due' => Presenter::present($e)['due_at'],
'due_month' => self::MONTHS[(int) $due->day->format('n') - 1],
'due_day' => $due->day->format('d'),
'due_time' => $wholeDay ? null : $due->moment->format('H:i'),
'rel' => self::relative($diff),
'sort' => $due->moment->getTimestamp(),
];
}
public static function sourceLabel(string $name): string
{
return $name === 'manual' ? 'вручную' : $name;
}
public static function relative(int $diff): string
{
$days = fn (int $n) => $n % 10 === 1 && $n % 100 !== 11 ? 'день' : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 'дня' : 'дней');
return match (true) {
$diff === 0 => 'сегодня',
$diff === 1 => 'завтра',
$diff > 1 => "через {$diff} ".$days($diff),
$diff === -1 => 'вчера',
default => abs($diff).' '.$days(abs($diff)).' назад',
};
}
}