docs: design handoff, manual events and archive in plan, spec and functional doc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
@@ -71,7 +71,8 @@ core/ Laravel app (created in Task 1)
|
||||
app/Http/Controllers/Me/{ProfileController,EventsController,ChannelsController}.php
|
||||
app/Http/Controllers/ActionController.php
|
||||
app/Http/Controllers/TelegramWebhookController.php
|
||||
app/Http/Controllers/InboxController.php
|
||||
app/Http/Controllers/InboxController.php (design: docs/design/handoff)
|
||||
public/inbox.js
|
||||
app/Console/Commands/{SourceCreate,TickCommand,PurgeCommand}.php
|
||||
config/hado.php
|
||||
database/migrations/0001_01_01_000000_create_hado_tables.php
|
||||
@@ -79,6 +80,7 @@ core/ Laravel app (created in Task 1)
|
||||
routes/{api,web,console}.php
|
||||
tests/Unit/Scheduling/*, tests/Unit/Ingest/*, tests/Feature/*
|
||||
docker-compose.yml, core/Dockerfile, core/Dockerfile.dev, deploy/Caddyfile.snippet, README.md
|
||||
docs/design/handoff/ дизайн-хендофф (прототип инбокса, токены, README)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -4759,17 +4761,223 @@ Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Web inbox page
|
||||
### Task 11: Manual events and archive API
|
||||
|
||||
Design handoff (`docs/design/handoff/README.md`) added two features the spec did not have: manual event creation («+ Добавить») and an archive of closed events («書庫 Архив · N закрыто»). Both stay. Backend support goes here; the page uses it in Task 12.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/app/Http/Controllers/InboxController.php`, `core/resources/views/inbox.blade.php`
|
||||
- Modify: `core/app/Http/Controllers/Me/EventsController.php` (add `store`, `archive`)
|
||||
- Modify: `core/routes/web.php` (add `POST /me/events`, `GET /me/archive`)
|
||||
- Test: `core/tests/Feature/ManualEventsTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `EventUpserter::upsert(Source, items)`, `ApiPresenter::present`.
|
||||
- Produces:
|
||||
- `POST /me/events` `{title, subtitle?, due_date (Y-m-d), due_time? (H:i)}` → 201 with the presented event. Source is the reserved `manual` source (`Source::firstOrCreate(['name' => 'manual'])` with an unusable random token hash). `source_ref = "manual:<ulid>"`, `topic = ""`, `after_due = keep`, `fire_at = due_date − 30 days` (so a manual event is «Грядёт» within 30 days and «Запланировано» before that, exactly as the prototype groups it), payload `{title, subtitle?, done_label: "Сделано"}`.
|
||||
- `GET /me/archive` → `{events: [...]}` — the user's `done` and `expired` events, newest closed first (`updated_at desc`), max 100, each presented plus `closed_as`: `done_label` (default «Сделано») for `done`, «Истекло» for `expired`. `withdrawn` is not shown.
|
||||
- `present()` gains `source` for `/me` responses (already added in `index`); manual events show `source = "manual"`; the page renders it as «вручную».
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
`core/tests/Feature/ManualEventsTest.php`:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\AfterDue;
|
||||
use App\Enums\DueMode;
|
||||
use App\Enums\EventState;
|
||||
use App\Models\Event;
|
||||
use App\Models\Source;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ManualEventsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private array $as = ['X-Remote-User' => 'nikita'];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['hado.default_tz' => 'UTC']);
|
||||
CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_creates_manual_event_grouped_by_date(): void
|
||||
{
|
||||
$this->postJson('/me/events', ['title' => 'Продлить страховку', 'subtitle' => 'раз в год', 'due_date' => '2026-09-20'], $this->as)
|
||||
->assertCreated()
|
||||
->assertJsonPath('state', 'preparing')
|
||||
->assertJsonPath('source', 'manual')
|
||||
->assertJsonPath('payload.title', 'Продлить страховку')
|
||||
->assertJsonPath('payload.done_label', 'Сделано')
|
||||
->assertJsonPath('fire_at', '2026-08-21');
|
||||
|
||||
$this->postJson('/me/events', ['title' => 'Далеко', 'due_date' => '2026-12-15'], $this->as)->assertJsonPath('state', 'scheduled');
|
||||
$this->postJson('/me/events', ['title' => 'Сегодня', 'due_date' => '2026-09-04', 'due_time' => '19:40'], $this->as)
|
||||
->assertJsonPath('state', 'today')->assertJsonPath('due_at', '2026-09-04T19:40');
|
||||
$this->postJson('/me/events', ['title' => 'Прошло', 'due_date' => '2026-09-01'], $this->as)->assertJsonPath('state', 'overdue');
|
||||
|
||||
$this->assertSame(4, Event::count());
|
||||
$this->assertSame('manual', Source::sole()->name);
|
||||
$this->assertSame(AfterDue::Keep, Event::first()->after_due);
|
||||
$this->assertSame(DueMode::Local, Event::first()->due_mode);
|
||||
}
|
||||
|
||||
public function test_validation(): void
|
||||
{
|
||||
$this->postJson('/me/events', ['due_date' => '2026-09-20'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['title']);
|
||||
$this->postJson('/me/events', ['title' => 'x'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['due_date']);
|
||||
$this->postJson('/me/events', ['title' => 'x', 'due_date' => '2026-09-20', 'due_time' => '25:00'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['due_time']);
|
||||
}
|
||||
|
||||
public function test_manual_source_cannot_be_used_through_client_api(): void
|
||||
{
|
||||
$this->postJson('/me/events', ['title' => 'x', 'due_date' => '2026-09-20'], $this->as)->assertCreated();
|
||||
$this->putJson('/api/events', ['events' => []], ['Authorization' => 'Bearer manual'])->assertStatus(401);
|
||||
}
|
||||
|
||||
public function test_archive_lists_done_and_expired_newest_first(): void
|
||||
{
|
||||
$this->getJson('/me', $this->as);
|
||||
$user = User::sole();
|
||||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||||
$make = fn (string $topic, EventState $state, string $updatedAt, ?string $doneLabel = null) => tap(Event::create([
|
||||
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
|
||||
'due_mode' => DueMode::Local, 'due_date' => '2026-08-28', 'due_time' => '24:00:00', 'fire_on' => '2026-08-28',
|
||||
'after_due' => AfterDue::Expire, 'payload' => array_filter(['title' => "E $topic", 'done_label' => $doneLabel]),
|
||||
'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||||
]), fn (Event $e) => Event::whereKey($e->id)->update(['updated_at' => $updatedAt]));
|
||||
$make('a', EventState::Done, '2026-09-01 10:00:00', 'Поздравил');
|
||||
$make('b', EventState::Expired, '2026-09-02 10:00:00');
|
||||
$make('c', EventState::Withdrawn, '2026-09-03 10:00:00');
|
||||
$make('d', EventState::Today, '2026-09-03 10:00:00');
|
||||
|
||||
$r = $this->getJson('/me/archive', $this->as)->assertOk()->assertJsonCount(2, 'events');
|
||||
$this->assertSame(['b', 'a'], array_column($r->json('events'), 'topic'));
|
||||
$this->assertSame(['Истекло', 'Поздравил'], array_column($r->json('events'), 'closed_as'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
```bash
|
||||
cd core && php artisan test --filter ManualEventsTest
|
||||
```
|
||||
Expected: FAIL (405/404).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add to `core/app/Http/Controllers/Me/EventsController.php`:
|
||||
```php
|
||||
use App\Ingest\EventUpserter;
|
||||
use App\Models\Source;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// constructor becomes:
|
||||
public function __construct(private readonly EventActions $actions, private readonly EventUpserter $upserter) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->attributes->get('user');
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:200'],
|
||||
'subtitle' => ['sometimes', 'nullable', 'string', 'max:200'],
|
||||
'due_date' => ['required', 'date_format:Y-m-d'],
|
||||
'due_time' => ['sometimes', 'nullable', 'date_format:H:i'],
|
||||
]);
|
||||
|
||||
$source = Source::firstOrCreate(['name' => 'manual'], ['token_hash' => hash('sha256', Str::random(64))]);
|
||||
$dueAt = $data['due_date'].(empty($data['due_time']) ? '' : 'T'.$data['due_time']);
|
||||
|
||||
[$result] = $this->upserter->upsert($source, [[
|
||||
'user' => $user->login,
|
||||
'source_ref' => 'manual:'.Str::ulid(),
|
||||
'topic' => '',
|
||||
'due_at' => $dueAt,
|
||||
'fire_at' => \Carbon\CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'),
|
||||
'after_due' => 'keep',
|
||||
'payload' => array_filter([
|
||||
'title' => $data['title'],
|
||||
'subtitle' => $data['subtitle'] ?? null,
|
||||
'done_label' => 'Сделано',
|
||||
], fn ($v) => $v !== null && $v !== ''),
|
||||
]]);
|
||||
|
||||
$event = Event::with('source')->findOrFail($result['id']);
|
||||
|
||||
return response()->json(ApiPresenter::present($event) + ['source' => $event->source->name], 201);
|
||||
}
|
||||
|
||||
public function archive(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->attributes->get('user');
|
||||
$events = $user->events()->with('source')
|
||||
->whereIn('state', [EventState::Done->value, EventState::Expired->value])
|
||||
->orderByDesc('updated_at')->limit(100)->get();
|
||||
|
||||
return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + [
|
||||
'source' => $e->source->name,
|
||||
'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'),
|
||||
])->values()]);
|
||||
}
|
||||
```
|
||||
|
||||
Routes, inside the `remote.user` group in `core/routes/web.php`:
|
||||
```php
|
||||
Route::post('/me/events', [MeEvents::class, 'store']);
|
||||
Route::get('/me/archive', [MeEvents::class, 'archive']);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd core && php artisan test --filter "ManualEventsTest|MeApiTest"
|
||||
```
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Spec and functional doc**
|
||||
|
||||
In `docs/superpowers/specs/2026-09-03-hado-core-design.md` §10 add after the route list: «`POST /me/events` — ручное событие: источник `manual` (зарезервирован, токена нет), `fire_at = срок − 30 дней`, `after_due = keep`. `GET /me/archive` — закрытые (`done`, `expired`) за период retention, новые сверху, с подписью закрытия.» Update the inbox paragraph to mention the archive block and the add form. These were added by the design handoff on 2026-09-04.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: manual events and archive API
|
||||
|
||||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||||
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Web inbox page (по дизайну handoff)
|
||||
|
||||
The page follows `docs/design/handoff/hado-inbox.dc.html` (working prototype) and `docs/design/handoff/README.md` (tokens and rules). Reproduce it in Blade + vanilla JS; the values below are copied from the prototype, not invented. Dark only. Sekai design system: 60% dusk, 30% text neutrals, ~10% cold accent (`--ruri`/`--byakugun`) on action buttons, counter and time. `--ruri` is never used as text. Errors are `--sakura`.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/app/Http/Controllers/InboxController.php`, `core/resources/views/inbox.blade.php`, `core/public/inbox.js`
|
||||
- Modify: `core/routes/web.php` (add `GET /` inside the `remote.user` group)
|
||||
- Delete: `core/resources/views/welcome.blade.php`
|
||||
- Test: `core/tests/Feature/InboxPageTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/me/*` JSON endpoints (the page is plain HTML + vanilla JS calling them with `X-CSRF-TOKEN`).
|
||||
- Produces: `GET /` renders groups `today`, `overdue`, `preparing`, `scheduled`; buttons «Помню» / done_label; settings block (tz, quiet hours, channels, Telegram link). Heartbeat every 30 s only when `document.visibilityState === 'visible'` and pointer/keyboard activity within 3 minutes.
|
||||
- Consumes: `/me`, `/me/events` (GET/POST), `/me/events/{id}/ack|done`, `/me/archive`, `/me/channels*`, `/me/heartbeat`.
|
||||
- Produces: `GET /` rendering the whole inbox server-side (groups, archive rows, settings); JS only toggles sections, submits forms and sends the heartbeat; every mutation reloads the page.
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
@@ -4793,40 +5001,78 @@ class InboxPageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['hado.default_tz' => 'UTC']);
|
||||
CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_requires_sso(): void
|
||||
{
|
||||
$this->get('/')->assertStatus(401);
|
||||
}
|
||||
|
||||
public function test_renders_groups_and_buttons(): void
|
||||
public function test_renders_groups_cards_archive_and_settings(): void
|
||||
{
|
||||
$this->get('/', ['X-Remote-User' => 'nikita']);
|
||||
$user = User::sole();
|
||||
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
|
||||
$make = fn (string $topic, EventState $state, ?string $doneLabel = null) => Event::create([
|
||||
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
|
||||
'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-08-31',
|
||||
'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'deep_link' => '/x', 'done_label' => $doneLabel]),
|
||||
$manual = Source::create(['name' => 'manual', 'token_hash' => hash('sha256', 'y')]);
|
||||
$make = fn (Source $src, string $topic, EventState $state, string $due, string $time = '24:00:00', ?string $doneLabel = null, ?string $link = '/x') => Event::create([
|
||||
'source_id' => $src->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
|
||||
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $due,
|
||||
'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'subtitle' => 'подзаголовок', 'deep_link' => $link, 'done_label' => $doneLabel]),
|
||||
'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
|
||||
]);
|
||||
$make('a', EventState::Today, 'Поздравил');
|
||||
$make('b', EventState::Overdue);
|
||||
$make('c', EventState::Preparing);
|
||||
$make('d', EventState::Scheduled);
|
||||
$make('e', EventState::Done);
|
||||
$make($source, 'a', EventState::Today, '2026-09-04', '19:40:00', 'Поздравил');
|
||||
$make($source, 'b', EventState::Overdue, '2026-09-01');
|
||||
$make($manual, 'c', EventState::Preparing, '2026-09-30', '24:00:00', null, null);
|
||||
$make($source, 'd', EventState::Scheduled, '2026-12-15');
|
||||
$make($source, 'e', EventState::Done, '2026-08-28', '24:00:00', 'Оплатил');
|
||||
$make($source, 'f', EventState::Expired, '2026-08-20');
|
||||
|
||||
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
|
||||
|
||||
// шапка и счётчик
|
||||
$this->assertStringContainsString('<title>(2) Hado</title>', $html);
|
||||
$this->assertStringContainsString('波動', $html);
|
||||
$this->assertStringContainsString('+ Добавить', $html);
|
||||
// группы в порядке и с кандзи
|
||||
$this->assertMatchesRegularExpression('/今日.*СЕГОДНЯ.*遅延.*ПРОСРОЧЕНО.*近日.*ГРЯДЁТ.*予定.*ЗАПЛАНИРОВАНО/su', $html);
|
||||
// карточки
|
||||
$this->assertStringContainsString('Событие a', $html);
|
||||
$this->assertStringContainsString('Событие d', $html);
|
||||
$this->assertStringNotContainsString('Событие e', $html);
|
||||
$this->assertStringContainsString('19:40', $html);
|
||||
$this->assertStringContainsString('СЕН', $html);
|
||||
$this->assertStringContainsString('сегодня', $html);
|
||||
$this->assertStringContainsString('3 дня назад', $html);
|
||||
$this->assertStringContainsString('через 26 дней', $html);
|
||||
$this->assertStringContainsString('вручную', $html);
|
||||
$this->assertStringContainsString('Поздравил', $html);
|
||||
$this->assertStringContainsString('Помню', $html);
|
||||
$this->assertStringContainsString('Сделано', $html);
|
||||
$this->assertStringContainsString('/me/heartbeat', $html);
|
||||
$this->assertStringContainsString('visibilityState', $html);
|
||||
// архив
|
||||
$this->assertStringContainsString('2 закрыто', $html);
|
||||
$this->assertStringContainsString('Оплатил', $html);
|
||||
$this->assertStringContainsString('Истекло', $html);
|
||||
// настройки, форма добавления, присутствие
|
||||
$this->assertStringContainsString('nikita', $html);
|
||||
$this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html);
|
||||
$this->assertStringContainsString('/inbox.js', $html);
|
||||
$this->assertStringContainsString('csrf-token', $html);
|
||||
}
|
||||
|
||||
public function test_counter_hidden_when_zero(): void
|
||||
{
|
||||
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
|
||||
$this->assertStringContainsString('<title>Hado</title>', $html);
|
||||
$this->assertStringNotContainsString('class="counter"', $html);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -4835,9 +5081,9 @@ class InboxPageTest extends TestCase
|
||||
```bash
|
||||
cd core && php artisan test --filter InboxPageTest
|
||||
```
|
||||
Expected: FAIL (`/` renders the Laravel welcome page or 404).
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
- [ ] **Step 3: Controller**
|
||||
|
||||
`core/app/Http/Controllers/InboxController.php`:
|
||||
```php
|
||||
@@ -4851,37 +5097,104 @@ 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();
|
||||
|
||||
$events = $user->events()->with('source')
|
||||
$live = $user->events()->with('source')
|
||||
->whereIn('state', EventState::nonTerminalValues())
|
||||
->orderBy('due_date')->orderBy('due_instant')->get()
|
||||
->map(fn (Event $e) => Presenter::present($e) + ['source' => $e->source->name]);
|
||||
->get()
|
||||
->map(fn (Event $e) => $this->card($e, $user, $today))
|
||||
->sortBy('sort')->values();
|
||||
|
||||
$groups = [];
|
||||
foreach (['today' => 'Сегодня', 'overdue' => 'Просрочено', 'preparing' => 'Грядёт', 'scheduled' => 'Запланировано'] as $state => $label) {
|
||||
$groups[] = ['state' => $state, 'label' => $label, 'events' => $events->where('state', $state)->values()->all()];
|
||||
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' => ProfileController::present($user),
|
||||
'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)).' назад',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`core/resources/views/inbox.blade.php`:
|
||||
- [ ] **Step 4: Blade template**
|
||||
|
||||
`core/resources/views/inbox.blade.php` — structure and values mirror the prototype; sister planets, nebula and star field included as in the Sekai template:
|
||||
```blade
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
@@ -4889,108 +5202,254 @@ class InboxController extends Controller
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Hado · {{ $profile['badge'] > 0 ? '('.$profile['badge'].') ' : '' }}уведомления</title>
|
||||
<title>{{ $title }}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font: 15px/1.4 system-ui, sans-serif; margin: 0; background: #f6f6f4; color: #1a1a1a; }
|
||||
main { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
h1 { font-size: 20px; display: flex; align-items: center; gap: 10px; }
|
||||
.badge { background: #d33; color: #fff; border-radius: 12px; padding: 0 8px; font-size: 13px; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: #666; margin: 28px 0 8px; }
|
||||
.event { background: #fff; border-radius: 10px; padding: 12px 14px; margin-bottom: 8px; display: flex; gap: 12px; align-items: center; }
|
||||
.event .text { flex: 1; min-width: 0; }
|
||||
.event .title { font-weight: 600; }
|
||||
.event .sub { color: #666; font-size: 13px; }
|
||||
.event .meta { color: #999; font-size: 12px; }
|
||||
button { border: 0; border-radius: 8px; padding: 8px 12px; cursor: pointer; font: inherit; }
|
||||
.ack { background: #eee; }
|
||||
.done { background: #1a1a1a; color: #fff; }
|
||||
.empty { color: #999; padding: 8px 0; }
|
||||
details { background: #fff; border-radius: 10px; padding: 12px 14px; margin-top: 36px; }
|
||||
summary { cursor: pointer; font-weight: 600; }
|
||||
form.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 10px 0; }
|
||||
input, select { font: inherit; padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; }
|
||||
ul.channels { list-style: none; padding: 0; }
|
||||
ul.channels li { display: flex; gap: 8px; align-items: center; padding: 6px 0; border-top: 1px solid #eee; }
|
||||
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; }
|
||||
:root{--bg:#070912;--ink-0:#090B16;--ink-1:#11152E;--ink-2:#1B2148;--ink-3:#2B3566;--sakura:#F7CEDC;--nadeshiko:#EA8FAE;--tsutsuji:#E84E8A;--kobai:#C13E6A;--yamabuki:#F2B233;--hanada:#1F7FC4;--ruri:#355CD6;--byakugun:#8FD6DC;--fuji:#9F8FCB;--washi:#F2ECE0;--kasumi:#AEB4D6;--nibi:#6B7099;--hair:rgba(174,180,214,.16);--hair-2:rgba(174,180,214,.30)}
|
||||
*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg)}
|
||||
body{min-height:100vh;color:var(--washi);font-family:'Space Grotesk',sans-serif;position:relative;overflow-x:hidden;display:flex;flex-direction:column}
|
||||
::selection{background:rgba(143,214,220,.25)}
|
||||
a{color:var(--byakugun);text-decoration-color:rgba(143,214,220,.4)}a:hover{color:var(--washi)}
|
||||
input,select,button{font:inherit}button[disabled]{opacity:.45;pointer-events:none}
|
||||
.mono{font-family:'Space Mono',monospace}.serif{font-family:'Shippori Mincho',serif}
|
||||
.layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
|
||||
.nebula{position:absolute;pointer-events:none;z-index:0;top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 42% 58% at 20% 60%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 58% 46% at 48% 44%,rgba(232,78,138,.14),transparent 72%),radial-gradient(ellipse 38% 54% at 70% 57%,rgba(159,143,203,.17),transparent 70%),radial-gradient(ellipse 28% 40% at 88% 51%,rgba(143,214,220,.11),transparent 70%),radial-gradient(ellipse 52% 34% at 36% 62%,rgba(53,92,214,.11),transparent 72%)}
|
||||
.core{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;animation:skNebulaCore 58s ease-in-out infinite;background:radial-gradient(ellipse 20% 70% at 8% 71%,rgba(247,206,220,.22),transparent 70%),radial-gradient(ellipse 14% 90% at 22% 52%,rgba(242,236,224,.18),transparent 70%),radial-gradient(ellipse 18% 80% at 36% 48%,rgba(234,143,174,.22),transparent 72%),radial-gradient(ellipse 12% 100% at 48% 38%,rgba(247,206,220,.24),transparent 70%),radial-gradient(ellipse 16% 85% at 60% 43%,rgba(242,236,224,.17),transparent 72%),radial-gradient(ellipse 13% 75% at 72% 43%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 18% 90% at 86% 55%,rgba(247,206,220,.18),transparent 72%),radial-gradient(ellipse 10% 60% at 94% 61%,rgba(143,214,220,.12),transparent 70%)}
|
||||
.dust{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 20% 38% at 16% 72%,rgba(7,9,18,.6),transparent 75%),radial-gradient(ellipse 28% 34% at 44% 55%,rgba(9,11,22,.55),transparent 75%),radial-gradient(ellipse 18% 38% at 70% 50%,rgba(7,9,18,.55),transparent 75%),radial-gradient(ellipse 14% 30% at 90% 68%,rgba(9,11,22,.5),transparent 75%)}
|
||||
.band{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)}
|
||||
.sister{position:absolute}.sister .col{display:flex;flex-direction:column;align-items:center;gap:9px}
|
||||
.skdot{pointer-events:auto;border-radius:50%}.sklbl{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s}.skdot:hover+.sklbl{opacity:1}
|
||||
header{position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;padding:30px 44px 0}
|
||||
.brand{display:flex;align-items:baseline;gap:14px}.brand .jp{font-family:'Shippori Mincho',serif;font-size:54px;line-height:1}.brand .lat{font-family:'Space Mono',monospace;font-size:13px;letter-spacing:.5em;color:var(--nibi)}
|
||||
.counter{display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 10px;border:1px solid rgba(53,92,214,.55);border-radius:999px;background:rgba(53,92,214,.14)}
|
||||
.counter .dot{width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%,var(--ruri),#24337F 55%,var(--ink-0) 92%);animation:skPulseDot 2.6s ease-in-out infinite}
|
||||
.counter .n{font-family:'Space Mono',monospace;font-size:12px;color:var(--byakugun)}
|
||||
.tools{display:flex;align-items:center;gap:16px;margin-top:14px}.num{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
|
||||
.btn{border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:background .2s,border-color .2s,color .2s}
|
||||
.primary{background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun)}.primary:hover{background:rgba(53,92,214,.4)}
|
||||
.ghost{background:transparent;border:1px solid var(--hair-2);color:var(--kasumi)}.ghost:hover{border-color:var(--byakugun);color:var(--byakugun)}
|
||||
.ghost.danger:hover{border-color:var(--nadeshiko);color:var(--sakura)}
|
||||
main{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:72px 32px 100px;flex:1}
|
||||
.panel{border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden;margin-top:64px}
|
||||
.panel>button.head{width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)}
|
||||
.panel .head .jp{font-family:'Shippori Mincho',serif;font-size:16px}.panel .head .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)}.panel .head .chev{margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)}
|
||||
.panel .body{border-top:1px solid var(--hair);padding:18px 22px;display:flex;flex-direction:column;gap:10px}
|
||||
.addform{margin-bottom:52px;border:1px dashed var(--hair-2);border-radius:14px;background:rgba(17,21,46,.7);padding:22px}
|
||||
.label{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px}
|
||||
.row{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end}
|
||||
.field{display:flex;flex-direction:column;gap:6px}.field span{font-size:12px;color:var(--nibi)}
|
||||
.field input,.field select{background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px}
|
||||
.err{color:var(--sakura);font-size:12px;margin-top:12px}.ok{color:var(--byakugun);font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;margin-top:12px}
|
||||
.group{margin-bottom:52px}.group .h{display:flex;align-items:baseline;gap:12px;margin-bottom:18px}.group .h .jp{font-family:'Shippori Mincho',serif;font-size:20px}.group .h .ru{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.35em;color:var(--kasumi)}.group .h .line{flex:1;border-bottom:1px solid var(--hair)}
|
||||
.empty{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi);padding:12px 2px}
|
||||
.cards{display:flex;flex-direction:column;gap:12px}
|
||||
.card{background:rgba(27,33,72,.72);border:1px solid var(--hair);border-radius:12px;padding:16px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||
.plaque{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:64px;padding:8px 10px;border:1px solid var(--hair-2);border-radius:10px;background:var(--ink-1)}
|
||||
.plaque .m{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.2em;color:var(--nibi)}.plaque .d{font-family:'Shippori Mincho',serif;font-size:24px;line-height:1}.plaque .t{font-family:'Space Mono',monospace;font-size:10px;color:var(--byakugun)}
|
||||
.card .text{flex:1;min-width:220px;display:flex;flex-direction:column;gap:5px}
|
||||
.card .title{font-size:16px;font-weight:500;color:var(--washi);align-self:flex-start}.card a.title{text-decoration:underline dotted var(--hair-2);text-underline-offset:4px}.card a.title:hover{color:var(--byakugun)}
|
||||
.card .sub{font-size:13px;color:var(--kasumi)}.card .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi);margin-top:3px}.card .meta .rel{color:var(--kasumi)}
|
||||
.card .actions{display:flex;gap:10px;align-items:center}
|
||||
.arow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1);opacity:.75}
|
||||
.arow .t{font-size:14px;color:var(--kasumi);flex:1;min-width:180px}.arow .m{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi)}.arow .tag{font-family:'Space Mono',monospace;font-size:10px;letter-spacing:.15em;color:var(--fuji);border:1px solid var(--hair);border-radius:999px;padding:3px 10px}
|
||||
.chrow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1)}.chrow .n{font-size:14px;min-width:76px}.chrow .note{font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi);flex:1;overflow-wrap:anywhere}
|
||||
.tgbox{border:1px dashed var(--hair-2);border-radius:10px;padding:14px 18px;display:flex;flex-direction:column;gap:8px}
|
||||
footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:22px 32px 36px;border-top:1px solid var(--hair);display:flex;justify-content:center;gap:8px;font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
|
||||
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
|
||||
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
|
||||
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
|
||||
@keyframes skOrbXa{0%,100%{transform:translateX(36px)}50%{transform:translateX(-36px)}}@keyframes skOrbYa{0%,100%{transform:translateY(13px)}50%{transform:translateY(-13px)}}
|
||||
@keyframes skOrbXb{0%,100%{transform:translateX(24px)}50%{transform:translateX(-24px)}}@keyframes skOrbYb{0%,100%{transform:translateY(22px)}50%{transform:translateY(-22px)}}
|
||||
@keyframes skOrbXc{0%,100%{transform:translateX(14px)}50%{transform:translateX(-14px)}}@keyframes skOrbYc{0%,100%{transform:translateY(28px)}50%{transform:translateY(-28px)}}
|
||||
@keyframes skOrbXd{0%,100%{transform:translateX(-20px)}50%{transform:translateX(20px)}}@keyframes skOrbYd{0%,100%{transform:translateY(16px)}50%{transform:translateY(-16px)}}
|
||||
@keyframes skPulseDot{0%,100%{box-shadow:0 0 6px rgba(53,92,214,.5)}50%{box-shadow:0 0 14px rgba(53,92,214,.95)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layer" id="stars"></div>
|
||||
<div class="nebula"></div><div class="core"></div><div class="dust"></div>
|
||||
<div class="band" id="band"></div>
|
||||
|
||||
{{-- сёстры-планеты: все, кроме Hado --}}
|
||||
<div class="layer">
|
||||
<div class="sister" style="top:17%;left:7%"><div style="animation:skOrbXa 19s ease-in-out infinite"><div class="col" style="animation:skOrbYa 19s ease-in-out -4.75s infinite"><div class="skdot" style="width:15px;height:15px;background:radial-gradient(circle at 32% 28%,var(--sakura),var(--nadeshiko) 50%,var(--ink-0) 92%);box-shadow:0 0 10px rgba(234,143,174,.4)"></div><span class="sklbl">YUME</span></div></div></div>
|
||||
<div class="sister" style="top:26%;right:8%"><div style="animation:skOrbXb 27s ease-in-out infinite"><div class="col" style="animation:skOrbYb 27s ease-in-out -20.25s infinite"><div class="skdot" style="width:12px;height:12px;background:radial-gradient(circle at 32% 28%,var(--tsutsuji),var(--kobai) 52%,var(--ink-0) 92%);box-shadow:0 0 9px rgba(232,78,138,.4)"></div><span class="sklbl">MYAKU</span></div></div></div>
|
||||
<div class="sister" style="top:60%;left:4.5%"><div style="animation:skOrbXc 14s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYc 14s ease-in-out -3.5s infinite"><div class="skdot" style="width:9px;height:9px;background:radial-gradient(circle at 34% 30%,var(--fuji),#4A4177 55%,var(--ink-0) 92%);box-shadow:0 0 8px rgba(159,143,203,.4)"></div><span class="sklbl">KIOKU</span></div></div></div>
|
||||
<div class="sister" style="top:64%;right:6%"><div style="animation:skOrbXd 22s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYd 22s ease-in-out -5.5s infinite"><div class="skdot" style="width:10px;height:10px;background:radial-gradient(circle at 32% 28%,var(--byakugun),var(--hanada) 50%,var(--ink-0) 93%);box-shadow:0 0 9px rgba(143,214,220,.4)"></div><span class="sklbl">FUMI</span></div></div></div>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<div class="brand">
|
||||
<span class="jp">波動</span><span class="lat">HADO</span>
|
||||
@if($profile['badge'] > 0)<span class="counter"><span class="dot"></span><span class="n">{{ $profile['badge'] }}</span></span>@endif
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn primary" id="toggle-add">+ Добавить</button>
|
||||
<span class="num">05</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1>Hado @if($profile['badge'] > 0)<span class="badge">{{ $profile['badge'] }}</span>@endif</h1>
|
||||
<section class="addform" id="addform" hidden>
|
||||
<div class="label">НОВОЕ СОБЫТИЕ</div>
|
||||
<form class="row" id="add">
|
||||
<label class="field" style="flex:2;min-width:220px"><span>Заголовок</span><input name="title" placeholder="Продлить страховку" required maxlength="200"></label>
|
||||
<label class="field" style="flex:1;min-width:160px"><span>Подзаголовок (опц.)</span><input name="subtitle" placeholder="раз в год" maxlength="200"></label>
|
||||
<label class="field"><span>Срок</span><input name="due_date" type="date" required></label>
|
||||
<label class="field"><span>Время (опц.)</span><input name="due_time" type="time"></label>
|
||||
<button type="submit" class="btn primary">Создать</button>
|
||||
<button type="button" class="btn ghost" id="cancel-add">Отмена</button>
|
||||
</form>
|
||||
<div class="err" id="add-err" hidden></div>
|
||||
</section>
|
||||
|
||||
@foreach($groups as $group)
|
||||
<h2>{{ $group['label'] }}</h2>
|
||||
@forelse($group['events'] as $event)
|
||||
<div class="event" data-id="{{ $event['id'] }}">
|
||||
<div class="text">
|
||||
<div class="title">
|
||||
@if(!empty($event['payload']['deep_link']))<a href="{{ $event['payload']['deep_link'] }}">{{ $event['payload']['title'] }}</a>@else{{ $event['payload']['title'] }}@endif
|
||||
</div>
|
||||
@if(!empty($event['payload']['subtitle']))<div class="sub">{{ $event['payload']['subtitle'] }}</div>@endif
|
||||
<div class="meta">{{ $event['source'] }} · срок {{ $event['due_at'] }}</div>
|
||||
</div>
|
||||
@if($group['state'] !== 'scheduled')
|
||||
<button class="ack" data-action="ack">Помню</button>
|
||||
@endif
|
||||
<button class="done" data-action="done">{{ $event['payload']['done_label'] ?? 'Сделано' }}</button>
|
||||
<section class="group">
|
||||
<div class="h"><span class="jp">{{ $group['jp'] }}</span><span class="ru">{{ $group['ru'] }}</span><span class="line"></span></div>
|
||||
@if(empty($group['events']))<div class="empty">ПУСТО</div>@endif
|
||||
<div class="cards">
|
||||
@foreach($group['events'] as $ev)
|
||||
<article class="card" data-id="{{ $ev['id'] }}">
|
||||
<div class="plaque"><span class="m">{{ $ev['due_month'] }}</span><span class="d">{{ $ev['due_day'] }}</span>@if($ev['due_time'])<span class="t">{{ $ev['due_time'] }}</span>@endif</div>
|
||||
<div class="text">
|
||||
@if($ev['link'])<a class="title" href="{{ $ev['link'] }}">{{ $ev['title'] }}</a>@else<span class="title">{{ $ev['title'] }}</span>@endif
|
||||
@if($ev['subtitle'])<span class="sub">{{ $ev['subtitle'] }}</span>@endif
|
||||
<span class="meta">{{ $ev['source'] }} · срок {{ $ev['due'] }} · <span class="rel">{{ $ev['rel'] }}</span></span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@if($ev['can_remind'])<button class="btn ghost" data-action="ack">Помню</button>@endif
|
||||
<button class="btn primary" data-action="done">{{ $ev['done_label'] }}</button>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">Пусто</div>
|
||||
@endforelse
|
||||
</section>
|
||||
@endforeach
|
||||
|
||||
<details>
|
||||
<summary>Настройки · {{ $profile['login'] }}</summary>
|
||||
|
||||
<form class="row" id="profile">
|
||||
<label>Пояс
|
||||
<select name="tz">
|
||||
@foreach($timezones as $tz)<option value="{{ $tz }}" @selected($tz === $profile['tz'])>{{ $tz }}</option>@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label>Тихие часы с <input name="quiet_start" type="time" value="{{ $profile['quiet_start'] }}"></label>
|
||||
<label>до <input name="quiet_end" type="time" value="{{ $profile['quiet_end'] }}"></label>
|
||||
<button type="submit" class="done">Сохранить</button>
|
||||
</form>
|
||||
|
||||
<h2>Каналы</h2>
|
||||
<ul class="channels">
|
||||
@foreach($channels as $channel)
|
||||
<li>
|
||||
<strong>{{ $channel['type'] }}</strong>
|
||||
<span class="meta">{{ $channel['type'] === 'webhook' ? $channel['config']['deliver_url'] : ($channel['type'] === 'telegram' ? 'chat '.$channel['config']['chat_id'] : 'этот инбокс') }}</span>
|
||||
@if($channel['type'] !== 'web')<button class="ack" data-delete-channel="{{ $channel['id'] }}">Удалить</button>@endif
|
||||
</li>
|
||||
<section class="panel" data-panel="archive">
|
||||
<button class="head" type="button"><span class="jp">書庫</span><span>Архив</span><span class="meta">{{ count($archive) }} закрыто</span><span class="chev">▼</span></button>
|
||||
<div class="body" hidden>
|
||||
@if(empty($archive))<div class="empty">ПУСТО</div>@endif
|
||||
@foreach($archive as $ar)
|
||||
<div class="arow"><span class="t">{{ $ar['title'] }}</span><span class="m">{{ $ar['source'] }} · {{ $ar['due'] }}</span><span class="tag">{{ $ar['closed_as'] }}</span></div>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="row" id="webhook">
|
||||
<input name="deliver_url" placeholder="Webhook: deliver_url" size="34" required>
|
||||
<input name="presence_url" placeholder="presence_url" size="34" required>
|
||||
<button type="submit" class="done">Добавить webhook</button>
|
||||
</form>
|
||||
<section class="panel" data-panel="settings">
|
||||
<button class="head" type="button"><span class="jp">設定</span><span>Настройки</span><span class="meta">{{ $profile['login'] }}</span><span class="chev">▼</span></button>
|
||||
<div class="body" hidden style="padding:26px 22px 30px;gap:34px">
|
||||
<div>
|
||||
<div class="label">ПОЯС И ТИХИЕ ЧАСЫ</div>
|
||||
<form class="row" id="profile">
|
||||
<label class="field" style="min-width:240px"><span>Часовой пояс</span>
|
||||
<select name="tz">@foreach($timezones as $tz)<option value="{{ $tz }}" @selected($tz === $profile['tz'])>{{ $tz }}</option>@endforeach</select>
|
||||
</label>
|
||||
<label class="field"><span>Тихие часы, с</span><input name="quiet_start" type="time" value="{{ $profile['quiet_start'] }}"></label>
|
||||
<label class="field"><span>до</span><input name="quiet_end" type="time" value="{{ $profile['quiet_end'] }}"></label>
|
||||
<button type="submit" class="btn primary">Сохранить</button>
|
||||
</form>
|
||||
<div class="err" id="profile-err" hidden></div>
|
||||
</div>
|
||||
|
||||
<form class="row" id="telegram">
|
||||
<button type="submit" class="done">Подключить Telegram</button>
|
||||
<span id="tg-result"></span>
|
||||
</form>
|
||||
</details>
|
||||
<div>
|
||||
<div class="label">КАНАЛЫ</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
@foreach($channels as $ch)
|
||||
<div class="chrow">
|
||||
<span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span>
|
||||
<span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.$ch['config']['chat_id'] : $ch['config']['deliver_url']) }}</span>
|
||||
@if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="label">ДОБАВИТЬ WEBHOOK</div>
|
||||
<form class="row" id="webhook">
|
||||
<label class="field" style="flex:1;min-width:220px"><span>Адрес доставки</span><input name="deliver_url" type="url" placeholder="https://home.local/hado/notify" required></label>
|
||||
<label class="field" style="flex:1;min-width:220px"><span>Адрес присутствия</span><input name="presence_url" type="url" placeholder="https://home.local/presence" required></label>
|
||||
<button type="submit" class="btn ghost">Добавить webhook</button>
|
||||
</form>
|
||||
<div class="err" id="webhook-err" hidden></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="label">TELEGRAM</div>
|
||||
<button class="btn ghost" id="tg-connect">Подключить Telegram</button>
|
||||
<div class="tgbox" id="tg-box" hidden>
|
||||
<span style="font-size:14px">Отправь боту <span class="mono" style="color:var(--byakugun)" id="tg-code"></span> или <a id="tg-link" href="#" target="_blank" rel="noopener">открой</a></span>
|
||||
<span class="mono" style="font-size:11px;letter-spacing:.15em;color:var(--nibi)">Код живёт 15 минут. Канал появится в списке при следующем открытии страницы.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
<footer><span>波動 HADO</span><span>· 世界 SEKAI</span></footer>
|
||||
<script src="/inbox.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Page script**
|
||||
|
||||
`core/public/inbox.js`:
|
||||
```js
|
||||
(() => {
|
||||
const csrf = document.querySelector('meta[name=csrf-token]').content;
|
||||
const call = (method, url, body) => fetch(url, {
|
||||
method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const showErr = async (el, r) => {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка';
|
||||
el.hidden = false;
|
||||
};
|
||||
|
||||
// Кнопки «Помню» / «Сделано»
|
||||
document.querySelectorAll('.event button[data-action]').forEach(btn => btn.addEventListener('click', async () => {
|
||||
const card = btn.closest('.event');
|
||||
btn.disabled = true;
|
||||
// Звёзды: 90 по небу, 170 в полосе (дуга −14°)
|
||||
const star = (color, size, bright, x, y, dur, delay) => {
|
||||
const s = document.createElement('div');
|
||||
s.style.cssText = `position:absolute;border-radius:50%;left:${x}%;top:${y}%;width:${size}px;height:${size}px;background:${color};${bright ? `box-shadow:0 0 6px ${color};` : ''}animation:skTwinkle ${dur}s ease-in-out ${delay}s infinite`;
|
||||
return s;
|
||||
};
|
||||
const sky = document.getElementById('stars'), band = document.getElementById('band');
|
||||
for (let i = 0; i < 90; i++) sky.appendChild(star(Math.random() < .2 ? '#8FD6DC' : '#F2ECE0', Math.random() < .85 ? 1.5 : 2.5, false, (Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2), (3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
|
||||
for (let j = 0; j < 170; j++) {
|
||||
const bright = Math.random() < .08, r = Math.random();
|
||||
const color = r < .68 ? '#F2ECE0' : (r < .88 ? '#F7CEDC' : '#8FD6DC');
|
||||
const g = (Math.random() + Math.random() + Math.random()) / 3, x = Math.random() * 100;
|
||||
const arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * .35;
|
||||
band.appendChild(star(color, bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright, x.toFixed(2), (6 + g * 55 + arc).toFixed(2), (2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
|
||||
}
|
||||
|
||||
// Панели архив/настройки
|
||||
document.querySelectorAll('.panel .head').forEach(h => h.addEventListener('click', () => {
|
||||
const body = h.nextElementSibling; body.hidden = !body.hidden;
|
||||
h.querySelector('.chev').textContent = body.hidden ? '▼' : '▲';
|
||||
}));
|
||||
|
||||
// Форма добавления
|
||||
const addform = document.getElementById('addform');
|
||||
const toggleAdd = () => { addform.hidden = !addform.hidden; document.getElementById('add-err').hidden = true; };
|
||||
document.getElementById('toggle-add').addEventListener('click', toggleAdd);
|
||||
document.getElementById('cancel-add').addEventListener('click', toggleAdd);
|
||||
document.getElementById('add').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.target);
|
||||
const r = await call('POST', '/me/events', { title: f.get('title'), subtitle: f.get('subtitle') || null, due_date: f.get('due_date'), due_time: f.get('due_time') || null });
|
||||
if (r.ok) location.reload(); else showErr(document.getElementById('add-err'), r);
|
||||
});
|
||||
|
||||
// «Помню» / «Сделано»
|
||||
document.querySelectorAll('.card button[data-action]').forEach(btn => btn.addEventListener('click', async () => {
|
||||
const card = btn.closest('.card');
|
||||
card.querySelectorAll('button').forEach(b => b.disabled = true);
|
||||
const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`);
|
||||
if (r.ok) location.reload(); else btn.disabled = false;
|
||||
if (r.ok) location.reload(); else card.querySelectorAll('button').forEach(b => b.disabled = false);
|
||||
}));
|
||||
|
||||
// Настройки
|
||||
@@ -4998,27 +5457,28 @@ class InboxController extends Controller
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.target);
|
||||
const r = await call('PATCH', '/me', { tz: f.get('tz'), quiet_start: f.get('quiet_start'), quiet_end: f.get('quiet_end') });
|
||||
if (r.ok) location.reload(); else alert((await r.json()).message || 'Ошибка');
|
||||
if (r.ok) location.reload(); else showErr(document.getElementById('profile-err'), r);
|
||||
});
|
||||
document.getElementById('webhook').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.target);
|
||||
const r = await call('POST', '/me/channels', { type: 'webhook', config: { deliver_url: f.get('deliver_url'), presence_url: f.get('presence_url') } });
|
||||
if (r.ok) location.reload(); else alert((await r.json()).message || 'Ошибка');
|
||||
});
|
||||
document.getElementById('telegram').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const r = await call('POST', '/me/channels/telegram/link');
|
||||
const d = await r.json();
|
||||
document.getElementById('tg-result').innerHTML = `Отправь боту <code>/start ${d.code}</code> или <a href="${d.bot_url}" target="_blank">открой</a>`;
|
||||
if (r.ok) location.reload(); else showErr(document.getElementById('webhook-err'), r);
|
||||
});
|
||||
document.querySelectorAll('[data-delete-channel]').forEach(btn => btn.addEventListener('click', async () => {
|
||||
const r = await call('DELETE', `/me/channels/${btn.dataset.deleteChannel}`);
|
||||
if (r.ok) location.reload();
|
||||
}));
|
||||
document.getElementById('tg-connect').addEventListener('click', async e => {
|
||||
const r = await call('POST', '/me/channels/telegram/link');
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
document.getElementById('tg-code').textContent = `/start ${d.code}`;
|
||||
document.getElementById('tg-link').href = d.bot_url;
|
||||
e.target.hidden = true; document.getElementById('tg-box').hidden = false;
|
||||
});
|
||||
|
||||
// Присутствие: heartbeat только если вкладка видима и была активность за 3 минуты.
|
||||
// Забытая фоновая вкладка не должна отвечать «пользователь тут» и глотать доставки.
|
||||
// Присутствие: heartbeat только при видимой вкладке и активности за 3 минуты.
|
||||
let lastActivity = Date.now();
|
||||
['pointerdown', 'pointermove', 'keydown', 'scroll'].forEach(ev => addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true }));
|
||||
const heartbeat = () => {
|
||||
@@ -5030,35 +5490,28 @@ class InboxController extends Controller
|
||||
setInterval(heartbeat, 30 * 1000);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { lastActivity = Date.now(); heartbeat(); } });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Add to the `remote.user` group in `core/routes/web.php`:
|
||||
```php
|
||||
Route::get('/', \App\Http\Controllers\InboxController::class);
|
||||
```
|
||||
and delete `core/resources/views/welcome.blade.php`.
|
||||
Delete `core/resources/views/welcome.blade.php`.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
- [ ] **Step 6: Run tests, Pint, whole suite**
|
||||
|
||||
```bash
|
||||
cd core && php artisan test --filter InboxPageTest
|
||||
cd core && php artisan test --filter InboxPageTest && php artisan test && vendor/bin/pint --test
|
||||
```
|
||||
Expected: 2 tests PASS.
|
||||
Expected: all green. In `test_renders_groups_cards_archive_and_settings` the relative strings depend on `setTestNow` (2026-09-04): «сегодня» for 09-04, «3 дня назад» for 09-01, «через 26 дней» for 09-30.
|
||||
|
||||
- [ ] **Step 5: Run the whole suite and Pint**
|
||||
- [ ] **Step 7: Visual check against the prototype**
|
||||
|
||||
Open `docs/design/handoff/hado-inbox.dc.html` in a browser (keep `support.js` next to it) and `http://localhost:8080/` with an `X-Remote-User` header (Task 13 compose). Compare: header, counter pill, group headings, card plaque, buttons, archive rows, settings. Fix spacing or token mismatches in `inbox.blade.php` only; do not change behavior.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd core && php artisan test && vendor/bin/pint --test
|
||||
```
|
||||
Expected: all green; if Pint reports style issues run `vendor/bin/pint` and re-run tests.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /d/ai-projects/hado && git add -A && git commit -m "feat: web inbox with presence heartbeat
|
||||
cd /d/ai-projects/hado && git add -A && git commit -m "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"
|
||||
@@ -5066,7 +5519,7 @@ Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Docker Compose, Dockerfiles, Caddy snippet, README
|
||||
### Task 13: Docker Compose, Dockerfiles, Caddy snippet, README
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.yml`, `core/Dockerfile`, `core/Dockerfile.dev`, `deploy/Caddyfile.snippet`, `README.md`, `.env.example` (root, for compose)
|
||||
@@ -5254,10 +5707,10 @@ Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
|
||||
| §6 states, transitions, eve rule, recompute on upsert | 3, 4, 5, 9 |
|
||||
| §7 cascade | 3 |
|
||||
| §8 windows, truncated window fallback, quiet rule, ack/done paths | 4, 7, 10 |
|
||||
| §9 drivers, presence table, web heartbeat rule, tick algorithm, pending row, retries | 8, 9, 11 |
|
||||
| §10 `/me` API, inbox, tz/quiet hours, channels, telegram link | 7, 10, 11 |
|
||||
| §11 three auth mechanisms, Caddy bypass paths | 6, 7, 10, 12 |
|
||||
| §12 compose, scheduler, worker, env | 12 |
|
||||
| §9 drivers, presence table, web heartbeat rule, tick algorithm, pending row, retries | 8, 9, 12 |
|
||||
| §10 `/me` API, inbox, tz/quiet hours, channels, telegram link, manual events, archive | 7, 10, 11, 12 |
|
||||
| §11 three auth mechanisms, Caddy bypass paths | 6, 7, 10, 13 |
|
||||
| §12 compose, scheduler, worker, env | 13 |
|
||||
| §13 tests | every task |
|
||||
|
||||
Known simplification vs spec: presence probes run sequentially per user (each cached 60 s) instead of in parallel; only `webhook` does network I/O, so the difference is at most one 2-second timeout per user per minute. Note it in the spec §9 if it ever matters.
|
||||
|
||||
Reference in New Issue
Block a user