From 860e27c4e0b68c082becc9279191db18863cc932 Mon Sep 17 00:00:00 2001
From: Houssem Eddine BENABDALLAH
Date: Thu, 22 Oct 2026 22:26:44 +0200
Subject: [PATCH 1/6] feat: host events with an issue-like page, a Watch-style
going button and a milestone agenda
---
KNOWLEDGE_BASE.md | 11 +
PRODUCT.md | 1 +
app/Actions/CreateEvent.php | 45 ++
app/Actions/CreateEventCancellation.php | 25 +
.../CreateEventCancellationNotifications.php | 39 ++
app/Actions/CreateExport.php | 53 +++
app/Actions/DeleteCommitContent.php | 3 +-
app/Actions/DeleteEventAttendance.php | 16 +
app/Actions/UpdateCommitAiDecision.php | 5 +-
app/Actions/UpdateEventAttendance.php | 22 +
app/Dto/NewEvent.php | 24 +
app/Enums/EventResponse.php | 11 +
app/Enums/EventState.php | 13 +
app/Enums/NotificationType.php | 1 +
.../Controllers/EventAttendanceController.php | 31 ++
.../EventCancellationController.php | 20 +
app/Http/Controllers/EventController.php | 86 ++++
.../Controllers/NotificationController.php | 2 +-
app/Http/Controllers/UserController.php | 15 +-
.../CreateEventCancellationRequest.php | 42 ++
app/Http/Requests/CreateEventRequest.php | 92 ++++
.../Requests/DeleteEventAttendanceRequest.php | 31 ++
app/Http/Requests/ListEventsRequest.php | 30 ++
app/Http/Requests/ShowEventRequest.php | 34 ++
app/Http/Requests/ShowUserRequest.php | 3 +-
.../Requests/UpdateEventAttendanceRequest.php | 49 ++
app/Models/Commit.php | 9 +
app/Models/Event.php | 141 ++++++
app/Models/EventAgendaItem.php | 51 +++
app/Models/EventAttendee.php | 59 +++
app/Models/User.php | 9 +
app/Queries/CommitBuilder.php | 2 +-
app/Queries/EventAttendeesQuery.php | 34 ++
app/Queries/EventsQuery.php | 68 +++
app/Queries/NotificationBuilder.php | 6 +
app/Queries/NotificationGroupsQuery.php | 13 +
app/Rules/EventDuration.php | 36 ++
app/Rules/OpenEvent.php | 31 ++
database/factories/EventAgendaItemFactory.php | 27 ++
database/factories/EventAttendeeFactory.php | 36 ++
database/factories/EventFactory.php | 55 +++
.../2026_10_22_120000_create_events_table.php | 27 ++
...120001_create_event_agenda_items_table.php | 23 +
...22_120002_create_event_attendees_table.php | 24 +
rector.php | 7 +
resources/js/components/app-nav.tsx | 13 +-
resources/js/components/commit-card.tsx | 7 +
resources/js/components/commit-event.tsx | 92 ++++
resources/js/components/event-list.tsx | 117 +++++
.../js/components/event-response-button.tsx | 172 +++++++
resources/js/components/event-state-badge.tsx | 51 +++
resources/js/components/event-tile.tsx | 29 ++
resources/js/lib/event-dates.ts | 65 +++
resources/js/lib/event.ts | 61 +++
resources/js/pages/event/create.tsx | 317 +++++++++++++
resources/js/pages/event/index.tsx | 87 ++++
resources/js/pages/event/show.tsx | 429 ++++++++++++++++++
resources/js/pages/notification/index.tsx | 33 +-
resources/js/pages/user/show.tsx | 26 +-
resources/js/types/commit.ts | 2 +
resources/js/types/event.ts | 30 ++
resources/js/types/index.ts | 1 +
resources/js/types/notification.ts | 7 +
routes/web.php | 22 +
.../EventAttendanceControllerTest.php | 85 ++++
.../EventCancellationControllerTest.php | 69 +++
.../Controllers/EventControllerTest.php | 246 ++++++++++
.../Controllers/ExportControllerTest.php | 37 ++
.../NotificationControllerTest.php | 27 ++
.../Controllers/UserControllerTest.php | 20 +-
.../Unit/Actions/DeleteCommitContentTest.php | 3 +
tests/Unit/Actions/DeleteUserTest.php | 17 +
tests/Unit/Enums/NotificationTypeTest.php | 1 +
tests/Unit/EventProgress.test.ts | 82 ++++
tests/Unit/Jobs/ModerateCommitTest.php | 15 +
tests/Unit/NotificationIndex.test.tsx | 29 ++
76 files changed, 3531 insertions(+), 23 deletions(-)
create mode 100644 app/Actions/CreateEvent.php
create mode 100644 app/Actions/CreateEventCancellation.php
create mode 100644 app/Actions/CreateEventCancellationNotifications.php
create mode 100644 app/Actions/DeleteEventAttendance.php
create mode 100644 app/Actions/UpdateEventAttendance.php
create mode 100644 app/Dto/NewEvent.php
create mode 100644 app/Enums/EventResponse.php
create mode 100644 app/Enums/EventState.php
create mode 100644 app/Http/Controllers/EventAttendanceController.php
create mode 100644 app/Http/Controllers/EventCancellationController.php
create mode 100644 app/Http/Controllers/EventController.php
create mode 100644 app/Http/Requests/CreateEventCancellationRequest.php
create mode 100644 app/Http/Requests/CreateEventRequest.php
create mode 100644 app/Http/Requests/DeleteEventAttendanceRequest.php
create mode 100644 app/Http/Requests/ListEventsRequest.php
create mode 100644 app/Http/Requests/ShowEventRequest.php
create mode 100644 app/Http/Requests/UpdateEventAttendanceRequest.php
create mode 100644 app/Models/Event.php
create mode 100644 app/Models/EventAgendaItem.php
create mode 100644 app/Models/EventAttendee.php
create mode 100644 app/Queries/EventAttendeesQuery.php
create mode 100644 app/Queries/EventsQuery.php
create mode 100644 app/Rules/EventDuration.php
create mode 100644 app/Rules/OpenEvent.php
create mode 100644 database/factories/EventAgendaItemFactory.php
create mode 100644 database/factories/EventAttendeeFactory.php
create mode 100644 database/factories/EventFactory.php
create mode 100644 database/migrations/2026_10_22_120000_create_events_table.php
create mode 100644 database/migrations/2026_10_22_120001_create_event_agenda_items_table.php
create mode 100644 database/migrations/2026_10_22_120002_create_event_attendees_table.php
create mode 100644 resources/js/components/commit-event.tsx
create mode 100644 resources/js/components/event-list.tsx
create mode 100644 resources/js/components/event-response-button.tsx
create mode 100644 resources/js/components/event-state-badge.tsx
create mode 100644 resources/js/components/event-tile.tsx
create mode 100644 resources/js/lib/event-dates.ts
create mode 100644 resources/js/lib/event.ts
create mode 100644 resources/js/pages/event/create.tsx
create mode 100644 resources/js/pages/event/index.tsx
create mode 100644 resources/js/pages/event/show.tsx
create mode 100644 resources/js/types/event.ts
create mode 100644 tests/Feature/Controllers/EventAttendanceControllerTest.php
create mode 100644 tests/Feature/Controllers/EventCancellationControllerTest.php
create mode 100644 tests/Feature/Controllers/EventControllerTest.php
create mode 100644 tests/Unit/EventProgress.test.ts
diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index dc159419a8..1232b94ea2 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -29,6 +29,7 @@ This file documents the platform's features, stack, and stories. Feature entries
- [Quoting commits](#quoting-commits)
- [Polls and voting](#polls-and-voting)
- [Scheduled commits](#scheduled-commits)
+ - [Events](#events)
- [Deleting commits and undo](#deleting-commits-and-undo)
- [Stars](#stars)
- [Starred commits](#starred-commits)
@@ -337,6 +338,16 @@ The scheduler checks due posts every minute. Scheduling submits the post for the
Sources: [schedule validation](app/Http/Requests/CreateScheduledCommitRequest.php), [outstanding limit](app/Rules/ScheduledCommitLimit.php), [scheduled-commit controller](app/Http/Controllers/ScheduledCommitController.php), [scheduler](routes/console.php).
+### Events
+
+Host a meetup, a stream, a workshop or a hackathon from **Events** in the navigation (in the **Menu** on mobile) with **New event**. An event has a title of up to **100 characters**, a description, a start and an end at most **7 days** apart and less than a year ahead, and a place, a link, or both: it is labelled **In person**, **Online**, or **Hybrid** accordingly. An optional agenda holds up to **12 timed talks**, each during the event.
+
+The description is posted as an ordinary commit announcing the event, so the event goes through the same moderation as any commit, title, place, link and agenda included, and shows up in logs as a small card on that commit. Until it is approved, only its host sees it. Replies to the announcement are the event's discussion, and deleting the announcement deletes the event. Event details cannot be edited, like commits: the host can only **cancel** an event before it ends, which keeps its page and discussion and marks it **Cancelled**.
+
+Event pages are laid out like a GitHub issue: a state badge (**Upcoming**, **Happening now**, **Ended**, or **Cancelled**), the details, the people going, and the agenda as a milestone whose progress bar ticks off each talk once the next one starts. Signed-in readers answer with a button shaped like GitHub's Watch button: **Going** puts their avatar among the people going, **Interested** is only visible to them, and **Not going** removes their answer. Responses close once an event ends or is cancelled, and the host is always going. People across a block are left out of the avatars. The **Events** page lists upcoming events soonest first and past or cancelled ones latest first, without muted hosts; each profile has an **Events** tab with the events that person hosts. Hosted events and your own responses are in your data export and are deleted with your account.
+
+Sources: [event controller](app/Http/Controllers/EventController.php), [event validation](app/Http/Requests/CreateEventRequest.php), [response rules](app/Rules/OpenEvent.php), [event page](resources/js/pages/event/show.tsx).
+
### Deleting commits and undo
Delete your own commit or reply from its controls. An **Undo** action is available for **30 seconds**, restoring the same commit without sending its notifications again. Deletion removes the post from normal lists, but visible replies can remain under an authorless `[deleted]` placeholder. After the undo window, the deleted post's text, mentions, images, and poll are erased permanently; restoring them is no longer possible. Link previews of the deleted post are cleared too. Deleting a post is different from permanently deleting your account and all its top-level threads.
diff --git a/PRODUCT.md b/PRODUCT.md
index 13dbe7437e..fb150d3cab 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -36,6 +36,7 @@ What exists, one line each (details in the knowledge base):
- **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries).
- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube.
- **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it. Their members post commits as the organization, which readers see from the organization alone.
+- **Events:** meetups and streams announced by a commit, with an issue-like page, an agenda, and Going or Interested responses.
- **Remotes:** private one-to-one and group messages (orgs), and calls, one-to-one or in an org.
- **Notifications:** in-app activity and on-site Remotes alerts by default; off-site browser and PWA notifications require a separate opt-in. Sound is controlled independently and plays while the site is open.
- **Search:** developers by name, commits by meaning.
diff --git a/app/Actions/CreateEvent.php b/app/Actions/CreateEvent.php
new file mode 100644
index 0000000000..ae9c9b3b29
--- /dev/null
+++ b/app/Actions/CreateEvent.php
@@ -0,0 +1,45 @@
+createCommit->handle($user, $body);
+
+ $event = $commit->event()->create([
+ 'title' => $this->emojiShortcodes->replace($newEvent->title),
+ 'starts_at' => $newEvent->startsAt,
+ 'ends_at' => $newEvent->endsAt,
+ 'location' => $newEvent->location,
+ 'url' => $newEvent->url,
+ ]);
+
+ $event->agendaItems()->createMany(array_map(fn (array $item): array => [
+ 'label' => $this->emojiShortcodes->replace($item['label']),
+ 'starts_at' => $item['starts_at'],
+ ], $newEvent->agenda));
+
+ return $event->setRelation('commit', $commit);
+ });
+ }
+}
diff --git a/app/Actions/CreateEventCancellation.php b/app/Actions/CreateEventCancellation.php
new file mode 100644
index 0000000000..bb14086f9b
--- /dev/null
+++ b/app/Actions/CreateEventCancellation.php
@@ -0,0 +1,25 @@
+update(['cancelled_at' => now()]);
+
+ $this->createEventCancellationNotifications->handle($event);
+ });
+ }
+}
diff --git a/app/Actions/CreateEventCancellationNotifications.php b/app/Actions/CreateEventCancellationNotifications.php
new file mode 100644
index 0000000000..e05eb6bda3
--- /dev/null
+++ b/app/Actions/CreateEventCancellationNotifications.php
@@ -0,0 +1,39 @@
+commit?->user_id;
+
+ Notification::query()->insertOrIgnore($event->attendees()
+ ->where('response', EventResponse::Going)
+ ->get()
+ ->map(fn (EventAttendee $attendee): array => [
+ 'id' => Str::uuid7()->toString(),
+ 'recipient_id' => $attendee->user_id,
+ 'actor_id' => $hostId,
+ 'type' => NotificationType::EventCancelled->value,
+ 'commit_id' => $event->commit_id,
+ 'key' => 'event_cancelled:'.$event->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ])
+ ->all());
+ }
+}
diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php
index acab064116..45736e84c2 100644
--- a/app/Actions/CreateExport.php
+++ b/app/Actions/CreateExport.php
@@ -11,6 +11,8 @@
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Device;
+use App\Models\Event;
+use App\Models\EventAgendaItem;
use App\Models\Message;
use App\Models\Poll;
use App\Models\PollOption;
@@ -32,6 +34,8 @@
/**
* @phpstan-type ExportPoll array{commit_id: string, duration_minutes: int, opened_at: string|null, options: list, created_at: string}
* @phpstan-type ExportPollVote array{commit_id: string, option: string, created_at: string}
+ * @phpstan-type ExportEvent array{commit_id: string, title: string, starts_at: string, ends_at: string, location: string|null, url: string|null, agenda: list, cancelled_at: string|null, created_at: string}
+ * @phpstan-type ExportEventResponse array{commit_id: string, response: string, created_at: string, updated_at: string}
* @phpstan-type ExportMessage array{to: string|null, body: string, voice: string|null, created_at: string}
* @phpstan-type ExportUser array{handle: string}
* @phpstan-type UserExport array{
@@ -43,6 +47,8 @@
* organization_commits: Generator>,
* polls: Generator,
* poll_votes: Generator,
+ * events: Generator,
+ * event_responses: Generator,
* stars: Generator,
* following: Generator,
* followers: Generator,
@@ -115,6 +121,8 @@ public function handle(User $user): array
'organization_commits' => $this->organizationCommits($user),
'polls' => $this->polls($user),
'poll_votes' => $this->pollVotes($user),
+ 'events' => $this->events($user),
+ 'event_responses' => $this->eventResponses($user),
'stars' => $this->stars($user),
'following' => $this->handles($user->following()),
'followers' => $this->handles($user->followers()),
@@ -301,6 +309,51 @@ private function activeDays(User $user): Generator
}
}
+ /**
+ * @return Generator
+ */
+ private function events(User $user): Generator
+ {
+ $events = Event::query()
+ ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id))
+ ->with('agendaItems')
+ ->lazyById(500);
+
+ foreach ($events as $event) {
+ yield [
+ 'commit_id' => $event->commit_id,
+ 'title' => $event->title,
+ 'starts_at' => $event->starts_at->toIso8601String(),
+ 'ends_at' => $event->ends_at->toIso8601String(),
+ 'location' => $event->location,
+ 'url' => $event->url,
+ 'agenda' => array_values($event->agendaItems->map(fn (EventAgendaItem $item): array => [
+ 'label' => $item->label,
+ 'starts_at' => $item->starts_at->toIso8601String(),
+ ])->all()),
+ 'cancelled_at' => $event->cancelled_at?->toIso8601String(),
+ 'created_at' => $event->created_at->toIso8601String(),
+ ];
+ }
+ }
+
+ /**
+ * Only the user's own responses: never who else is going.
+ *
+ * @return Generator
+ */
+ private function eventResponses(User $user): Generator
+ {
+ foreach ($user->eventAttendances()->with('event')->lazyById(500) as $attendance) {
+ yield [
+ 'commit_id' => $attendance->event->commit_id,
+ 'response' => $attendance->response->value,
+ 'created_at' => $attendance->created_at->toIso8601String(),
+ 'updated_at' => $attendance->updated_at->toIso8601String(),
+ ];
+ }
+ }
+
/**
* @return Generator
*/
diff --git a/app/Actions/DeleteCommitContent.php b/app/Actions/DeleteCommitContent.php
index 8e1882c326..a2b03a459e 100644
--- a/app/Actions/DeleteCommitContent.php
+++ b/app/Actions/DeleteCommitContent.php
@@ -15,7 +15,7 @@
/**
* Only a commit whose undo window has closed loses its content, so one restored in time, or deleted again since, keeps it.
* The row stays as a tombstone with its author, for its replies, quotes and reports, but its body, mentions, embedding,
- * link preview, poll and images are erased for good. So are the previews of links to it, which hold its body as their
+ * link preview, poll, event and images are erased for good. So are the previews of links to it, which hold its body as their
* title and description, and fall off whatever linked to it.
*/
public function handle(Commit $commit): void
@@ -31,6 +31,7 @@ public function handle(Commit $commit): void
$commit->images()->delete();
$commit->poll()->delete();
+ $commit->event()->delete();
LinkPreview::query()->where('url', 'like', "%/commits/{$commit->id}%")->delete();
diff --git a/app/Actions/DeleteEventAttendance.php b/app/Actions/DeleteEventAttendance.php
new file mode 100644
index 0000000000..f7d4b5074e
--- /dev/null
+++ b/app/Actions/DeleteEventAttendance.php
@@ -0,0 +1,16 @@
+attendees()->where('user_id', $user->id)->delete();
+ }
+}
diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php
index 195817be9b..6a755d48c7 100644
--- a/app/Actions/UpdateCommitAiDecision.php
+++ b/app/Actions/UpdateCommitAiDecision.php
@@ -8,6 +8,7 @@
use App\Enums\CommitStatus;
use App\Models\Commit;
use App\Models\CommitImage;
+use App\Models\EventAgendaItem;
use App\Models\PollOption;
use Illuminate\Support\Facades\Storage;
@@ -26,7 +27,7 @@ public function __construct(
}
/**
- * Asks the AI whether the commit, images included, should be approved. The decision is mostly a hint for
+ * Asks the AI whether the commit, images and any event it announces included, should be approved. The decision is mostly a hint for
* moderators: a clean commit is approved, and it is rejected, without a reason, when the AI is all but certain.
* Failures, such as timeouts or rate limits, are thrown, so the caller can try again later.
*/
@@ -69,6 +70,8 @@ private function text(Commit $commit): string
return collect([$commit->body])
->merge($commit->images->map(fn (CommitImage $image): ?string => $image->description))
->merge($commit->poll?->options->map(fn (PollOption $option): string => $option->label) ?? [])
+ ->merge([$commit->event?->title, $commit->event?->location, $commit->event?->url])
+ ->merge($commit->event?->agendaItems->map(fn (EventAgendaItem $item): string => $item->label) ?? [])
->filter(fn (?string $part): bool => $part !== null && $part !== '')
->implode("\n\n");
}
diff --git a/app/Actions/UpdateEventAttendance.php b/app/Actions/UpdateEventAttendance.php
new file mode 100644
index 0000000000..25090e13d2
--- /dev/null
+++ b/app/Actions/UpdateEventAttendance.php
@@ -0,0 +1,22 @@
+upsert(
+ [['event_id' => $event->id, 'user_id' => $user->id, 'response' => $response->value, 'created_at' => now(), 'updated_at' => now()]],
+ ['event_id', 'user_id'],
+ ['response', 'updated_at'],
+ );
+ }
+}
diff --git a/app/Dto/NewEvent.php b/app/Dto/NewEvent.php
new file mode 100644
index 0000000000..e53bc9c1be
--- /dev/null
+++ b/app/Dto/NewEvent.php
@@ -0,0 +1,24 @@
+ $agenda
+ */
+ public function __construct(
+ public string $title,
+ public CarbonImmutable $startsAt,
+ public CarbonImmutable $endsAt,
+ public ?string $location,
+ public ?string $url,
+ public array $agenda = [],
+ ) {
+ //
+ }
+}
diff --git a/app/Enums/EventResponse.php b/app/Enums/EventResponse.php
new file mode 100644
index 0000000000..526fa6677a
--- /dev/null
+++ b/app/Enums/EventResponse.php
@@ -0,0 +1,11 @@
+handle($user, $event, $request->eventResponse());
+
+ return back();
+ }
+
+ public function destroy(DeleteEventAttendanceRequest $request, #[CurrentUser] User $user, Event $event, DeleteEventAttendance $action): RedirectResponse
+ {
+ $action->handle($user, $event);
+
+ return back();
+ }
+}
diff --git a/app/Http/Controllers/EventCancellationController.php b/app/Http/Controllers/EventCancellationController.php
new file mode 100644
index 0000000000..23c1a6708b
--- /dev/null
+++ b/app/Http/Controllers/EventCancellationController.php
@@ -0,0 +1,20 @@
+handle($event);
+
+ return back();
+ }
+}
diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php
new file mode 100644
index 0000000000..e7d0dd68f8
--- /dev/null
+++ b/app/Http/Controllers/EventController.php
@@ -0,0 +1,86 @@
+ $request->tab(...),
+ 'events' => Inertia::scroll(fn (): CursorPaginator => $events->paginate($currentUser, past: $request->tab() === 'past')),
+ ]);
+ }
+
+ public function create(): Response
+ {
+ return Inertia::render('event/create', [
+ 'limits' => [
+ 'title' => Event::MAX_TITLE_LENGTH,
+ 'location' => Event::MAX_LOCATION_LENGTH,
+ 'url' => Event::MAX_URL_LENGTH,
+ 'agendaItems' => Event::MAX_AGENDA_ITEMS,
+ 'agendaLabel' => Event::MAX_AGENDA_LABEL_LENGTH,
+ 'durationDays' => Event::MAX_DURATION_DAYS,
+ ],
+ ]);
+ }
+
+ public function store(CreateEventRequest $request, #[CurrentUser] User $user, CreateEvent $action): RedirectResponse
+ {
+ $event = $action->handle($user, $request->string('body')->value(), $request->newEvent());
+
+ if ($event->commit?->status === CommitStatus::Pending) {
+ Inertia::flash('toast', [
+ 'type' => 'info',
+ 'message' => 'Your event is pending review. Everyone will see it once it is approved.',
+ ]);
+ }
+
+ return to_route('event.show', $event);
+ }
+
+ /**
+ * The event reads like an issue: its details on top, and the commit announcing it, with its replies, as the discussion.
+ */
+ public function show(ShowEventRequest $request, #[CurrentUser] ?User $currentUser, Event $event, CommitPageQuery $page, CommitRepliesQuery $replies, EventAttendeesQuery $attendees): Response
+ {
+ $commit = $event->commit;
+ assert($commit instanceof Commit);
+
+ return Inertia::render('event/show', [
+ 'event' => fn (): Event => $event->load('agendaItems')->loadCount(['attendees as going_count' => fn (Builder $query): Builder => $query->where('response', EventResponse::Going)]),
+ 'host' => fn (): ?User => $commit->user,
+ 'response' => fn (): ?string => $event->responseOf($currentUser)?->value,
+ 'attendees' => fn (): Collection => $attendees->get($event, $currentUser),
+ 'commit' => fn (): Commit => $page->get($commit, $currentUser),
+ 'replies' => fn (): CursorPaginator => $replies->paginate($commit, $currentUser, limit: $request->repliesPerPage()),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php
index ab7c8034f0..712b7e7920 100644
--- a/app/Http/Controllers/NotificationController.php
+++ b/app/Http/Controllers/NotificationController.php
@@ -20,7 +20,7 @@ public function index(ListNotificationsRequest $request, #[CurrentUser] User $us
$type = $request->type($user);
return Inertia::render('notification/index', [
- 'notifications' => Inertia::scroll(fn (): CursorPaginator => $query->withStarredCommitImages($query->withQuotedCommits($user, $query->paginate($user, $type, $request->through()))))->matchOn('data.group_id'),
+ 'notifications' => Inertia::scroll(fn (): CursorPaginator => $query->withCancelledEvents($query->withStarredCommitImages($query->withQuotedCommits($user, $query->paginate($user, $type, $request->through())))))->matchOn('data.group_id'),
'filters' => ['type' => $type],
'readAt' => CarbonImmutable::now()->startOfSecond()->toJSON(),
]);
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index f54a2dd861..1eca42b586 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -11,6 +11,7 @@
use App\Models\Commit;
use App\Models\User;
use App\Queries\BioProfilesQuery;
+use App\Queries\EventsQuery;
use App\Queries\FollowersYouKnowQuery;
use App\Queries\GitHubReadmeQuery;
use App\Queries\PinnedCommitQuery;
@@ -33,7 +34,7 @@
/**
* Profiles are public, so guests and crawlers read the same page, with the profile's link preview.
*/
- public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PageMetadata $metadata): Response
+ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, EventsQuery $events, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PageMetadata $metadata): Response
{
$blockerId = $currentUser?->blockerIdWith($user);
@@ -74,12 +75,14 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser
'tab' => $request->tab(...),
'pinnedCommit' => fn (): ?Commit => $request->tab() === 'commits' ? $pinned->get($user, $currentUser) : null,
'readme' => fn (): string|false|null => $request->tab() === 'readme' ? $readme->get($user) : null,
- // The README tab shows no commits, so it doesn't look for any.
- 'commits' => Inertia::scroll(fn () => $request->tab() === 'readme'
- ? new CursorPaginator([], 20)
- : $commits->builder($user, $currentUser, $request->tab() === 'replies')
+ 'commits' => Inertia::scroll(fn () => match ($request->tab()) {
+ // The README tab shows no commits, so it doesn't look for any.
+ 'readme' => new CursorPaginator([], 20),
+ 'events' => $events->paginateHostedBy($user, $currentUser),
+ default => $commits->builder($user, $currentUser, $request->tab() === 'replies')
->cursorPaginate(20)
- ->appends(['tab' => $request->tab()])),
+ ->appends(['tab' => $request->tab()]),
+ }),
])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->user($getCountedUser())]);
}
diff --git a/app/Http/Requests/CreateEventCancellationRequest.php b/app/Http/Requests/CreateEventCancellationRequest.php
new file mode 100644
index 0000000000..464788ae12
--- /dev/null
+++ b/app/Http/Requests/CreateEventCancellationRequest.php
@@ -0,0 +1,42 @@
+user();
+ assert($user instanceof User);
+
+ $event = $this->route('event');
+ assert($event instanceof Event);
+
+ if (! $event->isVisibleTo($user) || ! $event->isHostedBy($user)) {
+ return Response::denyAsNotFound();
+ }
+
+ return in_array($event->state(), [EventState::Upcoming, EventState::Live], true)
+ ? Response::allow()
+ : Response::deny('This event is already over.');
+ }
+
+ /**
+ * @return array{}
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/CreateEventRequest.php b/app/Http/Requests/CreateEventRequest.php
new file mode 100644
index 0000000000..ba77f4f4c4
--- /dev/null
+++ b/app/Http/Requests/CreateEventRequest.php
@@ -0,0 +1,92 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'body' => CreateCommitRequest::bodyRules(),
+ 'title' => ['required', 'string', 'max:'.Event::MAX_TITLE_LENGTH],
+ 'starts_at' => ['required', 'date', 'after:now', 'before:+1 year'],
+ 'ends_at' => ['required', 'date', 'after:starts_at', new EventDuration($this->input('starts_at'))],
+ 'location' => ['nullable', 'string', 'max:'.Event::MAX_LOCATION_LENGTH, 'required_without:url'],
+ 'url' => ['nullable', 'string', 'url:http,https', 'max:'.Event::MAX_URL_LENGTH, 'required_without:location'],
+ 'agenda' => ['nullable', 'list', 'max:'.Event::MAX_AGENDA_ITEMS],
+ 'agenda.*' => ['array:label,starts_at'],
+ 'agenda.*.label' => ['required', 'string', 'max:'.Event::MAX_AGENDA_LABEL_LENGTH],
+ 'agenda.*.starts_at' => ['required', 'date', 'after_or_equal:starts_at', 'before:ends_at'],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function attributes(): array
+ {
+ return [
+ 'body' => 'description',
+ 'starts_at' => 'start',
+ 'ends_at' => 'end',
+ 'url' => 'link',
+ 'agenda.*.label' => 'agenda item',
+ 'agenda.*.starts_at' => 'agenda time',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'location.required_without' => 'Add a place, a link, or both.',
+ 'url.required_without' => 'Add a place, a link, or both.',
+ 'agenda.*.starts_at.after_or_equal' => 'Agenda items happen during the event.',
+ 'agenda.*.starts_at.before' => 'Agenda items happen during the event.',
+ ];
+ }
+
+ public function newEvent(): NewEvent
+ {
+ /** @var list $agenda */
+ $agenda = $this->array('agenda');
+
+ return new NewEvent(
+ title: $this->string('title')->trim()->value(),
+ startsAt: CarbonImmutable::parse($this->string('starts_at')->value()),
+ endsAt: CarbonImmutable::parse($this->string('ends_at')->value()),
+ location: $this->filled('location') ? $this->string('location')->trim()->value() : null,
+ url: $this->filled('url') ? $this->string('url')->value() : null,
+ agenda: array_map(fn (array $item): array => [
+ 'label' => mb_trim($item['label']),
+ 'starts_at' => CarbonImmutable::parse($item['starts_at']),
+ ], $agenda),
+ );
+ }
+
+ public function prepareForValidation(): void
+ {
+ CreateCommitRequest::normalizeBody($this);
+ }
+}
diff --git a/app/Http/Requests/DeleteEventAttendanceRequest.php b/app/Http/Requests/DeleteEventAttendanceRequest.php
new file mode 100644
index 0000000000..100b54ffdf
--- /dev/null
+++ b/app/Http/Requests/DeleteEventAttendanceRequest.php
@@ -0,0 +1,31 @@
+route('event');
+ assert($event instanceof Event);
+
+ return $event->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound();
+ }
+
+ /**
+ * @return array{}
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/ListEventsRequest.php b/app/Http/Requests/ListEventsRequest.php
new file mode 100644
index 0000000000..a1049b9b35
--- /dev/null
+++ b/app/Http/Requests/ListEventsRequest.php
@@ -0,0 +1,30 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'tab' => ['nullable', 'string', 'in:upcoming,past'],
+ ];
+ }
+
+ public function tab(): string
+ {
+ return $this->input('tab') === 'past' ? 'past' : 'upcoming';
+ }
+}
diff --git a/app/Http/Requests/ShowEventRequest.php b/app/Http/Requests/ShowEventRequest.php
new file mode 100644
index 0000000000..e76acf7c89
--- /dev/null
+++ b/app/Http/Requests/ShowEventRequest.php
@@ -0,0 +1,34 @@
+route('event');
+ assert($event instanceof Event);
+
+ return $event->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound();
+ }
+
+ public function repliesPerPage(): int
+ {
+ return RepliesPerPage::of($this)->value;
+ }
+
+ /**
+ * @return array{}
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/ShowUserRequest.php b/app/Http/Requests/ShowUserRequest.php
index a6cef64f61..aac3e476ef 100644
--- a/app/Http/Requests/ShowUserRequest.php
+++ b/app/Http/Requests/ShowUserRequest.php
@@ -19,7 +19,7 @@ public function authorize(): bool
public function rules(): array
{
return [
- 'tab' => ['nullable', 'string', 'in:commits,replies,readme'],
+ 'tab' => ['nullable', 'string', 'in:commits,replies,readme,events'],
];
}
@@ -28,6 +28,7 @@ public function tab(): string
return match ($this->input('tab')) {
'replies' => 'replies',
'readme' => 'readme',
+ 'events' => 'events',
default => 'commits',
};
}
diff --git a/app/Http/Requests/UpdateEventAttendanceRequest.php b/app/Http/Requests/UpdateEventAttendanceRequest.php
new file mode 100644
index 0000000000..060533c072
--- /dev/null
+++ b/app/Http/Requests/UpdateEventAttendanceRequest.php
@@ -0,0 +1,49 @@
+event()->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound();
+ }
+
+ /**
+ * @return array>
+ */
+ public function rules(): array
+ {
+ $user = $this->user();
+ assert($user instanceof User);
+
+ return [
+ 'response' => ['required', Rule::enum(EventResponse::class), new OpenEvent($this->event(), $user)],
+ ];
+ }
+
+ public function eventResponse(): EventResponse
+ {
+ return EventResponse::from($this->string('response')->value());
+ }
+
+ private function event(): Event
+ {
+ $event = $this->route('event');
+ assert($event instanceof Event);
+
+ return $event;
+ }
+}
diff --git a/app/Models/Commit.php b/app/Models/Commit.php
index 805af50de6..4dbb3f3440 100644
--- a/app/Models/Commit.php
+++ b/app/Models/Commit.php
@@ -56,6 +56,7 @@
* @property-read string|null $continuation_id
* @property-read LinkPreview|null $linkPreview
* @property-read Poll|null $poll
+ * @property-read Event|null $event
* @property-read Commit|null $quotedCommit
* @property-read Collection $quotes
* @property-read Collection $images
@@ -311,6 +312,14 @@ public function poll(): HasOne
return $this->hasOne(Poll::class);
}
+ /**
+ * @return HasOne
+ */
+ public function event(): HasOne
+ {
+ return $this->hasOne(Event::class);
+ }
+
/**
* @return BelongsTo
*/
diff --git a/app/Models/Event.php b/app/Models/Event.php
new file mode 100644
index 0000000000..31dbb298f4
--- /dev/null
+++ b/app/Models/Event.php
@@ -0,0 +1,141 @@
+ $agendaItems
+ * @property-read Collection $attendees
+ */
+#[Visible(['id', 'commit_id', 'title', 'starts_at', 'ends_at', 'location', 'url', 'cancelled_at', 'going_count', 'agendaItems', 'commit'])]
+final class Event extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+
+ use HasUuids;
+
+ public const int MAX_TITLE_LENGTH = 100;
+
+ public const int MAX_LOCATION_LENGTH = 100;
+
+ public const int MAX_URL_LENGTH = 255;
+
+ public const int MAX_AGENDA_ITEMS = 12;
+
+ public const int MAX_AGENDA_LABEL_LENGTH = 100;
+
+ /**
+ * Long enough for a conference or a hackathon week, short enough that an event is something you attend.
+ */
+ public const int MAX_DURATION_DAYS = 7;
+
+ /**
+ * @return array
+ */
+ public function casts(): array
+ {
+ return [
+ 'id' => 'string',
+ 'commit_id' => 'string',
+ 'title' => 'string',
+ 'starts_at' => 'datetime',
+ 'ends_at' => 'datetime',
+ 'location' => 'string',
+ 'url' => 'string',
+ 'cancelled_at' => 'datetime',
+ 'going_count' => 'integer',
+ 'created_at' => 'datetime',
+ 'updated_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [...parent::toArray(), 'state' => $this->state()];
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function commit(): BelongsTo
+ {
+ return $this->belongsTo(Commit::class);
+ }
+
+ /**
+ * @return HasMany
+ */
+ public function agendaItems(): HasMany
+ {
+ return $this->hasMany(EventAgendaItem::class)->oldest('starts_at')->orderBy('id');
+ }
+
+ /**
+ * @return HasMany
+ */
+ public function attendees(): HasMany
+ {
+ return $this->hasMany(EventAttendee::class);
+ }
+
+ public function state(): EventState
+ {
+ return match (true) {
+ $this->cancelled_at !== null => EventState::Cancelled,
+ now()->greaterThanOrEqualTo($this->ends_at) => EventState::Ended,
+ now()->greaterThanOrEqualTo($this->starts_at) => EventState::Live,
+ default => EventState::Upcoming,
+ };
+ }
+
+ /**
+ * An event follows its announcement commit: visible once approved, to its host before, and gone with it.
+ */
+ public function isVisibleTo(?User $user): bool
+ {
+ return $this->commit instanceof Commit && $this->commit->isVisibleTo($user);
+ }
+
+ public function isHostedBy(User $user): bool
+ {
+ return $this->commit?->user_id === $user->id;
+ }
+
+ public function responseOf(?User $user): ?EventResponse
+ {
+ if (! $user instanceof User) {
+ return null;
+ }
+
+ return $this->attendees()->where('user_id', $user->id)->first()?->response;
+ }
+}
diff --git a/app/Models/EventAgendaItem.php b/app/Models/EventAgendaItem.php
new file mode 100644
index 0000000000..eb5355c3cf
--- /dev/null
+++ b/app/Models/EventAgendaItem.php
@@ -0,0 +1,51 @@
+ */
+ use HasFactory;
+
+ /**
+ * @return array
+ */
+ public function casts(): array
+ {
+ return [
+ 'id' => 'integer',
+ 'event_id' => 'string',
+ 'label' => 'string',
+ 'starts_at' => 'datetime',
+ 'created_at' => 'datetime',
+ 'updated_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function event(): BelongsTo
+ {
+ return $this->belongsTo(Event::class);
+ }
+}
diff --git a/app/Models/EventAttendee.php b/app/Models/EventAttendee.php
new file mode 100644
index 0000000000..569d986d91
--- /dev/null
+++ b/app/Models/EventAttendee.php
@@ -0,0 +1,59 @@
+ */
+ use HasFactory;
+
+ /**
+ * @return array
+ */
+ public function casts(): array
+ {
+ return [
+ 'id' => 'integer',
+ 'event_id' => 'string',
+ 'user_id' => 'string',
+ 'response' => EventResponse::class,
+ 'created_at' => 'datetime',
+ 'updated_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function event(): BelongsTo
+ {
+ return $this->belongsTo(Event::class);
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index 8f8f264b5f..f4021524ba 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -81,6 +81,7 @@
* @property-read Collection $notifications
* @property-read Collection $sshKeys
* @property-read Collection $pollVotes
+ * @property-read Collection $eventAttendances
* @property-read Collection $devices
* @property-read Collection $timeouts
* @property-read Ban|null $ban
@@ -274,6 +275,14 @@ public function pollVotes(): HasMany
return $this->hasMany(PollVote::class);
}
+ /**
+ * @return HasMany
+ */
+ public function eventAttendances(): HasMany
+ {
+ return $this->hasMany(EventAttendee::class);
+ }
+
/**
* @return HasMany
*/
diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php
index 9ce30e753d..72743c45a8 100644
--- a/app/Queries/CommitBuilder.php
+++ b/app/Queries/CommitBuilder.php
@@ -294,7 +294,7 @@ public function withPollFor(?User $viewer): self
public function withCardDataFor(?User $viewer): self
{
return $this
- ->with(['author', 'linkPreview', 'images'])
+ ->with(['author', 'linkPreview', 'images', 'event.agendaItems'])
->withQuotedCommitFor($viewer)
->withStarsFor($viewer)
->withPollFor($viewer)
diff --git a/app/Queries/EventAttendeesQuery.php b/app/Queries/EventAttendeesQuery.php
new file mode 100644
index 0000000000..c212274df8
--- /dev/null
+++ b/app/Queries/EventAttendeesQuery.php
@@ -0,0 +1,34 @@
+
+ */
+ public function get(Event $event, ?User $viewer): Collection
+ {
+ return User::query()
+ ->select('users.*')
+ ->join('event_attendees', 'event_attendees.user_id', '=', 'users.id')
+ ->where('event_attendees.event_id', $event->id)
+ ->where('event_attendees.response', EventResponse::Going)
+ ->when($viewer, fn (Builder $query, User $viewer): Builder => $query->whereNotIn('users.id', $viewer->idsAcrossBlocks()))
+ ->orderByDesc('event_attendees.id')
+ ->limit(self::LIMIT)
+ ->get();
+ }
+}
diff --git a/app/Queries/EventsQuery.php b/app/Queries/EventsQuery.php
new file mode 100644
index 0000000000..0454ef7336
--- /dev/null
+++ b/app/Queries/EventsQuery.php
@@ -0,0 +1,68 @@
+
+ */
+ public function paginate(?User $viewer, bool $past = false): CursorPaginator
+ {
+ return $this->builder($viewer)
+ ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->withoutMutedAuthorsFor($viewer))
+ ->when(
+ $past,
+ fn (Builder $query): Builder => $query
+ ->where(fn (Builder $query): Builder => $query->where('ends_at', '<=', now())->orWhereNotNull('cancelled_at'))
+ ->latest('starts_at')
+ ->orderByDesc('id'),
+ fn (Builder $query): Builder => $query
+ ->where('ends_at', '>', now())
+ ->whereNull('cancelled_at')
+ ->oldest('starts_at')
+ ->orderBy('id'),
+ )
+ ->cursorPaginate(self::PER_PAGE)
+ ->withQueryString();
+ }
+
+ /**
+ * The events a user hosts, as the viewer may see them, latest first.
+ *
+ * @return CursorPaginator
+ */
+ public function paginateHostedBy(User $host, ?User $viewer): CursorPaginator
+ {
+ return $this->builder($viewer)
+ ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->whereBelongsTo($host))
+ ->latest('starts_at')
+ ->orderByDesc('id')
+ ->cursorPaginate(self::PER_PAGE)
+ ->withQueryString();
+ }
+
+ /**
+ * @return Builder
+ */
+ private function builder(?User $viewer): Builder
+ {
+ return Event::query()
+ ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer))
+ ->with('commit.user')
+ ->withCount(['attendees as going_count' => fn (Builder $query): Builder => $query->where('response', EventResponse::Going)]);
+ }
+}
diff --git a/app/Queries/NotificationBuilder.php b/app/Queries/NotificationBuilder.php
index 15c1a6bed0..9edef95c2a 100644
--- a/app/Queries/NotificationBuilder.php
+++ b/app/Queries/NotificationBuilder.php
@@ -48,6 +48,12 @@ public function relevant(): self
->whereColumn('organization_members.organization_id', 'notifications.organization_id')
->whereColumn('organization_members.user_id', 'notifications.recipient_id')))
->offset(0)))
+ ->orWhere(fn (Builder $query): Builder => $query
+ ->where('type', NotificationType::EventCancelled)
+ ->whereHas('commit', fn (CommitBuilder $commit): CommitBuilder => $commit
+ ->where('status', CommitStatus::Approved)
+ ->has('event')
+ ->offset(0)))
->orWhere('type', NotificationType::LeveledUp))
->withoutSilencedActors();
}
diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php
index 60fb07cdf7..cd574a6683 100644
--- a/app/Queries/NotificationGroupsQuery.php
+++ b/app/Queries/NotificationGroupsQuery.php
@@ -88,6 +88,19 @@ public function withStarredCommitImages(CursorPaginator $groups): CursorPaginato
return $groups;
}
+ /**
+ * A cancellation names the event, which goes with the commit announcing it.
+ *
+ * @param CursorPaginator $groups
+ * @return CursorPaginator
+ */
+ public function withCancelledEvents(CursorPaginator $groups): CursorPaginator
+ {
+ new Collection($groups->items())->where('type', NotificationType::EventCancelled)->loadMissing('commit.event');
+
+ return $groups;
+ }
+
/**
* Whether two unread notifications share a group only depends on what happened between them, so grouping from the
* oldest unread one onward counts the same groups as grouping the whole history, which every page would pay for.
diff --git a/app/Rules/EventDuration.php b/app/Rules/EventDuration.php
new file mode 100644
index 0000000000..158d5a0ad1
--- /dev/null
+++ b/app/Rules/EventDuration.php
@@ -0,0 +1,36 @@
+startsAt) ? $this->startsAt : '');
+ $endsAt = CarbonImmutable::parse(is_string($value) ? $value : '');
+ } catch (Throwable) {
+ return;
+ }
+
+ if ($endsAt->greaterThan($startsAt->addDays(Event::MAX_DURATION_DAYS))) {
+ $fail('An event lasts at most '.Event::MAX_DURATION_DAYS.' days.');
+ }
+ }
+}
diff --git a/app/Rules/OpenEvent.php b/app/Rules/OpenEvent.php
new file mode 100644
index 0000000000..7a758a6431
--- /dev/null
+++ b/app/Rules/OpenEvent.php
@@ -0,0 +1,31 @@
+event->isHostedBy($this->user)) {
+ $fail('You host this event.');
+ } elseif (! in_array($this->event->state(), [EventState::Upcoming, EventState::Live], true)) {
+ $fail('This event no longer takes responses.');
+ }
+ }
+}
diff --git a/database/factories/EventAgendaItemFactory.php b/database/factories/EventAgendaItemFactory.php
new file mode 100644
index 0000000000..a6e3ca16ed
--- /dev/null
+++ b/database/factories/EventAgendaItemFactory.php
@@ -0,0 +1,27 @@
+
+ */
+final class EventAgendaItemFactory extends Factory
+{
+ /**
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'event_id' => Event::factory(),
+ 'label' => fake()->sentence(4),
+ 'starts_at' => now()->addDays(7)->startOfHour(),
+ ];
+ }
+}
diff --git a/database/factories/EventAttendeeFactory.php b/database/factories/EventAttendeeFactory.php
new file mode 100644
index 0000000000..c0ba988131
--- /dev/null
+++ b/database/factories/EventAttendeeFactory.php
@@ -0,0 +1,36 @@
+
+ */
+final class EventAttendeeFactory extends Factory
+{
+ /**
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'event_id' => Event::factory(),
+ 'user_id' => User::factory(),
+ 'response' => EventResponse::Going,
+ ];
+ }
+
+ public function interested(): self
+ {
+ return $this->state(fn (array $attributes): array => [
+ 'response' => EventResponse::Interested,
+ ]);
+ }
+}
diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php
new file mode 100644
index 0000000000..2ce44988e6
--- /dev/null
+++ b/database/factories/EventFactory.php
@@ -0,0 +1,55 @@
+
+ */
+final class EventFactory extends Factory
+{
+ /**
+ * @return array
+ */
+ public function definition(): array
+ {
+ $startsAt = now()->addDays(7)->startOfHour();
+
+ return [
+ 'commit_id' => Commit::factory(),
+ 'title' => fake()->words(3, true),
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->copy()->addHours(2),
+ 'location' => fake()->city(),
+ 'url' => null,
+ ];
+ }
+
+ public function live(): self
+ {
+ return $this->state(fn (array $attributes): array => [
+ 'starts_at' => now()->subHour(),
+ 'ends_at' => now()->addHour(),
+ ]);
+ }
+
+ public function ended(): self
+ {
+ return $this->state(fn (array $attributes): array => [
+ 'starts_at' => now()->subDay(),
+ 'ends_at' => now()->subDay()->addHours(2),
+ ]);
+ }
+
+ public function cancelled(): self
+ {
+ return $this->state(fn (array $attributes): array => [
+ 'cancelled_at' => now(),
+ ]);
+ }
+}
diff --git a/database/migrations/2026_10_22_120000_create_events_table.php b/database/migrations/2026_10_22_120000_create_events_table.php
new file mode 100644
index 0000000000..8df1a51204
--- /dev/null
+++ b/database/migrations/2026_10_22_120000_create_events_table.php
@@ -0,0 +1,27 @@
+uuid('id')->primary();
+ $table->foreignUuid('commit_id')->unique()->constrained()->cascadeOnDelete();
+ $table->string('title', 100);
+ $table->timestamp('starts_at');
+ $table->timestamp('ends_at');
+ $table->string('location', 100)->nullable();
+ $table->string('url', 255)->nullable();
+ $table->timestamp('cancelled_at')->nullable();
+ $table->timestamps();
+
+ $table->index(['starts_at', 'id']);
+ });
+ }
+};
diff --git a/database/migrations/2026_10_22_120001_create_event_agenda_items_table.php b/database/migrations/2026_10_22_120001_create_event_agenda_items_table.php
new file mode 100644
index 0000000000..6c0cd9ddc4
--- /dev/null
+++ b/database/migrations/2026_10_22_120001_create_event_agenda_items_table.php
@@ -0,0 +1,23 @@
+id();
+ $table->foreignUuid('event_id')->constrained()->cascadeOnDelete();
+ $table->string('label', 100);
+ $table->timestamp('starts_at');
+ $table->timestamps();
+
+ $table->index(['event_id', 'starts_at']);
+ });
+ }
+};
diff --git a/database/migrations/2026_10_22_120002_create_event_attendees_table.php b/database/migrations/2026_10_22_120002_create_event_attendees_table.php
new file mode 100644
index 0000000000..f8f84fc331
--- /dev/null
+++ b/database/migrations/2026_10_22_120002_create_event_attendees_table.php
@@ -0,0 +1,24 @@
+id();
+ $table->foreignUuid('event_id')->constrained()->cascadeOnDelete();
+ $table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
+ $table->string('response', 16);
+ $table->timestamps();
+
+ $table->unique(['event_id', 'user_id']);
+ $table->index(['user_id', 'id']);
+ });
+ }
+};
diff --git a/rector.php b/rector.php
index 7b92f146ca..8afa40f59e 100644
--- a/rector.php
+++ b/rector.php
@@ -6,6 +6,7 @@
use Rector\CodingStyle\Rector\ClassMethod\MakeInheritedMethodVisibilitySameAsParentRector;
use Rector\Config\RectorConfig;
use Rector\Php85\Rector\Property\AddOverrideAttributeToOverriddenPropertiesRector;
+use Rector\Renaming\Rector\Name\RenameClassRector;
use RectorLaravel\Rector\Class_\RemoveDefaultAuthorizeTrueFromFormRequestRector;
use RectorLaravel\Rector\If_\ThrowIfRector;
use RectorLaravel\Set\LaravelSetList;
@@ -47,6 +48,12 @@
ThrowIfRector::class => [__DIR__.'/app/Actions/*'],
// Generated by Octane, and runs before the autoloader exists...
__DIR__.'/public/frankenphp-worker.php',
+ // The Event model shares its name with Laravel's Event facade alias, which these models refer to it by...
+ RenameClassRector::class => [
+ __DIR__.'/app/Models/Commit.php',
+ __DIR__.'/app/Models/EventAgendaItem.php',
+ __DIR__.'/app/Models/EventAttendee.php',
+ ],
])
->withPreparedSets(
deadCode: true,
diff --git a/resources/js/components/app-nav.tsx b/resources/js/components/app-nav.tsx
index 31cd9a640b..facdc4fa11 100644
--- a/resources/js/components/app-nav.tsx
+++ b/resources/js/components/app-nav.tsx
@@ -2,6 +2,7 @@ import { Link, usePage } from '@inertiajs/react';
import {
Activity,
Bell,
+ CalendarDays,
Star,
GitCommitVertical,
History,
@@ -17,6 +18,7 @@ import {
import type { ReactElement, ReactNode } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import ConversationController from '@/actions/App/Http/Controllers/ConversationController';
+import EventController from '@/actions/App/Http/Controllers/EventController';
import LogController from '@/actions/App/Http/Controllers/LogController';
import ModerationController from '@/actions/App/Http/Controllers/ModerationController';
import NotificationController from '@/actions/App/Http/Controllers/NotificationController';
@@ -94,6 +96,11 @@ function useNavItems(): (NavItem & { isActive: boolean })[] {
},
]
: []),
+ {
+ title: 'Events',
+ href: EventController.index(),
+ icon: CalendarDays,
+ },
{
title: 'Profile',
href: hrefFor((user) => show(user)),
@@ -138,7 +145,9 @@ function useNavItems(): (NavItem & { isActive: boolean })[] {
? isCurrentOrParentUrl('/remotes')
: item.title === 'Operations'
? isCurrentOrParentUrl('/operations')
- : isCurrentUrl(item.href)),
+ : item.title === 'Events'
+ ? isCurrentOrParentUrl('/events')
+ : isCurrentUrl(item.href)),
}));
}
@@ -535,7 +544,7 @@ export function MobileNav({ children }: { children?: ReactNode }) {
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
const allItems = useNavItems();
const menuItems = allItems.filter((item) =>
- ['Profile', 'Settings', 'Operations'].includes(item.title),
+ ['Events', 'Profile', 'Settings', 'Operations'].includes(item.title),
);
const items = allItems.filter(
(item) =>
diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx
index 94eea0222c..f12ba8fe8d 100644
--- a/resources/js/components/commit-card.tsx
+++ b/resources/js/components/commit-card.tsx
@@ -5,6 +5,7 @@ import { useCallback, useContext, useRef, useState } from 'react';
import CommitController from '@/actions/App/Http/Controllers/CommitController';
import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController';
import { FoldedCommitBody } from '@/components/commit-body';
+import { CommitEvent } from '@/components/commit-event';
import { CommitImages } from '@/components/commit-images';
import { CommitPastelButton } from '@/components/commit-pastel-button';
import { CommitPoll } from '@/components/commit-poll';
@@ -475,6 +476,12 @@ export function CommitCard({
poll={commit.poll}
/>
)}
+ {commit.event && (
+
+ )}
{commit.quoted_commit_id !== null && (
;
+ event: Event;
+}) {
+ const { auth } = usePage().props;
+ const now = useNow();
+ const timeZone = auth.user?.timezone ?? undefined;
+ const state = eventStateAt(event, now);
+ const isOnlyOnline =
+ event.location === null ||
+ event.location.trim().toLowerCase() === 'online';
+ const where =
+ event.url === null
+ ? (event.location ?? '')
+ : isOnlyOnline
+ ? 'Online'
+ : `${event.location} · Online`;
+
+ return (
+
+
+
+
+
+
+ {event.title}
+
+
+
+
+ {formatEventWhen(event, timeZone)}
+
+
+ {eventFormat(event) === 'Online' ? (
+
+ ) : (
+
+ )}
+
+ {where}
+
+
+
+
+ {commit.status !== 'approved' &&
+ event.agenda_items !== undefined &&
+ event.agenda_items.length > 0 && (
+
+ {event.agenda_items.map((item) => (
+
+
+ {formatEventTime(item.starts_at, timeZone)}
+
+
+ {item.label}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/resources/js/components/event-list.tsx b/resources/js/components/event-list.tsx
new file mode 100644
index 0000000000..6d071f1311
--- /dev/null
+++ b/resources/js/components/event-list.tsx
@@ -0,0 +1,117 @@
+import { InfiniteScroll, Link, usePage } from '@inertiajs/react';
+import {
+ CalendarCheck2,
+ CalendarClock,
+ CalendarX2,
+ Radio,
+ Users,
+} from 'lucide-react';
+import type { ReactNode } from 'react';
+import EventController from '@/actions/App/Http/Controllers/EventController';
+import { Spinner } from '@/components/ui/spinner';
+import { useNow } from '@/hooks/use-now';
+import { eventFormat, eventStateAt, eventStateLabels } from '@/lib/event';
+import { formatEventWhen } from '@/lib/event-dates';
+import { exactCountTitle, formatCount } from '@/lib/numbers';
+import { cn, shortHash } from '@/lib/utils';
+import type { CursorPaginated, EventState, EventWithHost } from '@/types';
+
+const stateIcons = {
+ upcoming: CalendarClock,
+ live: Radio,
+ ended: CalendarCheck2,
+ cancelled: CalendarX2,
+} satisfies Record;
+
+const stateColors: Record = {
+ upcoming: 'text-emerald-600 dark:text-emerald-400',
+ live: 'text-red-600 dark:text-red-400',
+ ended: 'text-violet-600 dark:text-violet-400',
+ cancelled: 'text-muted-foreground',
+};
+
+/**
+ * Events listed the way GitHub lists issues: a state icon, the title, and one line of metadata.
+ */
+export function EventList({
+ events,
+ data,
+ empty,
+}: {
+ events: CursorPaginated;
+ data: string;
+ empty: ReactNode;
+}) {
+ const { auth } = usePage().props;
+ const now = useNow();
+ const timeZone = auth.user?.timezone ?? undefined;
+
+ if (events.data.length === 0) {
+ return {empty}
;
+ }
+
+ return (
+
+
+
+ }
+ >
+
+ {events.data.map((event) => {
+ const state = eventStateAt(event, now);
+ const Icon = stateIcons[state];
+
+ return (
+
+
+
+
+
+ {event.title}
+
+
+ {eventFormat(event)}
+
+
+
+ #{shortHash(event.id)} ·{' '}
+ {formatEventWhen(event, timeZone)}
+
+
+ {event.location !== null &&
+ `${event.location} · `}
+ hosted by @{event.commit.user.username}
+
+
+
+
+ {formatCount(event.going_count)}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/resources/js/components/event-response-button.tsx b/resources/js/components/event-response-button.tsx
new file mode 100644
index 0000000000..63e87a8162
--- /dev/null
+++ b/resources/js/components/event-response-button.tsx
@@ -0,0 +1,172 @@
+import { Link, router, usePage } from '@inertiajs/react';
+import { CalendarCheck, Check, ChevronDown, Star, X } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import EventAttendanceController from '@/actions/App/Http/Controllers/EventAttendanceController';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { exactCountTitle, formatCount } from '@/lib/numbers';
+import { cn } from '@/lib/utils';
+import { login } from '@/routes';
+import type { EventResponse } from '@/types';
+
+type Choice = EventResponse | null;
+
+const choices: {
+ value: Choice;
+ label: string;
+ description: string;
+ icon: typeof Check;
+}[] = [
+ {
+ value: null,
+ label: 'Not going',
+ description: 'You are not listed and nothing is kept.',
+ icon: X,
+ },
+ {
+ value: 'going',
+ label: 'Going',
+ description: 'You show up among the people going.',
+ icon: CalendarCheck,
+ },
+ {
+ value: 'interested',
+ label: 'Interested',
+ description: 'Keep an eye on it. Only you see this.',
+ icon: Star,
+ },
+];
+
+/**
+ * A response picker in the shape of GitHub's Watch button: the current choice opens a menu of all of them, each with
+ * what it means, beside the number of people going.
+ */
+export function EventResponseButton({
+ eventId,
+ response,
+ goingCount,
+ isHost,
+ isOpen,
+ className,
+}: {
+ eventId: string;
+ response: Choice;
+ goingCount: number;
+ isHost: boolean;
+ isOpen: boolean;
+ className?: string;
+}) {
+ const { auth } = usePage().props;
+ const [isSaving, setIsSaving] = useState(false);
+ const current = choices.find((choice) => choice.value === response);
+ const Icon = isHost ? CalendarCheck : (current?.icon ?? X);
+ const label = isHost ? 'Hosting' : (current?.label ?? 'Not going');
+
+ const choose = (value: Choice) => {
+ if (value === response) {
+ return;
+ }
+
+ const options = {
+ preserveScroll: true,
+ only: ['event', 'response', 'attendees'],
+ onStart: () => setIsSaving(true),
+ onFinish: () => setIsSaving(false),
+ onError: (errors: Record) =>
+ toast.error(
+ Object.values(errors)[0] ?? 'Could not save your response.',
+ ),
+ };
+
+ if (value === null) {
+ router.delete(EventAttendanceController.destroy(eventId), options);
+
+ return;
+ }
+
+ router.put(
+ EventAttendanceController.update(eventId),
+ { response: value },
+ options,
+ );
+ };
+
+ const trigger = (
+
+
+ {label}
+ {!isHost && }
+
+ );
+
+ return (
+
+ {auth.user === null ? (
+
+
+
+ Not going
+
+
+
+ ) : isHost || !isOpen ? (
+ trigger
+ ) : (
+
+ {trigger}
+
+ {choices.map((choice) => (
+ choose(choice.value)}
+ className="items-start gap-2 py-2"
+ data-test={`event-response-${choice.value ?? 'none'}`}
+ >
+
+
+
+ {choice.label}
+
+
+ {choice.description}
+
+
+
+ ))}
+
+
+ )}
+
+ {formatCount(goingCount)}
+
+
+ );
+}
diff --git a/resources/js/components/event-state-badge.tsx b/resources/js/components/event-state-badge.tsx
new file mode 100644
index 0000000000..30a1b56dc9
--- /dev/null
+++ b/resources/js/components/event-state-badge.tsx
@@ -0,0 +1,51 @@
+import { CalendarCheck2, CalendarClock, CalendarX2, Radio } from 'lucide-react';
+import { eventStateLabels } from '@/lib/event';
+import { cn } from '@/lib/utils';
+import type { EventState } from '@/types';
+
+const styles: Record = {
+ upcoming:
+ 'border-emerald-600/30 bg-emerald-600/10 text-emerald-700 dark:text-emerald-400',
+ live: 'border-red-600/30 bg-red-600/10 text-red-700 dark:text-red-400',
+ ended: 'border-violet-600/30 bg-violet-600/10 text-violet-700 dark:text-violet-400',
+ cancelled: 'border-border bg-muted text-muted-foreground',
+};
+
+const icons = {
+ upcoming: CalendarClock,
+ live: Radio,
+ ended: CalendarCheck2,
+ cancelled: CalendarX2,
+} satisfies Record;
+
+/**
+ * Like an issue's Open and Closed badge: the one place an event's state is spelled out.
+ */
+export function EventStateBadge({
+ state,
+ size = 'default',
+ className,
+}: {
+ state: EventState;
+ size?: 'default' | 'small';
+ className?: string;
+}) {
+ const Icon = icons[state];
+
+ return (
+
+
+ {eventStateLabels[state]}
+
+ );
+}
diff --git a/resources/js/components/event-tile.tsx b/resources/js/components/event-tile.tsx
new file mode 100644
index 0000000000..55e802faeb
--- /dev/null
+++ b/resources/js/components/event-tile.tsx
@@ -0,0 +1,29 @@
+import { eventTile } from '@/lib/event-dates';
+import { cn } from '@/lib/utils';
+
+export function EventTile({
+ date,
+ timeZone,
+ className,
+}: {
+ date: string;
+ timeZone?: string;
+ className?: string;
+}) {
+ const { month, day } = eventTile(date, timeZone);
+
+ return (
+
+
+ {month}
+
+ {day}
+
+ );
+}
diff --git a/resources/js/lib/event-dates.ts b/resources/js/lib/event-dates.ts
new file mode 100644
index 0000000000..2eca3d0e02
--- /dev/null
+++ b/resources/js/lib/event-dates.ts
@@ -0,0 +1,65 @@
+import { dateLocale } from '@/lib/utils';
+import type { Event } from '@/types';
+
+function sameDay(a: Date, b: Date, timeZone?: string): boolean {
+ const day = (date: Date) =>
+ date.toLocaleDateString(dateLocale, {
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ timeZone,
+ });
+
+ return day(a) === day(b);
+}
+
+export function formatEventTime(date: string, timeZone?: string): string {
+ return new Date(date).toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZone,
+ });
+}
+
+export function formatEventDay(date: string, timeZone?: string): string {
+ return new Date(date).toLocaleDateString(dateLocale, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ timeZone,
+ });
+}
+
+/**
+ * "Tue, Nov 4, 2026 · 6:00 PM – 8:00 PM", or both days when it spans several.
+ */
+export function formatEventWhen(
+ event: Pick,
+ timeZone?: string,
+): string {
+ const start = `${formatEventDay(event.starts_at, timeZone)} · ${formatEventTime(event.starts_at, timeZone)}`;
+
+ if (sameDay(new Date(event.starts_at), new Date(event.ends_at), timeZone)) {
+ return `${start} – ${formatEventTime(event.ends_at, timeZone)}`;
+ }
+
+ return `${start} – ${formatEventDay(event.ends_at, timeZone)} · ${formatEventTime(event.ends_at, timeZone)}`;
+}
+
+/**
+ * A short calendar tile: "NOV" over "4".
+ */
+export function eventTile(
+ date: string,
+ timeZone?: string,
+): { month: string; day: string } {
+ const value = new Date(date);
+
+ return {
+ month: value
+ .toLocaleDateString(dateLocale, { month: 'short', timeZone })
+ .toUpperCase(),
+ day: value.toLocaleDateString(dateLocale, { day: 'numeric', timeZone }),
+ };
+}
diff --git a/resources/js/lib/event.ts b/resources/js/lib/event.ts
new file mode 100644
index 0000000000..c05ee9fd84
--- /dev/null
+++ b/resources/js/lib/event.ts
@@ -0,0 +1,61 @@
+import type { Event, EventAgendaItem, EventState } from '@/types';
+
+export const eventStateLabels: Record = {
+ upcoming: 'Upcoming',
+ live: 'Happening now',
+ ended: 'Ended',
+ cancelled: 'Cancelled',
+};
+
+/**
+ * The state as of now, since a page left open keeps the state it was served with.
+ */
+export function eventStateAt(event: Event, now: Date): EventState {
+ if (event.cancelled_at !== null) {
+ return 'cancelled';
+ }
+
+ if (now >= new Date(event.ends_at)) {
+ return 'ended';
+ }
+
+ return now >= new Date(event.starts_at) ? 'live' : 'upcoming';
+}
+
+/**
+ * How far along the agenda is, like a milestone: an item is done once the next one has started, and the last one once
+ * the event has ended.
+ */
+export function agendaProgress(
+ items: EventAgendaItem[],
+ event: Pick,
+ now: Date,
+): { done: number; current: number | null } {
+ let done = 0;
+ let current: number | null = null;
+
+ items.forEach((item, index) => {
+ const next = items[index + 1]?.starts_at ?? event.ends_at;
+
+ if (now >= new Date(next)) {
+ done++;
+ } else if (now >= new Date(item.starts_at)) {
+ current = item.id;
+ }
+ });
+
+ return { done, current };
+}
+
+/**
+ * A place, a link, or both, as GitHub would label it.
+ */
+export function eventFormat(
+ event: Pick,
+): 'In person' | 'Online' | 'Hybrid' {
+ if (event.location !== null && event.url !== null) {
+ return 'Hybrid';
+ }
+
+ return event.url !== null ? 'Online' : 'In person';
+}
diff --git a/resources/js/pages/event/create.tsx b/resources/js/pages/event/create.tsx
new file mode 100644
index 0000000000..9fb3916ea6
--- /dev/null
+++ b/resources/js/pages/event/create.tsx
@@ -0,0 +1,317 @@
+import { Head, Link, useForm } from '@inertiajs/react';
+import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
+import type { FormEvent } from 'react';
+import EventController from '@/actions/App/Http/Controllers/EventController';
+import InputError from '@/components/input-error';
+import { MentionTextarea } from '@/components/mention-textarea';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import AppLayout from '@/layouts/app-layout';
+import { characterCount, cn } from '@/lib/utils';
+
+const inputClass =
+ 'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30';
+
+const maxBodyLength = 4000;
+
+type AgendaDraft = { label: string; starts_at: string };
+
+/**
+ * A datetime-local value is in the browser's time zone, so it is sent as an instant the server can't misread.
+ */
+function toInstant(value: string): string {
+ return value === '' ? '' : new Date(value).toISOString();
+}
+
+export default function Create({
+ limits,
+}: {
+ limits: {
+ title: number;
+ location: number;
+ url: number;
+ agendaItems: number;
+ agendaLabel: number;
+ durationDays: number;
+ };
+}) {
+ const form = useForm({
+ title: '',
+ body: '',
+ starts_at: '',
+ ends_at: '',
+ location: '',
+ url: '',
+ agenda: [] as AgendaDraft[],
+ });
+ const { data, setData, errors, processing } = form;
+ const errorFor = (key: string): string | undefined =>
+ (errors as Record)[key];
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+
+ form.transform((values) => ({
+ ...values,
+ starts_at: toInstant(values.starts_at),
+ ends_at: toInstant(values.ends_at),
+ location: values.location.trim() === '' ? null : values.location,
+ url: values.url.trim() === '' ? null : values.url,
+ agenda: values.agenda.map((item) => ({
+ label: item.label,
+ starts_at: toInstant(item.starts_at),
+ })),
+ }));
+ form.post(EventController.store.url());
+ };
+
+ const updateAgenda = (index: number, item: Partial) =>
+ setData(
+ 'agenda',
+ data.agenda.map((existing, position) =>
+ position === index ? { ...existing, ...item } : existing,
+ ),
+ );
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+Create.layout = AppLayout;
diff --git a/resources/js/pages/event/index.tsx b/resources/js/pages/event/index.tsx
new file mode 100644
index 0000000000..3c20d33471
--- /dev/null
+++ b/resources/js/pages/event/index.tsx
@@ -0,0 +1,87 @@
+import { Head, Link, usePage } from '@inertiajs/react';
+import { Plus } from 'lucide-react';
+import EventController from '@/actions/App/Http/Controllers/EventController';
+import { EmptyFeed } from '@/components/commit-feed';
+import { EventList } from '@/components/event-list';
+import { PageTabs } from '@/components/page-tabs';
+import { Button } from '@/components/ui/button';
+import AppLayout from '@/layouts/app-layout';
+import { login } from '@/routes';
+import type { CursorPaginated, EventWithHost } from '@/types';
+
+export default function Index({
+ tab,
+ events,
+}: {
+ tab: 'upcoming' | 'past';
+ events: CursorPaginated;
+}) {
+ const { auth } = usePage().props;
+
+ return (
+ <>
+
+
+
+
Events
+
+
+
+ New event
+
+
+
+
+
+
+
+
+ Nothing scheduled yet. Host a meetup, a stream
+ or a hackathon, and it shows up here.
+
+ ) : (
+
+ Events that ended or were cancelled show up
+ here.
+
+ )
+ }
+ />
+
+ >
+ );
+}
+
+Index.layout = AppLayout;
diff --git a/resources/js/pages/event/show.tsx b/resources/js/pages/event/show.tsx
new file mode 100644
index 0000000000..8e32d8957f
--- /dev/null
+++ b/resources/js/pages/event/show.tsx
@@ -0,0 +1,429 @@
+import { Head, Link, router, usePage } from '@inertiajs/react';
+import {
+ ArrowLeft,
+ CalendarDays,
+ Circle,
+ CircleCheck,
+ CircleDot,
+ Globe,
+ Link2,
+ MapPin,
+ Tag,
+ Users,
+} from 'lucide-react';
+import type { ReactNode } from 'react';
+import { useState } from 'react';
+import EventCancellationController from '@/actions/App/Http/Controllers/EventCancellationController';
+import EventController from '@/actions/App/Http/Controllers/EventController';
+import { show as userShow } from '@/actions/App/Http/Controllers/UserController';
+import { CommitCard } from '@/components/commit-card';
+import { EventResponseButton } from '@/components/event-response-button';
+import { EventStateBadge } from '@/components/event-state-badge';
+import { GraphCanvas } from '@/components/graph-canvas';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { UserAvatar } from '@/components/user-avatar';
+import { useNow } from '@/hooks/use-now';
+import AppLayout from '@/layouts/app-layout';
+import { agendaProgress, eventFormat, eventStateAt } from '@/lib/event';
+import { formatEventTime, formatEventWhen } from '@/lib/event-dates';
+import { cn, formatRelativeTime, shortHash } from '@/lib/utils';
+import type {
+ Commit,
+ CursorPaginated,
+ Event,
+ EventAgendaItem,
+ EventResponse,
+ ThreadCommit,
+ User,
+} from '@/types';
+
+export default function Show({
+ event,
+ host,
+ response,
+ attendees,
+ commit,
+ replies,
+}: {
+ event: Event & { going_count: number; agenda_items: EventAgendaItem[] };
+ host: User | null;
+ response: EventResponse | null;
+ attendees: User[];
+ commit: Commit;
+ replies: CursorPaginated;
+}) {
+ const { auth } = usePage().props;
+ const now = useNow();
+ const timeZone = auth.user?.timezone ?? undefined;
+ const state = eventStateAt(event, now);
+ const isHost = auth.user !== null && host?.id === auth.user.id;
+ const isOpen = state === 'upcoming' || state === 'live';
+
+ const goBack = () => {
+ if (window.history.length > 1) {
+ window.history.back();
+
+ return;
+ }
+
+ router.visit(EventController.index());
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
Event
+
+ {shortHash(event.id)}
+
+
+
+
+
+ {event.title}{' '}
+
+ #{shortHash(event.id)}
+
+
+
+
+ {host && (
+
+
+ @{host.username}
+ {' '}
+ {state === 'cancelled'
+ ? 'cancelled this event'
+ : 'is hosting'}{' '}
+ ·{' '}
+
+ {formatRelativeTime(
+ event.cancelled_at ?? commit.created_at,
+ now,
+ )}
+ {' '}
+ · {event.going_count} going
+
+ )}
+
+
+
+
+ {isHost && isOpen && }
+
+
+
+
+
+
+ {formatEventWhen(event, timeZone)}
+
+
+ {event.location !== null && (
+
+
+ {event.location}
+
+
+ )}
+ {event.url !== null && (
+
+
+ {event.url.replace(/^https?:\/\//u, '')}
+
+
+ )}
+
+
+
+ {eventFormat(event) === 'Online' ? (
+
+ ) : (
+
+ )}
+ {eventFormat(event)}
+
+ {event.agenda_items.length > 0 && (
+
+ {event.agenda_items.length}{' '}
+ {event.agenda_items.length === 1
+ ? 'talk'
+ : 'talks'}
+
+ )}
+
+
+
+ {attendees.length === 0 && host === null ? (
+
+ No one yet
+
+ ) : (
+
+ {host && (
+
+ )}
+ {attendees.map((attendee) => (
+
+ ))}
+ {event.going_count > attendees.length && (
+
+ +{event.going_count - attendees.length}
+
+ )}
+
+ )}
+
+
+
+ {event.agenda_items.length > 0 && (
+
+ )}
+
+
+ Discussion
+
+
+
+
+ >
+ );
+}
+
+function Detail({
+ icon: Icon,
+ term,
+ id,
+ children,
+}: {
+ icon: typeof CalendarDays;
+ term: string;
+ id?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {term}
+
+ {children}
+
+ );
+}
+
+function AttendeeAvatar({ user, label }: { user: User; label?: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * The agenda reads like a milestone: a progress bar over its talks, ticked off as the next one starts.
+ */
+function Agenda({
+ event,
+ items,
+ now,
+}: {
+ event: Event;
+ items: EventAgendaItem[];
+ now: Date;
+}) {
+ const { auth } = usePage().props;
+ const timeZone = auth.user?.timezone ?? undefined;
+ const isCancelled = event.cancelled_at !== null;
+ const { done, current } = isCancelled
+ ? { done: 0, current: null }
+ : agendaProgress(items, event, now);
+ const percent = Math.round((done / items.length) * 100);
+
+ return (
+
+
+
Agenda
+
+ {done} of {items.length} done · {percent}%
+
+
+
+
+ {items.map((item, index) => {
+ const isDone = !isCancelled && index < done;
+ const isCurrent = item.id === current;
+ const Icon = isDone
+ ? CircleCheck
+ : isCurrent
+ ? CircleDot
+ : Circle;
+
+ return (
+
+
+
+ {formatEventTime(item.starts_at, timeZone)}
+
+
+ {item.label}
+ {isCurrent && (
+
+ Now
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * Cancelling is final, like closing an issue as not planned, so it asks first.
+ */
+function CancelEventButton({ event }: { event: Event }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const cancel = () => {
+ router.post(
+ EventCancellationController.store(event.id),
+ {},
+ {
+ preserveScroll: true,
+ onStart: () => setIsSaving(true),
+ onFinish: () => {
+ setIsSaving(false);
+ setIsOpen(false);
+ },
+ },
+ );
+ };
+
+ return (
+ <>
+ setIsOpen(true)}
+ data-test="cancel-event"
+ >
+ Cancel event
+
+
+
+ Cancel “{event.title}”?
+
+ The event stays here, marked as cancelled, with its
+ discussion. It can't be reopened.
+
+
+
+ Keep it
+
+
+ Cancel event
+
+
+
+
+ >
+ );
+}
+
+Show.layout = AppLayout;
diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx
index b2450aa29a..c6a694544a 100644
--- a/resources/js/pages/notification/index.tsx
+++ b/resources/js/pages/notification/index.tsx
@@ -10,6 +10,7 @@ import {
import {
ArrowUp,
AtSign,
+ CalendarX2,
Check,
ChevronDown,
ChevronsUp,
@@ -31,6 +32,7 @@ import {
useState,
} from 'react';
import CommitController from '@/actions/App/Http/Controllers/CommitController';
+import EventController from '@/actions/App/Http/Controllers/EventController';
import { index } from '@/actions/App/Http/Controllers/NotificationController';
import { show as groupShow } from '@/actions/App/Http/Controllers/NotificationGroupController';
import NotificationReadAtController from '@/actions/App/Http/Controllers/NotificationReadAtController';
@@ -49,6 +51,7 @@ import { useNow } from '@/hooks/use-now';
import { useUser } from '@/hooks/use-user';
import AppLayout from '@/layouts/app-layout';
import { backgroundPollOptions } from '@/lib/background-poll';
+import { formatEventWhen } from '@/lib/event-dates';
import { exactCountTitle, formatCount } from '@/lib/numbers';
import {
cn,
@@ -765,6 +768,7 @@ export function NotificationContent({
}) {
const now = useNow();
const { user } = useUser();
+ const timeZone = user?.timezone ?? undefined;
const actorRef = useRef(null);
const { actor } = notification;
const actors =
@@ -800,8 +804,13 @@ export function NotificationContent({
notification.type === 'mentioned' ||
notification.type === 'first_commit';
const level = notification.type === 'leveled_up' ? notification : null;
+ const cancelledEvent =
+ notification.type === 'event_cancelled'
+ ? notification.commit.event
+ : null;
const Icon = {
followed: UserPlus,
+ event_cancelled: CalendarX2,
leveled_up: ChevronsUp,
replied: MessageSquareReply,
quoted: Quote,
@@ -826,12 +835,14 @@ export function NotificationContent({
notification.type === 'leveled_up' ||
(!summary && notification.type === 'starred')
? show(actor)
- : CommitController.show({
- profile: isOwnCommit
- ? (notification.organization ?? user)
- : actor,
- commit: notification.commit,
- });
+ : cancelledEvent
+ ? EventController.show(cancelledEvent.id)
+ : CommitController.show({
+ profile: isOwnCommit
+ ? (notification.organization ?? user)
+ : actor,
+ commit: notification.commit,
+ });
const time = (
{notification.rank_description}
+ ) : cancelledEvent ? (
+
+
+ {cancelledEvent.title}
+ {' '}
+
+ {formatEventWhen(cancelledEvent, timeZone)}
+
+
) : isOwnCommit ? (
) : (
diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx
index 08bc3296a3..3ae03fedac 100644
--- a/resources/js/pages/user/show.tsx
+++ b/resources/js/pages/user/show.tsx
@@ -20,6 +20,7 @@ import { ActivityStreaks } from '@/components/activity-streaks';
import { BioText } from '@/components/bio-text';
import { CommitFeed, EmptyFeed } from '@/components/commit-feed';
import { EditProfileMenu } from '@/components/edit-profile-menu';
+import { EventList } from '@/components/event-list';
import { FollowButton, useIsFollowing } from '@/components/follow-button';
import { FollowersYouKnow } from '@/components/followers-you-know';
import type { FollowersYouKnowPreview } from '@/components/followers-you-know';
@@ -55,6 +56,7 @@ import type {
Activity,
Commit,
CursorPaginated,
+ EventWithHost,
Profile,
Streaks,
} from '@/types';
@@ -93,8 +95,8 @@ export default function Show({
activity: Activity;
streaks: Streaks;
achievements: Achievements;
- commits: CursorPaginated;
- tab: 'commits' | 'replies' | 'readme';
+ commits: CursorPaginated | CursorPaginated;
+ tab: 'commits' | 'replies' | 'readme' | 'events';
pinnedCommit: Commit | null;
readme: string | false | null;
}) {
@@ -577,6 +579,11 @@ export default function Show({
href: show(user, { query: { tab: 'readme' } }),
isActive: tab === 'readme',
},
+ {
+ title: 'Events',
+ href: show(user, { query: { tab: 'events' } }),
+ isActive: tab === 'events',
+ },
]}
/>
@@ -586,10 +593,23 @@ export default function Show({
username={user.handle}
isCurrentUser={isCurrentUser}
/>
+ ) : tab === 'events' ? (
+ }
+ data="commits"
+ empty={
+
+ {isCurrentUser
+ ? 'Events you host show up here. Plan one from Events.'
+ : `@${user.handle} hasn't hosted an event yet.`}
+
+ }
+ />
) : (
}
pinnedCommit={tab === 'commits' ? pinnedCommit : null}
threadActions={
isCurrentUser && tab === 'commits'
diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts
index 73c8df690e..6ebc4cc418 100644
--- a/resources/js/types/commit.ts
+++ b/resources/js/types/commit.ts
@@ -1,4 +1,5 @@
import type { User } from '@/types/auth';
+import type { Event } from '@/types/event';
import type { Organization } from '@/types/organization';
export type CommitStatus = 'pending' | 'approved' | 'rejected';
@@ -114,6 +115,7 @@ export type Commit = {
| null;
link_preview?: LinkPreview | null;
poll?: Poll | null;
+ event?: Event | null;
quoted_commit_id: string | null;
quoted_commit?: QuotedCommit | DeletedCommit | null;
images?: CommitImage[];
diff --git a/resources/js/types/event.ts b/resources/js/types/event.ts
new file mode 100644
index 0000000000..32e9fb111a
--- /dev/null
+++ b/resources/js/types/event.ts
@@ -0,0 +1,30 @@
+import type { User } from '@/types/auth';
+
+export type EventState = 'upcoming' | 'live' | 'ended' | 'cancelled';
+
+export type EventResponse = 'going' | 'interested';
+
+export type EventAgendaItem = {
+ id: number;
+ label: string;
+ starts_at: string;
+};
+
+export type Event = {
+ id: string;
+ commit_id: string;
+ title: string;
+ starts_at: string;
+ ends_at: string;
+ location: string | null;
+ url: string | null;
+ cancelled_at: string | null;
+ state: EventState;
+ going_count?: number;
+ agenda_items?: EventAgendaItem[];
+};
+
+export type EventWithHost = Event & {
+ commit: { id: string; user: User };
+ going_count: number;
+};
diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts
index f7facc21a5..27527f36b6 100644
--- a/resources/js/types/index.ts
+++ b/resources/js/types/index.ts
@@ -4,6 +4,7 @@ export type * from './auth';
export type * from './commit';
export type * from './conversation';
export type * from './device';
+export type * from './event';
export type * from './log';
export type * from './navigation';
export type * from './notification';
diff --git a/resources/js/types/notification.ts b/resources/js/types/notification.ts
index 246f073363..2e51620e08 100644
--- a/resources/js/types/notification.ts
+++ b/resources/js/types/notification.ts
@@ -1,5 +1,6 @@
import type { User } from '@/types/auth';
import type { Commit, DeletedCommit, OrganizationAuthor } from '@/types/commit';
+import type { Event } from '@/types/event';
import type { Organization } from '@/types/organization';
// Whoever did it: a user, who says whether you follow them, or an organization replying, quoting or mentioning.
@@ -50,6 +51,12 @@ export type Notification =
rank: string;
rank_description: string;
})
+ | (BaseNotification & {
+ type: 'event_cancelled';
+ commit_id: string;
+ subject_id: null;
+ commit: Omit & { event: Event | null };
+ })
| (BaseNotification & {
type: 'quoted';
commit_id: string;
diff --git a/routes/web.php b/routes/web.php
index 42cd4ddaec..aa4c05c6e4 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -51,6 +51,9 @@
use App\Http\Controllers\EmailController;
use App\Http\Controllers\EmailDraftController;
use App\Http\Controllers\EmailPreviewController;
+use App\Http\Controllers\EventAttendanceController;
+use App\Http\Controllers\EventCancellationController;
+use App\Http\Controllers\EventController;
use App\Http\Controllers\ExportController;
use App\Http\Controllers\FailedJobController;
use App\Http\Controllers\FailedJobRetryController;
@@ -262,6 +265,8 @@
->scopeBindings()
->whereNumber('photo')
->name('commit-photo.show');
+ Route::get('events', [EventController::class, 'index'])->name('event.index');
+ Route::get('events/{event}', [EventController::class, 'show'])->whereUuid('event')->name('event.show');
});
// Sitemaps, the same for everyone, so they skip the web group's session and cookies, which would keep a cache from sharing them...
@@ -463,6 +468,23 @@
Route::post('commits/{commit}/poll/votes', [CommitPollVoteController::class, 'store'])
->name('commit-poll-vote.store');
+ // Events...
+ Route::get('events/create', [EventController::class, 'create'])->name('event.create');
+ Route::post('events', [EventController::class, 'store'])
+ ->middleware([PreventPostingInTimeout::class, RequireCommitRules::class, ThrottleCommits::class])
+ ->name('event.store');
+ Route::put('events/{event}/attendance', [EventAttendanceController::class, 'update'])
+ ->whereUuid('event')
+ ->middleware('throttle:60,1')
+ ->name('event-attendance.update');
+ Route::delete('events/{event}/attendance', [EventAttendanceController::class, 'destroy'])
+ ->whereUuid('event')
+ ->middleware('throttle:60,1')
+ ->name('event-attendance.destroy');
+ Route::post('events/{event}/cancellation', [EventCancellationController::class, 'store'])
+ ->whereUuid('event')
+ ->name('event-cancellation.store');
+
// Notifications...
Route::get('notifications', [NotificationController::class, 'index'])->name('notification.index');
Route::put('notifications/read-at', [NotificationReadAtController::class, 'update'])->name('notification-read-at.update');
diff --git a/tests/Feature/Controllers/EventAttendanceControllerTest.php b/tests/Feature/Controllers/EventAttendanceControllerTest.php
new file mode 100644
index 0000000000..1810c4c47e
--- /dev/null
+++ b/tests/Feature/Controllers/EventAttendanceControllerTest.php
@@ -0,0 +1,85 @@
+create();
+ $event = Event::factory()->create();
+
+ $this->actingAs($user)
+ ->from(route('event.show', $event))
+ ->put(route('event-attendance.update', $event), ['response' => 'going'])
+ ->assertRedirect(route('event.show', $event));
+
+ expect($user->eventAttendances()->sole()->response)->toBe(EventResponse::Going);
+
+ $this->actingAs($user)->put(route('event-attendance.update', $event), ['response' => 'interested']);
+
+ expect($user->eventAttendances()->sole()->response)->toBe(EventResponse::Interested);
+});
+
+it('takes responses while an event is happening', function (): void {
+ $event = Event::factory()->live()->create();
+
+ $this->actingAs(User::factory()->create())
+ ->put(route('event-attendance.update', $event), ['response' => 'going'])
+ ->assertSessionHasNoErrors();
+
+ expect(EventAttendee::query()->count())->toBe(1);
+});
+
+it('explains why a response is refused', function (Closure $scenario, string $message): void {
+ [$user, $event] = $scenario();
+
+ $this->actingAs($user)
+ ->put(route('event-attendance.update', $event), ['response' => 'going'])
+ ->assertSessionHasErrors(['response' => $message]);
+
+ expect(EventAttendee::query()->count())->toBe(0);
+})->with([
+ 'host' => [function (): array {
+ $event = Event::factory()->create();
+
+ return [$event->commit->user, $event];
+ }, 'You host this event.'],
+ 'ended' => [fn (): array => [User::factory()->create(), Event::factory()->ended()->create()], 'This event no longer takes responses.'],
+ 'cancelled' => [fn (): array => [User::factory()->create(), Event::factory()->cancelled()->create()], 'This event no longer takes responses.'],
+]);
+
+it('requires a known response', function (): void {
+ $this->actingAs(User::factory()->create())
+ ->put(route('event-attendance.update', Event::factory()->create()), ['response' => 'maybe'])
+ ->assertSessionHasErrors('response');
+});
+
+it('hides events that cannot be seen', function (): void {
+ $event = Event::factory()->for(Commit::factory()->pending())->create();
+ $user = User::factory()->create();
+
+ $this->actingAs($user)->put(route('event-attendance.update', $event), ['response' => 'going'])->assertNotFound();
+ $this->actingAs($user)->delete(route('event-attendance.destroy', $event))->assertNotFound();
+});
+
+it('takes a response back, even after the event', function (): void {
+ $user = User::factory()->create();
+ $event = Event::factory()->ended()->create();
+ EventAttendee::factory()->for($event)->for($user)->create();
+ $other = EventAttendee::factory()->for($event)->create();
+
+ $this->actingAs($user)
+ ->delete(route('event-attendance.destroy', $event))
+ ->assertRedirect();
+
+ expect(EventAttendee::query()->sole()->is($other))->toBeTrue();
+});
+
+it('sends guests to sign in before responding', function (): void {
+ $this->put(route('event-attendance.update', Event::factory()->create()), ['response' => 'going'])
+ ->assertRedirect(route('home'));
+});
diff --git a/tests/Feature/Controllers/EventCancellationControllerTest.php b/tests/Feature/Controllers/EventCancellationControllerTest.php
new file mode 100644
index 0000000000..98dca57ba0
--- /dev/null
+++ b/tests/Feature/Controllers/EventCancellationControllerTest.php
@@ -0,0 +1,69 @@
+actingAs($event->commit->user)
+ ->from(route('event.show', $event))
+ ->post(route('event-cancellation.store', $event))
+ ->assertRedirect(route('event.show', $event));
+
+ expect($event->refresh()->state())->toBe(EventState::Cancelled);
+})->with([
+ 'upcoming' => fn (): Event => Event::factory()->create(),
+ 'live' => fn (): Event => Event::factory()->live()->create(),
+]);
+
+it('lets nobody else cancel an event', function (): void {
+ $event = Event::factory()->create();
+
+ $this->actingAs(User::factory()->create())
+ ->post(route('event-cancellation.store', $event))
+ ->assertNotFound();
+
+ expect($event->refresh()->cancelled_at)->toBeNull();
+});
+
+it('keeps an event that is over as it was', function (string $state): void {
+ $event = Event::factory()->{$state}()->create();
+ $cancelledAt = $event->cancelled_at;
+
+ $this->actingAs($event->commit->user)
+ ->post(route('event-cancellation.store', $event))
+ ->assertForbidden();
+
+ expect($event->refresh()->cancelled_at?->toIso8601String())->toBe($cancelledAt?->toIso8601String());
+})->with(['ended', 'cancelled']);
+
+it('tells the people going, and only them, that the event is cancelled', function (): void {
+ $event = Event::factory()->create();
+ $going = EventAttendee::factory()->for($event)->create();
+ EventAttendee::factory()->for($event)->interested()->create();
+
+ $this->actingAs($event->commit->user)->post(route('event-cancellation.store', $event));
+
+ expect(Notification::query()->sole())
+ ->recipient_id->toBe($going->user_id)
+ ->actor_id->toBe($event->commit->user_id)
+ ->type->toBe(NotificationType::EventCancelled)
+ ->commit_id->toBe($event->commit_id)
+ ->key->toBe('event_cancelled:'.$event->id);
+});
+
+it('tells no one when an event is over', function (): void {
+ $event = Event::factory()->ended()->create();
+ EventAttendee::factory()->for($event)->create();
+
+ $this->actingAs($event->commit->user)->post(route('event-cancellation.store', $event));
+
+ expect(Notification::query()->count())->toBe(0);
+});
diff --git a/tests/Feature/Controllers/EventControllerTest.php b/tests/Feature/Controllers/EventControllerTest.php
new file mode 100644
index 0000000000..67b2511b43
--- /dev/null
+++ b/tests/Feature/Controllers/EventControllerTest.php
@@ -0,0 +1,246 @@
+
+ */
+function newEvent(array $overrides = []): array
+{
+ $startsAt = CarbonImmutable::now()->addWeek()->startOfHour();
+
+ return [
+ 'title' => 'Laravel Lisbon #12',
+ 'body' => 'Talks, pastéis and a lot of PHP.',
+ 'starts_at' => $startsAt->toIso8601String(),
+ 'ends_at' => $startsAt->addHours(3)->toIso8601String(),
+ 'location' => 'Lisbon',
+ 'url' => null,
+ 'agenda' => [
+ ['label' => 'Doors open', 'starts_at' => $startsAt->toIso8601String()],
+ ['label' => 'Octane in production', 'starts_at' => $startsAt->addHour()->toIso8601String()],
+ ],
+ ...$overrides,
+ ];
+}
+
+it('lists upcoming events soonest first to guests', function (): void {
+ $later = Event::factory()->create(['starts_at' => now()->addWeeks(2), 'ends_at' => now()->addWeeks(2)->addHour()]);
+ $sooner = Event::factory()->create();
+ $live = Event::factory()->live()->create();
+ Event::factory()->ended()->create();
+ Event::factory()->cancelled()->create();
+ Event::factory()->for(Commit::factory()->pending())->create();
+ EventAttendee::factory()->for($sooner)->count(2)->create();
+ EventAttendee::factory()->for($sooner)->interested()->create();
+
+ $this->get(route('event.index'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page): Assert => $page
+ ->component('event/index')
+ ->where('tab', 'upcoming')
+ ->has('events.data', 3)
+ ->where('events.data.0.id', $live->id)
+ ->where('events.data.0.state', 'live')
+ ->where('events.data.1.id', $sooner->id)
+ ->where('events.data.1.going_count', 2)
+ ->where('events.data.1.commit.user.username', $sooner->commit->user->username)
+ ->where('events.data.2.id', $later->id));
+});
+
+it('lists past and cancelled events latest first', function (): void {
+ $ended = Event::factory()->ended()->create();
+ $cancelled = Event::factory()->cancelled()->create();
+ Event::factory()->create();
+
+ $this->get(route('event.index', ['tab' => 'past']))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page): Assert => $page
+ ->where('tab', 'past')
+ ->has('events.data', 2)
+ ->where('events.data.0.id', $cancelled->id)
+ ->where('events.data.0.state', 'cancelled')
+ ->where('events.data.1.id', $ended->id));
+});
+
+it('leaves muted and blocked hosts out of the list', function (): void {
+ $user = User::factory()->create();
+ $muted = Event::factory()->create();
+ $blocked = Event::factory()->create();
+ $kept = Event::factory()->create();
+ $user->muting()->attach($muted->commit->user_id);
+ $user->blocking()->attach($blocked->commit->user_id);
+
+ $this->actingAs($user)
+ ->get(route('event.index'))
+ ->assertInertia(fn (Assert $page): Assert => $page
+ ->has('events.data', 1)
+ ->where('events.data.0.id', $kept->id));
+});
+
+it('shows an event like an issue, with its agenda, people going and discussion', function (): void {
+ $viewer = User::factory()->create();
+ $event = Event::factory()->create();
+ EventAgendaItem::factory()->for($event)->create(['label' => 'Second', 'starts_at' => $event->starts_at->addHour()]);
+ EventAgendaItem::factory()->for($event)->create(['label' => 'First', 'starts_at' => $event->starts_at]);
+ $first = EventAttendee::factory()->for($event)->create();
+ $latest = EventAttendee::factory()->for($event)->create();
+ $blocked = EventAttendee::factory()->for($event)->create();
+ EventAttendee::factory()->for($event)->interested()->create();
+ EventAttendee::factory()->for($event)->for($viewer)->interested()->create();
+ $viewer->blocking()->attach($blocked->user_id);
+ $reply = Commit::factory()->create(['parent_id' => $event->commit_id]);
+
+ $this->actingAs($viewer)
+ ->get(route('event.show', $event))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page): Assert => $page
+ ->component('event/show')
+ ->where('event.id', $event->id)
+ ->where('event.title', $event->title)
+ ->where('event.state', 'upcoming')
+ ->where('event.going_count', 3)
+ ->where('event.agenda_items.0.label', 'First')
+ ->where('event.agenda_items.1.label', 'Second')
+ ->missing('event.created_at')
+ ->where('host.id', $event->commit->user_id)
+ ->where('response', 'interested')
+ ->has('attendees', 2)
+ ->where('attendees.0.id', $latest->user_id)
+ ->where('attendees.1.id', $first->user_id)
+ ->where('commit.id', $event->commit_id)
+ ->where('commit.event.id', $event->id)
+ ->where('replies.data.0.id', $reply->id));
+});
+
+it('shows events to guests', function (): void {
+ $event = Event::factory()->create();
+
+ $this->get(route('event.show', $event))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page): Assert => $page
+ ->where('response', null)
+ ->has('attendees', 0));
+});
+
+it('shows a pending event only to its host', function (): void {
+ $event = Event::factory()->for(Commit::factory()->pending())->create();
+
+ $this->get(route('event.show', $event))->assertNotFound();
+ $this->actingAs(User::factory()->create())->get(route('event.show', $event))->assertNotFound();
+ $this->actingAs($event->commit->user)->get(route('event.show', $event))->assertOk();
+});
+
+it('hides events across a block and with their deleted commit', function (): void {
+ $user = User::factory()->create();
+ $blocked = Event::factory()->create();
+ $user->blockers()->attach($blocked->commit->user_id);
+ $deleted = Event::factory()->create();
+ $deleted->commit->delete();
+
+ $this->actingAs($user)->get(route('event.show', $blocked))->assertNotFound();
+ $this->actingAs($user)->get(route('event.show', $deleted))->assertNotFound();
+});
+
+it('shows the form for a new event', function (): void {
+ $this->actingAs(User::factory()->create())
+ ->get(route('event.create'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page): Assert => $page
+ ->component('event/create')
+ ->where('limits.title', Event::MAX_TITLE_LENGTH)
+ ->where('limits.agendaItems', Event::MAX_AGENDA_ITEMS));
+});
+
+it('sends guests to sign in before creating an event', function (): void {
+ $this->get(route('event.create'))->assertRedirect(route('home'));
+ $this->post(route('event.store'), newEvent())->assertRedirect(route('home'));
+});
+
+it('creates an event announced by a commit pending review', function (): void {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('event.store'), newEvent(['title' => 'PHP night :rocket:']));
+
+ $event = Event::query()->sole();
+
+ $response->assertRedirect(route('event.show', $event))
+ ->assertInertiaFlash('toast.message', 'Your event is pending review. Everyone will see it once it is approved.');
+
+ expect($event)
+ ->title->toBe('PHP night 🚀')
+ ->location->toBe('Lisbon')
+ ->url->toBeNull()
+ ->and($event->commit)
+ ->user_id->toBe($user->id)
+ ->body->toBe('Talks, pastéis and a lot of PHP.')
+ ->status->toBe(CommitStatus::Pending)
+ ->and($event->agendaItems->pluck('label')->all())->toBe(['Doors open', 'Octane in production']);
+});
+
+it('creates an approved event without a toast for trusted authors', function (): void {
+ $user = User::factory()->create(['created_at' => now()->subDay()]);
+ Commit::factory()->for($user)->create();
+ $user->followers()->attach(User::factory()->count(10)->create());
+
+ $this->actingAs($user)
+ ->post(route('event.store'), newEvent(['location' => null, 'url' => 'https://youtube.com/live/abc', 'agenda' => []]))
+ ->assertInertiaFlashMissing('toast');
+
+ expect(Event::query()->sole())
+ ->url->toBe('https://youtube.com/live/abc')
+ ->location->toBeNull()
+ ->commit->status->toBe(CommitStatus::Approved);
+});
+
+it('validates new events', function (array $overrides, string $field, ?string $message = null): void {
+ $response = $this->actingAs(User::factory()->create())
+ ->post(route('event.store'), newEvent($overrides));
+
+ $message === null
+ ? $response->assertSessionHasErrors($field)
+ : $response->assertSessionHasErrors([$field => $message]);
+
+ expect(Event::query()->count())->toBe(0)
+ ->and(Commit::query()->count())->toBe(0);
+})->with([
+ 'no title' => [['title' => ''], 'title'],
+ 'long title' => [['title' => str_repeat('a', Event::MAX_TITLE_LENGTH + 1)], 'title'],
+ 'no description' => [['body' => ''], 'body'],
+ 'start in the past' => [['starts_at' => now()->subHour()->toIso8601String()], 'starts_at'],
+ 'start too far ahead' => [['starts_at' => now()->addYears(2)->toIso8601String(), 'ends_at' => now()->addYears(2)->addHour()->toIso8601String(), 'agenda' => []], 'starts_at'],
+ 'end before start' => [['ends_at' => now()->addDay()->toIso8601String()], 'ends_at'],
+ 'too long' => [['ends_at' => now()->addWeeks(3)->toIso8601String()], 'ends_at', 'An event lasts at most 7 days.'],
+ 'nowhere' => [['location' => null, 'url' => null], 'location', 'Add a place, a link, or both.'],
+ 'not a web link' => [['url' => 'ftp://example.com'], 'url'],
+ 'agenda outside the event' => [['agenda' => [['label' => 'Afterparty', 'starts_at' => now()->addMonth()->toIso8601String()]]], 'agenda.0.starts_at', 'Agenda items happen during the event.'],
+ 'agenda before the event' => [['agenda' => [['label' => 'Warmup', 'starts_at' => now()->addDay()->toIso8601String()]]], 'agenda.0.starts_at', 'Agenda items happen during the event.'],
+ 'agenda without a label' => [['agenda' => [['label' => '', 'starts_at' => now()->addWeek()->startOfHour()->toIso8601String()]]], 'agenda.0.label'],
+ 'too many agenda items' => [['agenda' => array_fill(0, Event::MAX_AGENDA_ITEMS + 1, ['label' => 'Talk', 'starts_at' => now()->addWeek()->startOfHour()->toIso8601String()])], 'agenda'],
+]);
+
+it('reports an invalid start on its own field only', function (): void {
+ $this->actingAs(User::factory()->create())
+ ->post(route('event.store'), newEvent(['starts_at' => 'soon', 'agenda' => []]))
+ ->assertSessionHasErrors('starts_at')
+ ->assertSessionDoesntHaveErrors('ends_at');
+});
+
+it('keeps users in a timeout from creating events', function (): void {
+ $user = User::factory()->create();
+ UserTimeout::factory()->for($user)->create();
+
+ $this->actingAs($user)->post(route('event.store'), newEvent());
+
+ expect(Event::query()->count())->toBe(0);
+});
diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php
index b3762ab68d..c29b2c762d 100644
--- a/tests/Feature/Controllers/ExportControllerTest.php
+++ b/tests/Feature/Controllers/ExportControllerTest.php
@@ -14,6 +14,9 @@
use App\Models\CommitImage;
use App\Models\CommitReport;
use App\Models\Device;
+use App\Models\Event;
+use App\Models\EventAgendaItem;
+use App\Models\EventAttendee;
use App\Models\Message;
use App\Models\MessageVoice;
use App\Models\Notification;
@@ -131,6 +134,8 @@
'organization_commits' => [],
'polls' => [],
'poll_votes' => [],
+ 'events' => [],
+ 'event_responses' => [],
'stars' => [],
'following' => [],
'followers' => [],
@@ -676,3 +681,35 @@
$export = resolve(CreateExport::class)->handle($user);
expect($export['push_subscriptions'])->toBe([['id' => $subscription->id]]);
});
+
+it('exports hosted events and the users own responses without other attendees', function (): void {
+ $user = User::factory()->create();
+ $event = Event::factory()->for(Commit::factory()->for($user))->create(['title' => 'PHP night']);
+ $item = EventAgendaItem::factory()->for($event)->create(['label' => 'Talks', 'starts_at' => $event->starts_at]);
+ Event::factory()->for(Commit::factory()->for($user)->state(['deleted_at' => now()]))->create(['title' => 'Never exported']);
+ $other = Event::factory()->create();
+ $response = EventAttendee::factory()->for($other)->for($user)->interested()->create();
+ $attendee = EventAttendee::factory()->for($event)->create();
+
+ $export = $this->actingAs($user)->get(route('export'));
+ $data = json_decode($export->streamedContent(), true, flags: JSON_THROW_ON_ERROR);
+
+ expect($data['events'])->toBe([[
+ 'commit_id' => $event->commit_id,
+ 'title' => 'PHP night',
+ 'starts_at' => $event->starts_at->toIso8601String(),
+ 'ends_at' => $event->ends_at->toIso8601String(),
+ 'location' => $event->location,
+ 'url' => null,
+ 'agenda' => [['label' => 'Talks', 'starts_at' => $item->starts_at->toIso8601String()]],
+ 'cancelled_at' => null,
+ 'created_at' => $event->created_at->toIso8601String(),
+ ]])
+ ->and($data['event_responses'])->toBe([[
+ 'commit_id' => $other->commit_id,
+ 'response' => 'interested',
+ 'created_at' => $response->created_at->toIso8601String(),
+ 'updated_at' => $response->updated_at->toIso8601String(),
+ ]])
+ ->and($export->streamedContent())->not->toContain($attendee->user_id, 'Never exported');
+});
diff --git a/tests/Feature/Controllers/NotificationControllerTest.php b/tests/Feature/Controllers/NotificationControllerTest.php
index a3a875e6c1..c7e2dac3a7 100644
--- a/tests/Feature/Controllers/NotificationControllerTest.php
+++ b/tests/Feature/Controllers/NotificationControllerTest.php
@@ -3,9 +3,12 @@
declare(strict_types=1);
use App\Actions\CreateCommitStar;
+use App\Actions\CreateEventCancellation;
use App\Actions\DeleteUserFollow;
use App\Models\Commit;
use App\Models\CommitImage;
+use App\Models\Event;
+use App\Models\EventAttendee;
use App\Models\Notification;
use App\Models\User;
@@ -214,3 +217,27 @@
$response->assertRedirectToRoute('home');
});
+
+it('names the cancelled event, until its announcement is deleted', function (): void {
+ $user = User::factory()->create();
+ $event = Event::factory()->create(['title' => 'PHP night']);
+ EventAttendee::factory()->for($event)->for($user)->create();
+ resolve(CreateEventCancellation::class)->handle($event);
+
+ $this->actingAs($user)->get(route('notification.index'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('notifications.data.0.type', 'event_cancelled')
+ ->where('notifications.data.0.actor.id', $event->commit->user_id)
+ ->where('notifications.data.0.commit.event.id', $event->id)
+ ->where('notifications.data.0.commit.event.title', 'PHP night')
+ ->where('notifications.data.0.commit.event.state', 'cancelled'));
+
+ $this->get(route('notification.index', ['type' => 'replies']))
+ ->assertInertia(fn ($page) => $page->where('notifications.data', []));
+
+ $event->commit->delete();
+
+ $this->get(route('notification.index'))
+ ->assertInertia(fn ($page) => $page->where('notifications.data', []));
+});
diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php
index 52c8c4ba28..3e9343a61d 100644
--- a/tests/Feature/Controllers/UserControllerTest.php
+++ b/tests/Feature/Controllers/UserControllerTest.php
@@ -6,6 +6,7 @@
use App\Enums\Log;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\Commit;
+use App\Models\Event;
use App\Models\LinkPreview;
use App\Models\Organization;
use App\Models\User;
@@ -757,7 +758,7 @@
$this->get(route('user.show', ['user' => $user, 'tab' => $tab]))->assertOk()->assertInertia(fn ($page) => $page
->where('readme', null));
-})->with(['commits', 'replies']);
+})->with(['commits', 'replies', 'events']);
it('names the followers the viewer knows, the most followed first', function (): void {
$viewer = User::factory()->create();
@@ -829,3 +830,20 @@
->assertMovedPermanently()
->assertRedirect("/@OctoCat/{$list}");
})->with(['following', 'followers', 'mutuals']);
+
+it('lists the events a user hosts on the events tab, latest first', function (): void {
+ $user = User::factory()->create();
+ $earlier = Event::factory()->for(Commit::factory()->for($user))->ended()->create();
+ $later = Event::factory()->for(Commit::factory()->for($user))->create();
+ Event::factory()->for(Commit::factory()->for($user)->pending())->create();
+ Event::factory()->create();
+
+ $this->get(route('user.show', ['user' => $user, 'tab' => 'events']))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('tab', 'events')
+ ->where('pinnedCommit', null)
+ ->has('commits.data', 2)
+ ->where('commits.data.0.id', $later->id)
+ ->where('commits.data.1.id', $earlier->id));
+});
diff --git a/tests/Unit/Actions/DeleteCommitContentTest.php b/tests/Unit/Actions/DeleteCommitContentTest.php
index 422536c68b..7a26f7cfe1 100644
--- a/tests/Unit/Actions/DeleteCommitContentTest.php
+++ b/tests/Unit/Actions/DeleteCommitContentTest.php
@@ -8,6 +8,7 @@
use App\Actions\DeleteCommitContent;
use App\Models\Commit;
use App\Models\CommitImage;
+use App\Models\Event;
use App\Models\LinkPreview;
use App\Models\Organization;
use App\Models\Poll;
@@ -80,6 +81,7 @@ function deletedCommitWithImage(): CommitImage
resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => Organization::factory()->create()->id]);
$headline = ($isReply ? $root : $commit)->headlines()->create(['name' => 'Laravel adds a new feature']);
$poll = Poll::factory()->withOptions('Yes', 'No')->create(['commit_id' => $commit->id]);
+ $event = Event::factory()->for($commit)->create();
resolve(DeleteCommit::class)->handle($commit);
$headline->update(['summary' => 'A recap awaiting final erasure.', 'summary_created_at' => now()]);
$this->travel(30)->seconds();
@@ -98,6 +100,7 @@ function deletedCommitWithImage(): CommitImage
$this->assertDatabaseMissing('commit_topics', ['commit_id' => $commit->id]);
$this->assertDatabaseMissing('commit_headline', ['commit_id' => $commit->id]);
expect($headline->refresh()->summary)->toBe($isReply ? 'A recap awaiting final erasure.' : null);
+ $this->assertModelMissing($event);
})->with(['root' => false, 'reply' => true]);
it('erases the previews of links to the commit, which hold its body', function (): void {
diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php
index cc7cf39fb9..47e51bbfdb 100644
--- a/tests/Unit/Actions/DeleteUserTest.php
+++ b/tests/Unit/Actions/DeleteUserTest.php
@@ -16,6 +16,8 @@
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Device;
+use App\Models\Event;
+use App\Models\EventAttendee;
use App\Models\LinkPreview;
use App\Models\Message;
use App\Models\MessageImage;
@@ -461,3 +463,18 @@
resolve(DeleteUser::class)->handle($user);
expect(PushSubscription::query()->count())->toBe(0);
});
+
+it('erases hosted events and the users responses while keeping other events and attendees', function (): void {
+ $user = User::factory()->create();
+ $hosted = Event::factory()->for(Commit::factory()->for($user))->create();
+ EventAttendee::factory()->for($hosted)->create();
+ $other = Event::factory()->create();
+ EventAttendee::factory()->for($other)->for($user)->create();
+ $kept = EventAttendee::factory()->for($other)->create();
+
+ resolve(DeleteUser::class)->handle($user);
+
+ $this->assertModelMissing($hosted);
+ expect(Event::query()->sole()->is($other))->toBeTrue()
+ ->and(EventAttendee::query()->sole()->is($kept))->toBeTrue();
+});
diff --git a/tests/Unit/Enums/NotificationTypeTest.php b/tests/Unit/Enums/NotificationTypeTest.php
index 864490e378..29427e744b 100644
--- a/tests/Unit/Enums/NotificationTypeTest.php
+++ b/tests/Unit/Enums/NotificationTypeTest.php
@@ -15,5 +15,6 @@
'Starred' => 'starred',
'LeveledUp' => 'leveled_up',
'FirstCommit' => 'first_commit',
+ 'EventCancelled' => 'event_cancelled',
]);
});
diff --git a/tests/Unit/EventProgress.test.ts b/tests/Unit/EventProgress.test.ts
new file mode 100644
index 0000000000..638a3aaf6d
--- /dev/null
+++ b/tests/Unit/EventProgress.test.ts
@@ -0,0 +1,82 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import {
+ agendaProgress,
+ eventFormat,
+ eventStateAt,
+} from '../../resources/js/lib/event.ts';
+import type { Event } from '../../resources/js/types/event.ts';
+
+const event: Event = {
+ id: 'event',
+ commit_id: 'commit',
+ title: 'PHP night',
+ starts_at: '2026-11-04T18:00:00Z',
+ ends_at: '2026-11-04T21:00:00Z',
+ location: 'Lisbon',
+ url: null,
+ cancelled_at: null,
+ state: 'upcoming',
+};
+
+const items = [
+ { id: 1, label: 'Doors', starts_at: '2026-11-04T18:00:00Z' },
+ { id: 2, label: 'Octane', starts_at: '2026-11-04T19:00:00Z' },
+ { id: 3, label: 'Pint', starts_at: '2026-11-04T20:00:00Z' },
+];
+
+void test('the state follows the clock, not the state the page was served with', () => {
+ assert.equal(
+ eventStateAt(event, new Date('2026-11-04T17:59:59Z')),
+ 'upcoming',
+ );
+ assert.equal(eventStateAt(event, new Date('2026-11-04T18:00:00Z')), 'live');
+ assert.equal(
+ eventStateAt(event, new Date('2026-11-04T21:00:00Z')),
+ 'ended',
+ );
+ assert.equal(
+ eventStateAt(
+ { ...event, cancelled_at: '2026-11-01T00:00:00Z' },
+ new Date('2026-11-04T19:00:00Z'),
+ ),
+ 'cancelled',
+ );
+});
+
+void test('nothing on the agenda is done before the event', () => {
+ assert.deepEqual(
+ agendaProgress(items, event, new Date('2026-11-04T17:00:00Z')),
+ { done: 0, current: null },
+ );
+});
+
+void test('an agenda item is done once the next one starts', () => {
+ assert.deepEqual(
+ agendaProgress(items, event, new Date('2026-11-04T19:30:00Z')),
+ { done: 1, current: 2 },
+ );
+});
+
+void test('the last agenda item is done once the event ends', () => {
+ assert.deepEqual(
+ agendaProgress(items, event, new Date('2026-11-04T20:59:00Z')),
+ { done: 2, current: 3 },
+ );
+ assert.deepEqual(
+ agendaProgress(items, event, new Date('2026-11-04T21:00:00Z')),
+ { done: 3, current: null },
+ );
+});
+
+void test('a place, a link or both make an in-person, online or hybrid event', () => {
+ assert.equal(eventFormat({ location: 'Lisbon', url: null }), 'In person');
+ assert.equal(
+ eventFormat({ location: null, url: 'https://x.dev' }),
+ 'Online',
+ );
+ assert.equal(
+ eventFormat({ location: 'Lisbon', url: 'https://x.dev' }),
+ 'Hybrid',
+ );
+});
diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php
index 7c31be9b84..83ffc15769 100644
--- a/tests/Unit/Jobs/ModerateCommitTest.php
+++ b/tests/Unit/Jobs/ModerateCommitTest.php
@@ -10,6 +10,8 @@
use App\Models\Commit;
use App\Models\CommitImage;
use App\Models\CommitReport;
+use App\Models\Event;
+use App\Models\EventAgendaItem;
use App\Models\Notification;
use App\Models\Poll;
use App\Models\User;
@@ -363,3 +365,16 @@
->status->toBe(CommitStatus::Approved)
->ai_decision->toBeNull();
});
+
+it('moderates the event a commit announces along with its body', function (): void {
+ $text = "Come along\n\nPHP night\n\nLisbon\n\nhttps://example.com\n\nLightning talks";
+ $moderation = $this->mock(ModerationProvider::class);
+ $moderation->shouldReceive('moderate')->once()->with($text, [])->andReturn([]);
+ $moderation->shouldReceive('isHarmful')->once()->with($text)->andReturnFalse();
+
+ $commit = Commit::factory()->pending()->create(['body' => 'Come along']);
+ $event = Event::factory()->for($commit)->create(['title' => 'PHP night', 'location' => 'Lisbon', 'url' => 'https://example.com']);
+ EventAgendaItem::factory()->for($event)->create(['label' => 'Lightning talks', 'starts_at' => $event->starts_at]);
+
+ dispatch_sync(new ModerateCommit($commit));
+});
diff --git a/tests/Unit/NotificationIndex.test.tsx b/tests/Unit/NotificationIndex.test.tsx
index ad6f1db8d9..072af07fa0 100644
--- a/tests/Unit/NotificationIndex.test.tsx
+++ b/tests/Unit/NotificationIndex.test.tsx
@@ -441,6 +441,35 @@ test('keeps an expanded star as one link to the profile', async () => {
assert.match(body, /]*href="\/@mnapoli"/);
});
+test('names the cancelled event, linking to its page', async () => {
+ const cancelled = {
+ ...notification,
+ id: 'cancelled',
+ group_id: 'cancelled',
+ type: 'event_cancelled',
+ subject_id: null,
+ commit: {
+ ...notification.commit,
+ event: {
+ id: 'event',
+ commit_id: 'reply',
+ title: 'PHP night',
+ starts_at: '2026-11-04T18:00:00Z',
+ ends_at: '2026-11-04T21:00:00Z',
+ location: 'Lisbon',
+ url: null,
+ cancelled_at: '2026-10-20T10:00:00Z',
+ state: 'cancelled',
+ },
+ },
+ } as NotificationGroup;
+ const body = await render([cancelled]);
+
+ assert.match(body, /cancelled an event you were going to<\/a>/);
+ assert.match(body, /data-test="cancelled-event"[^>]*>.*PHP night/s);
+ assert.match(body, / ]*href="\/events\/event"/);
+});
+
test('says which level and rank a level-up reached, linking to the profile', async () => {
const leveledUp = {
...notification,
From ccce55839c2fc417eb7d6ccc9c2db753e2e56a91 Mon Sep 17 00:00:00 2001
From: Houssem Eddine BENABDALLAH
Date: Fri, 23 Oct 2026 17:15:27 +0200
Subject: [PATCH 2/6] fix: show moderators the whole event, keep times in one
zone and count who is going consistently
---
KNOWLEDGE_BASE.md | 2 +-
app/Http/Controllers/EventController.php | 2 +
app/Http/Controllers/ModerationController.php | 4 +-
app/Models/Event.php | 14 +++-
app/Queries/CommitBuilder.php | 2 +-
app/Queries/EventsQuery.php | 2 +-
resources/js/components/commit-event.tsx | 31 ++++----
resources/js/components/event-list.tsx | 13 +++-
.../js/components/event-response-button.tsx | 58 +++++++-------
resources/js/components/quoted-commit.tsx | 20 ++++-
resources/js/lib/event.ts | 77 ++++++++++++++++++-
resources/js/pages/event/create.tsx | 30 +++++---
resources/js/pages/event/show.tsx | 42 ++++++----
resources/js/types/commit.ts | 1 +
resources/js/types/event.ts | 2 +-
.../Controllers/EventControllerTest.php | 4 +-
.../Controllers/ModerationControllerTest.php | 14 ++++
tests/Unit/EventProgress.test.ts | 46 +++++++++++
18 files changed, 283 insertions(+), 81 deletions(-)
diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index 1232b94ea2..bb88efd7d3 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -344,7 +344,7 @@ Host a meetup, a stream, a workshop or a hackathon from **Events** in the naviga
The description is posted as an ordinary commit announcing the event, so the event goes through the same moderation as any commit, title, place, link and agenda included, and shows up in logs as a small card on that commit. Until it is approved, only its host sees it. Replies to the announcement are the event's discussion, and deleting the announcement deletes the event. Event details cannot be edited, like commits: the host can only **cancel** an event before it ends, which keeps its page and discussion and marks it **Cancelled**.
-Event pages are laid out like a GitHub issue: a state badge (**Upcoming**, **Happening now**, **Ended**, or **Cancelled**), the details, the people going, and the agenda as a milestone whose progress bar ticks off each talk once the next one starts. Signed-in readers answer with a button shaped like GitHub's Watch button: **Going** puts their avatar among the people going, **Interested** is only visible to them, and **Not going** removes their answer. Responses close once an event ends or is cancelled, and the host is always going. People across a block are left out of the avatars. The **Events** page lists upcoming events soonest first and past or cancelled ones latest first, without muted hosts; each profile has an **Events** tab with the events that person hosts. Hosted events and your own responses are in your data export and are deleted with your account.
+Event pages are laid out like a GitHub issue: a state badge (**Upcoming**, **Happening now**, **Ended**, or **Cancelled**), the details, the people going, and the agenda as a milestone whose progress bar ticks off each talk once the next one starts. Signed-in readers answer with a button shaped like GitHub's Watch button: **Going** puts their avatar among the people going, **Interested** is only visible to them, and **Not going** removes their answer. Responses close once an event ends or is cancelled, and the host, shown as hosting, isn't counted among the people going. People across a block are left out of the avatars. The **Events** page lists upcoming events soonest first and past or cancelled ones latest first, without muted hosts; each profile has an **Events** tab with the events that person hosts. Hosted events and your own responses are in your data export and are deleted with your account.
Sources: [event controller](app/Http/Controllers/EventController.php), [event validation](app/Http/Requests/CreateEventRequest.php), [response rules](app/Rules/OpenEvent.php), [event page](resources/js/pages/event/show.tsx).
diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php
index e7d0dd68f8..fb4998236d 100644
--- a/app/Http/Controllers/EventController.php
+++ b/app/Http/Controllers/EventController.php
@@ -43,6 +43,7 @@ public function create(): Response
return Inertia::render('event/create', [
'limits' => [
'title' => Event::MAX_TITLE_LENGTH,
+ 'body' => Commit::MAX_BODY_LENGTH,
'location' => Event::MAX_LOCATION_LENGTH,
'url' => Event::MAX_URL_LENGTH,
'agendaItems' => Event::MAX_AGENDA_ITEMS,
@@ -79,6 +80,7 @@ public function show(ShowEventRequest $request, #[CurrentUser] ?User $currentUse
'host' => fn (): ?User => $commit->user,
'response' => fn (): ?string => $event->responseOf($currentUser)?->value,
'attendees' => fn (): Collection => $attendees->get($event, $currentUser),
+ 'attendeesLimit' => EventAttendeesQuery::LIMIT,
'commit' => fn (): Commit => $page->get($commit, $currentUser),
'replies' => fn (): CursorPaginator => $replies->paginate($commit, $currentUser, limit: $request->repliesPerPage()),
]);
diff --git a/app/Http/Controllers/ModerationController.php b/app/Http/Controllers/ModerationController.php
index d880268dc9..63a9775b4b 100644
--- a/app/Http/Controllers/ModerationController.php
+++ b/app/Http/Controllers/ModerationController.php
@@ -31,7 +31,9 @@ public function show(ShowModerationRequest $request, #[CurrentUser] User $user):
'parent.author',
'linkPreview',
'images',
- 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with(['author', 'linkPreview', 'images'])->withPollFor($user),
+ // An announcement's event is part of what gets approved: title, place, link and agenda.
+ 'event.agendaItems',
+ 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with(['author', 'linkPreview', 'images', 'event'])->withPollFor($user),
])
->withStarsFor($user)
->withPollFor($user)
diff --git a/app/Models/Event.php b/app/Models/Event.php
index 31dbb298f4..d1cdbb788a 100644
--- a/app/Models/Event.php
+++ b/app/Models/Event.php
@@ -15,6 +15,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOneThrough;
/**
* @property-read string $id
@@ -29,10 +30,11 @@
* @property-read CarbonInterface $updated_at
* @property-read int|null $going_count
* @property-read Commit|null $commit
+ * @property-read User|null $host
* @property-read Collection $agendaItems
* @property-read Collection $attendees
*/
-#[Visible(['id', 'commit_id', 'title', 'starts_at', 'ends_at', 'location', 'url', 'cancelled_at', 'going_count', 'agendaItems', 'commit'])]
+#[Visible(['id', 'commit_id', 'title', 'starts_at', 'ends_at', 'location', 'url', 'cancelled_at', 'going_count', 'agendaItems', 'commit', 'host'])]
final class Event extends Model
{
/** @use HasFactory */
@@ -91,6 +93,16 @@ public function commit(): BelongsTo
return $this->belongsTo(Commit::class);
}
+ /**
+ * Whoever posted the announcement, for lists that show only who hosts rather than the whole commit.
+ *
+ * @return HasOneThrough
+ */
+ public function host(): HasOneThrough
+ {
+ return $this->hasOneThrough(User::class, Commit::class, 'id', 'id', 'commit_id', 'user_id');
+ }
+
/**
* @return HasMany
*/
diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php
index 72743c45a8..76c9433166 100644
--- a/app/Queries/CommitBuilder.php
+++ b/app/Queries/CommitBuilder.php
@@ -206,7 +206,7 @@ public function withQuotedCommitFor(?User $viewer): self
'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query
->withTrashed()
->where(fn (self $query): self => $query->visibleTo($viewer))
- ->with(['author', 'linkPreview', 'images'])
+ ->with(['author', 'linkPreview', 'images', 'event'])
->withPollFor($viewer),
]);
}
diff --git a/app/Queries/EventsQuery.php b/app/Queries/EventsQuery.php
index 0454ef7336..eaa3557963 100644
--- a/app/Queries/EventsQuery.php
+++ b/app/Queries/EventsQuery.php
@@ -62,7 +62,7 @@ private function builder(?User $viewer): Builder
{
return Event::query()
->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer))
- ->with('commit.user')
+ ->with('host')
->withCount(['attendees as going_count' => fn (Builder $query): Builder => $query->where('response', EventResponse::Going)]);
}
}
diff --git a/resources/js/components/commit-event.tsx b/resources/js/components/commit-event.tsx
index b96a2f0255..7b6060e7e3 100644
--- a/resources/js/components/commit-event.tsx
+++ b/resources/js/components/commit-event.tsx
@@ -4,13 +4,13 @@ import EventController from '@/actions/App/Http/Controllers/EventController';
import { EventStateBadge } from '@/components/event-state-badge';
import { EventTile } from '@/components/event-tile';
import { useNow } from '@/hooks/use-now';
-import { eventFormat, eventStateAt } from '@/lib/event';
+import { eventFormat, eventPlace, eventStateAt } from '@/lib/event';
import { formatEventTime, formatEventWhen } from '@/lib/event-dates';
import type { Commit, Event } from '@/types';
/**
- * The event a commit announces, opening its page. Until the commit is approved, the agenda is spelled out too, so
- * whoever reviews it reads every word the event will show.
+ * The event a commit announces, opening its page. Until the commit is approved, its link and agenda are spelled out
+ * too, so whoever reviews it reads every word the event will show.
*/
export function CommitEvent({
commit,
@@ -23,15 +23,13 @@ export function CommitEvent({
const now = useNow();
const timeZone = auth.user?.timezone ?? undefined;
const state = eventStateAt(event, now);
- const isOnlyOnline =
- event.location === null ||
- event.location.trim().toLowerCase() === 'online';
- const where =
- event.url === null
- ? (event.location ?? '')
- : isOnlyOnline
- ? 'Online'
- : `${event.location} · Online`;
+ const format = eventFormat(event);
+ const where = {
+ 'In person': event.location ?? '',
+ Online: 'Online',
+ Hybrid: `${eventPlace(event)} · Online`,
+ }[format];
+ const isPending = commit.status !== 'approved';
return (
- {eventFormat(event) === 'Online' ? (
+ {format === 'Online' ? (
) : (
@@ -68,7 +66,12 @@ export function CommitEvent({
- {commit.status !== 'approved' &&
+ {isPending && event.url !== null && (
+
+ {event.url}
+
+ )}
+ {isPending &&
event.agenda_items !== undefined &&
event.agenda_items.length > 0 && (
- {event.location !== null &&
- `${event.location} · `}
- hosted by @{event.commit.user.username}
+ {eventPlace(event) !== null &&
+ `${eventPlace(event)} · `}
+ hosted by @{event.host.username}
{trigger}
- {choices.map((choice) => (
- choose(choice.value)}
- className="items-start gap-2 py-2"
- data-test={`event-response-${choice.value ?? 'none'}`}
- >
-
-
-
- {choice.label}
+ {/* Radio items, so the current choice is announced as checked rather than only drawn. */}
+
+ choose(
+ value === 'none'
+ ? null
+ : (value as EventResponse),
+ )
+ }
+ >
+ {choices.map((choice) => (
+
+
+
+ {choice.label}
+
+
+ {choice.description}
+
-
- {choice.description}
-
-
-
- ))}
+
+ ))}
+
)}
diff --git a/resources/js/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx
index cba9565d66..9be7b53ddb 100644
--- a/resources/js/components/quoted-commit.tsx
+++ b/resources/js/components/quoted-commit.tsx
@@ -1,4 +1,5 @@
-import { Link } from '@inertiajs/react';
+import { Link, usePage } from '@inertiajs/react';
+import { CalendarDays } from 'lucide-react';
import CommitController from '@/actions/App/Http/Controllers/CommitController';
import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController';
import { AuthorAvatar } from '@/components/author-avatar';
@@ -8,6 +9,7 @@ import { CommitPoll } from '@/components/commit-poll';
import { CommitStatusBadge } from '@/components/commit-status-badge';
import { LinkPreview } from '@/components/link-preview';
import { useNow } from '@/hooks/use-now';
+import { formatEventWhen } from '@/lib/event-dates';
import {
cn,
formatFullDateTime,
@@ -26,6 +28,7 @@ export function QuotedCommit({
label?: string;
}) {
const now = useNow();
+ const { auth } = usePage().props;
if (commit === null || isDeleted(commit)) {
return (
+ {commit.event && (
+ // The quote links to the commit already, so the event it announces is named rather than linked.
+
+
+
+ {commit.event.title}
+
+
+ {formatEventWhen(
+ commit.event,
+ auth.user?.timezone ?? undefined,
+ )}
+
+
+ )}
{commit.images && }
{commit.poll && (
diff --git a/resources/js/lib/event.ts b/resources/js/lib/event.ts
index c05ee9fd84..a2d41803d5 100644
--- a/resources/js/lib/event.ts
+++ b/resources/js/lib/event.ts
@@ -47,15 +47,90 @@ export function agendaProgress(
return { done, current };
}
+/**
+ * The event's place, unless it only says it is online.
+ */
+export function eventPlace(event: Pick): string | null {
+ return event.location === null ||
+ event.location.trim().toLowerCase() === 'online'
+ ? null
+ : event.location;
+}
+
/**
* A place, a link, or both, as GitHub would label it.
*/
export function eventFormat(
event: Pick,
): 'In person' | 'Online' | 'Hybrid' {
- if (event.location !== null && event.url !== null) {
+ if (eventPlace(event) !== null && event.url !== null) {
return 'Hybrid';
}
return event.url !== null ? 'Online' : 'In person';
}
+
+/**
+ * The zone's short name at that moment, like "GMT+1" or "CET", so times say which clock they follow.
+ */
+export function timeZoneLabel(date: Date, timeZone?: string): string {
+ return (
+ new Intl.DateTimeFormat(undefined, {
+ timeZone,
+ timeZoneName: 'short',
+ })
+ .formatToParts(date)
+ .find((part) => part.type === 'timeZoneName')?.value ?? ''
+ );
+}
+
+/**
+ * How far the zone's clock is ahead of UTC at that moment, in milliseconds.
+ */
+function zoneOffset(date: Date, timeZone?: string): number {
+ const parts = Object.fromEntries(
+ new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ hourCycle: 'h23',
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ })
+ .formatToParts(date)
+ .map((part) => [part.type, Number(part.value)]),
+ );
+
+ return (
+ Date.UTC(
+ parts.year,
+ parts.month - 1,
+ parts.day,
+ parts.hour,
+ parts.minute,
+ parts.second,
+ ) -
+ Math.floor(date.getTime() / 1000) * 1000
+ );
+}
+
+/**
+ * Reads a datetime-local value on the zone's clock rather than the browser's, so an event is entered in the same zone
+ * its pages show it in.
+ */
+export function wallTimeToInstant(value: string, timeZone?: string): string {
+ if (value === '') {
+ return '';
+ }
+
+ const [date, time] = value.split('T');
+ const [year, month, day] = date.split('-').map(Number);
+ const [hour, minute] = time.split(':').map(Number);
+ const wall = Date.UTC(year, month - 1, day, hour, minute);
+ const guess = wall - zoneOffset(new Date(wall), timeZone);
+
+ // Around a clock change the offset at the guess can differ from the one at the result, so it is taken again.
+ return new Date(wall - zoneOffset(new Date(guess), timeZone)).toISOString();
+}
diff --git a/resources/js/pages/event/create.tsx b/resources/js/pages/event/create.tsx
index 9fb3916ea6..119cfd9827 100644
--- a/resources/js/pages/event/create.tsx
+++ b/resources/js/pages/event/create.tsx
@@ -1,4 +1,4 @@
-import { Head, Link, useForm } from '@inertiajs/react';
+import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
import type { FormEvent } from 'react';
import EventController from '@/actions/App/Http/Controllers/EventController';
@@ -6,28 +6,22 @@ import InputError from '@/components/input-error';
import { MentionTextarea } from '@/components/mention-textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
+import { useNow } from '@/hooks/use-now';
import AppLayout from '@/layouts/app-layout';
+import { timeZoneLabel, wallTimeToInstant } from '@/lib/event';
import { characterCount, cn } from '@/lib/utils';
const inputClass =
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30';
-const maxBodyLength = 4000;
-
type AgendaDraft = { label: string; starts_at: string };
-/**
- * A datetime-local value is in the browser's time zone, so it is sent as an instant the server can't misread.
- */
-function toInstant(value: string): string {
- return value === '' ? '' : new Date(value).toISOString();
-}
-
export default function Create({
limits,
}: {
limits: {
title: number;
+ body: number;
location: number;
url: number;
agendaItems: number;
@@ -35,6 +29,13 @@ export default function Create({
durationDays: number;
};
}) {
+ const { auth } = usePage().props;
+ const now = useNow();
+ // Times are entered on the clock the event pages show them on: the profile's time zone, else the browser's.
+ const timeZone = auth.user?.timezone ?? undefined;
+ const zoneName =
+ timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const toInstant = (value: string) => wallTimeToInstant(value, timeZone);
const form = useForm({
title: '',
body: '',
@@ -123,7 +124,7 @@ export default function Create({
Replies to it become the event's discussion.
- {maxBodyLength - characterCount(data.body)}
+ {limits.body - characterCount(data.body)}
@@ -161,6 +162,13 @@ export default function Create({
/>
+
+ Times are in {zoneName} ({timeZoneLabel(now, timeZone)}
+ ).
+
diff --git a/resources/js/pages/event/show.tsx b/resources/js/pages/event/show.tsx
index 8e32d8957f..950465d01f 100644
--- a/resources/js/pages/event/show.tsx
+++ b/resources/js/pages/event/show.tsx
@@ -32,7 +32,13 @@ import {
import { UserAvatar } from '@/components/user-avatar';
import { useNow } from '@/hooks/use-now';
import AppLayout from '@/layouts/app-layout';
-import { agendaProgress, eventFormat, eventStateAt } from '@/lib/event';
+import {
+ agendaProgress,
+ eventFormat,
+ eventPlace,
+ eventStateAt,
+ timeZoneLabel,
+} from '@/lib/event';
import { formatEventTime, formatEventWhen } from '@/lib/event-dates';
import { cn, formatRelativeTime, shortHash } from '@/lib/utils';
import type {
@@ -50,6 +56,7 @@ export default function Show({
host,
response,
attendees,
+ attendeesLimit,
commit,
replies,
}: {
@@ -57,12 +64,14 @@ export default function Show({
host: User | null;
response: EventResponse | null;
attendees: User[];
+ attendeesLimit: number;
commit: Commit;
replies: CursorPaginated
;
}) {
const { auth } = usePage().props;
const now = useNow();
const timeZone = auth.user?.timezone ?? undefined;
+ const place = eventPlace(event);
const state = eventStateAt(event, now);
const isHost = auth.user !== null && host?.id === auth.user.id;
const isOpen = state === 'upcoming' || state === 'live';
@@ -135,7 +144,6 @@ export default function Show({
- {formatEventWhen(event, timeZone)}
+ {formatEventWhen(event, timeZone)} ·{' '}
+ {timeZoneLabel(new Date(event.starts_at), timeZone)}
- {event.location !== null && (
+ {place !== null && (
- {event.location}
+ {place}
)}
@@ -198,26 +207,25 @@ export default function Show({
- {attendees.length === 0 && host === null ? (
+ {attendees.length === 0 ? (
No one yet
) : (
- {host && (
-
- )}
{attendees.map((attendee) => (
))}
- {event.going_count > attendees.length && (
-
- +{event.going_count - attendees.length}
-
- )}
+ {/* Only once the avatars are full, so it never stands for someone left out across a block. */}
+ {attendees.length >= attendeesLimit &&
+ event.going_count > attendees.length && (
+
+ +{event.going_count - attendees.length}
+
+ )}
)}
@@ -264,12 +272,12 @@ function Detail({
);
}
-function AttendeeAvatar({ user, label }: { user: User; label?: string }) {
+function AttendeeAvatar({ user }: { user: User }) {
return (
diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts
index 6ebc4cc418..40e5e4824b 100644
--- a/resources/js/types/commit.ts
+++ b/resources/js/types/commit.ts
@@ -140,6 +140,7 @@ export type QuotedCommit = Pick<
| 'poll'
| 'link_preview'
| 'images'
+ | 'event'
>;
export type DeletedCommit = {
diff --git a/resources/js/types/event.ts b/resources/js/types/event.ts
index 32e9fb111a..17cabd994d 100644
--- a/resources/js/types/event.ts
+++ b/resources/js/types/event.ts
@@ -25,6 +25,6 @@ export type Event = {
};
export type EventWithHost = Event & {
- commit: { id: string; user: User };
+ host: User;
going_count: number;
};
diff --git a/tests/Feature/Controllers/EventControllerTest.php b/tests/Feature/Controllers/EventControllerTest.php
index 67b2511b43..a588d86e4a 100644
--- a/tests/Feature/Controllers/EventControllerTest.php
+++ b/tests/Feature/Controllers/EventControllerTest.php
@@ -54,7 +54,8 @@ function newEvent(array $overrides = []): array
->where('events.data.0.state', 'live')
->where('events.data.1.id', $sooner->id)
->where('events.data.1.going_count', 2)
- ->where('events.data.1.commit.user.username', $sooner->commit->user->username)
+ ->where('events.data.1.host.username', $sooner->commit->user->username)
+ ->missing('events.data.1.commit')
->where('events.data.2.id', $later->id));
});
@@ -159,6 +160,7 @@ function newEvent(array $overrides = []): array
->assertInertia(fn (Assert $page): Assert => $page
->component('event/create')
->where('limits.title', Event::MAX_TITLE_LENGTH)
+ ->where('limits.body', Commit::MAX_BODY_LENGTH)
->where('limits.agendaItems', Event::MAX_AGENDA_ITEMS));
});
diff --git a/tests/Feature/Controllers/ModerationControllerTest.php b/tests/Feature/Controllers/ModerationControllerTest.php
index c39c7d2c1e..1b31c22d2e 100644
--- a/tests/Feature/Controllers/ModerationControllerTest.php
+++ b/tests/Feature/Controllers/ModerationControllerTest.php
@@ -10,6 +10,8 @@
use App\Jobs\DetectCommitLanguage;
use App\Jobs\ModerateCommit;
use App\Models\Commit;
+use App\Models\Event;
+use App\Models\EventAgendaItem;
use App\Models\LinkPreview;
use App\Models\Organization;
use App\Models\User;
@@ -41,6 +43,18 @@
->has('commits.data.0.author'));
});
+it('shows moderators the event a pending commit announces, with its link and agenda', function (): void {
+ $admin = User::factory()->create(['username' => 'admin']);
+ $commit = Commit::factory()->pending()->create();
+ $event = Event::factory()->for($commit)->create(['title' => 'Laravel night', 'url' => 'https://example.com/stream']);
+ EventAgendaItem::factory()->for($event)->create(['label' => 'Opening talk', 'starts_at' => $event->starts_at]);
+
+ $this->actingAs($admin)->get(route('moderation.show'))->assertOk()->assertInertia(fn ($page) => $page
+ ->where('commits.data.0.event.title', 'Laravel night')
+ ->where('commits.data.0.event.url', 'https://example.com/stream')
+ ->where('commits.data.0.event.agenda_items.0.label', 'Opening talk'));
+});
+
it('shows the ai decision on commits', function (): void {
$admin = User::factory()->create(['handle' => 'admin']);
diff --git a/tests/Unit/EventProgress.test.ts b/tests/Unit/EventProgress.test.ts
index 638a3aaf6d..b9f05b024c 100644
--- a/tests/Unit/EventProgress.test.ts
+++ b/tests/Unit/EventProgress.test.ts
@@ -3,7 +3,10 @@ import { test } from 'node:test';
import {
agendaProgress,
eventFormat,
+ eventPlace,
eventStateAt,
+ timeZoneLabel,
+ wallTimeToInstant,
} from '../../resources/js/lib/event.ts';
import type { Event } from '../../resources/js/types/event.ts';
@@ -80,3 +83,46 @@ void test('a place, a link or both make an in-person, online or hybrid event', (
'Hybrid',
);
});
+
+void test('reads a datetime-local value on the clock of the given zone', () => {
+ assert.equal(
+ wallTimeToInstant('2026-11-03T19:00', 'Europe/Lisbon'),
+ '2026-11-03T19:00:00.000Z',
+ );
+ assert.equal(
+ wallTimeToInstant('2026-07-03T19:00', 'Europe/Lisbon'),
+ '2026-07-03T18:00:00.000Z',
+ );
+ assert.equal(
+ wallTimeToInstant('2026-11-03T19:00', 'America/New_York'),
+ '2026-11-04T00:00:00.000Z',
+ );
+ assert.equal(
+ wallTimeToInstant('2026-11-03T19:00', 'Asia/Kolkata'),
+ '2026-11-03T13:30:00.000Z',
+ );
+ assert.equal(wallTimeToInstant('', 'Europe/Lisbon'), '');
+});
+
+void test('reads a time on the day the clocks go forward after the change', () => {
+ assert.equal(
+ wallTimeToInstant('2026-03-29T03:30', 'Europe/Paris'),
+ '2026-03-29T01:30:00.000Z',
+ );
+});
+
+void test('names the zone a time follows', () => {
+ assert.match(
+ timeZoneLabel(new Date('2026-11-03T19:00:00Z'), 'Asia/Kolkata'),
+ /GMT\+5:30|IST/u,
+ );
+});
+
+void test('takes a place that only says online as no place', () => {
+ assert.equal(eventPlace({ location: ' Online ' }), null);
+ assert.equal(eventPlace({ location: 'Lisbon' }), 'Lisbon');
+ assert.equal(
+ eventFormat({ location: 'online', url: 'https://youtube.com/live' }),
+ 'Online',
+ );
+});
From 83bc37d7874cae018ffe6b7223245ae6d561f123 Mon Sep 17 00:00:00 2001
From: Houssem Eddine BENABDALLAH
Date: Fri, 23 Oct 2026 17:23:43 +0200
Subject: [PATCH 3/6] fix: keep the compose button off the new event form and
name the zone once
---
resources/js/layouts/app-layout.tsx | 3 ++-
resources/js/pages/event/create.tsx | 5 +++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx
index 78a560c0a0..acfcdfe030 100644
--- a/resources/js/layouts/app-layout.tsx
+++ b/resources/js/layouts/app-layout.tsx
@@ -150,7 +150,8 @@ export default function AppLayout({ children }: AppLayoutProps) {
)}
- {!isMessages && (
+ {/* The new event form is a composer of its own, so the button would only cover its fields. */}
+ {!isMessages && component !== 'event/create' && (
wallTimeToInstant(value, timeZone);
const form = useForm({
title: '',
@@ -166,8 +167,8 @@ export default function Create({
className="text-xs text-muted-foreground sm:col-span-2"
data-test="event-time-zone"
>
- Times are in {zoneName} ({timeZoneLabel(now, timeZone)}
- ).
+ Times are in {zoneName}
+ {zoneLabel === zoneName ? '' : ` (${zoneLabel})`}.
From 64c984d0fd4166238ff7ab6c690da368121d565d Mon Sep 17 00:00:00 2001
From: Houssem Eddine BENABDALLAH
Date: Sun, 25 Oct 2026 14:40:10 +0100
Subject: [PATCH 4/6] fix: notify a large event's attendees in chunks and show
the exact going count on hover
---
.../CreateEventCancellationNotifications.php | 28 ++++++++++---------
resources/js/components/event-list.tsx | 4 +--
.../EventCancellationControllerTest.php | 9 ++++++
3 files changed, 26 insertions(+), 15 deletions(-)
diff --git a/app/Actions/CreateEventCancellationNotifications.php b/app/Actions/CreateEventCancellationNotifications.php
index e05eb6bda3..afee3dcf81 100644
--- a/app/Actions/CreateEventCancellationNotifications.php
+++ b/app/Actions/CreateEventCancellationNotifications.php
@@ -9,6 +9,7 @@
use App\Models\Event;
use App\Models\EventAttendee;
use App\Models\Notification;
+use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
final readonly class CreateEventCancellationNotifications
@@ -21,19 +22,20 @@ public function handle(Event $event): void
{
$hostId = $event->commit?->user_id;
- Notification::query()->insertOrIgnore($event->attendees()
+ // A statement takes at most 65,535 bound values, so a large event's notifications go in chunks.
+ $event->attendees()
->where('response', EventResponse::Going)
- ->get()
- ->map(fn (EventAttendee $attendee): array => [
- 'id' => Str::uuid7()->toString(),
- 'recipient_id' => $attendee->user_id,
- 'actor_id' => $hostId,
- 'type' => NotificationType::EventCancelled->value,
- 'commit_id' => $event->commit_id,
- 'key' => 'event_cancelled:'.$event->id,
- 'created_at' => now(),
- 'updated_at' => now(),
- ])
- ->all());
+ ->chunkById(500, fn (Collection $attendees): int => Notification::query()->insertOrIgnore($attendees
+ ->map(fn (EventAttendee $attendee): array => [
+ 'id' => Str::uuid7()->toString(),
+ 'recipient_id' => $attendee->user_id,
+ 'actor_id' => $hostId,
+ 'type' => NotificationType::EventCancelled->value,
+ 'commit_id' => $event->commit_id,
+ 'key' => 'event_cancelled:'.$event->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ])
+ ->all()));
}
}
diff --git a/resources/js/components/event-list.tsx b/resources/js/components/event-list.tsx
index 4b101e77d3..77106522d6 100644
--- a/resources/js/components/event-list.tsx
+++ b/resources/js/components/event-list.tsx
@@ -17,7 +17,7 @@ import {
eventStateLabels,
} from '@/lib/event';
import { formatEventWhen } from '@/lib/event-dates';
-import { exactCountTitle, formatCount } from '@/lib/numbers';
+import { formatCount, formatExactCount } from '@/lib/numbers';
import { cn, shortHash } from '@/lib/utils';
import type { CursorPaginated, EventState, EventWithHost } from '@/types';
@@ -108,7 +108,7 @@ export function EventList({
{formatCount(event.going_count)}
diff --git a/tests/Feature/Controllers/EventCancellationControllerTest.php b/tests/Feature/Controllers/EventCancellationControllerTest.php
index 98dca57ba0..2c4400e4b1 100644
--- a/tests/Feature/Controllers/EventCancellationControllerTest.php
+++ b/tests/Feature/Controllers/EventCancellationControllerTest.php
@@ -59,6 +59,15 @@
->key->toBe('event_cancelled:'.$event->id);
});
+it('tells everyone going at a large event, past one chunk of notifications', function (): void {
+ $event = Event::factory()->create();
+ EventAttendee::factory()->for($event)->count(501)->create();
+
+ $this->actingAs($event->commit->user)->post(route('event-cancellation.store', $event));
+
+ expect(Notification::query()->where('type', NotificationType::EventCancelled)->count())->toBe(501);
+});
+
it('tells no one when an event is over', function (): void {
$event = Event::factory()->ended()->create();
EventAttendee::factory()->for($event)->create();
From 0f67d6b87fe4fae5e7af90c17a726c12cd7b7b28 Mon Sep 17 00:00:00 2001
From: Houssem Eddine BENABDALLAH
Date: Tue, 27 Oct 2026 09:18:02 +0100
Subject: [PATCH 5/6] fix: name event hosts by their handle
---
resources/js/components/event-list.tsx | 2 +-
resources/js/pages/event/show.tsx | 4 ++--
tests/Feature/Controllers/EventControllerTest.php | 2 +-
tests/Feature/Controllers/ModerationControllerTest.php | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/resources/js/components/event-list.tsx b/resources/js/components/event-list.tsx
index 77106522d6..2513f1739d 100644
--- a/resources/js/components/event-list.tsx
+++ b/resources/js/components/event-list.tsx
@@ -103,7 +103,7 @@ export function EventList({
{eventPlace(event) !== null &&
`${eventPlace(event)} · `}
- hosted by @{event.host.username}
+ hosted by @{event.host.handle}
- @{host.username}
+ @{host.handle}
{' '}
{state === 'cancelled'
? 'cancelled this event'
@@ -277,7 +277,7 @@ function AttendeeAvatar({ user }: { user: User }) {
diff --git a/tests/Feature/Controllers/EventControllerTest.php b/tests/Feature/Controllers/EventControllerTest.php
index a588d86e4a..56b8e47a69 100644
--- a/tests/Feature/Controllers/EventControllerTest.php
+++ b/tests/Feature/Controllers/EventControllerTest.php
@@ -54,7 +54,7 @@ function newEvent(array $overrides = []): array
->where('events.data.0.state', 'live')
->where('events.data.1.id', $sooner->id)
->where('events.data.1.going_count', 2)
- ->where('events.data.1.host.username', $sooner->commit->user->username)
+ ->where('events.data.1.host.handle', $sooner->commit->user->handle)
->missing('events.data.1.commit')
->where('events.data.2.id', $later->id));
});
diff --git a/tests/Feature/Controllers/ModerationControllerTest.php b/tests/Feature/Controllers/ModerationControllerTest.php
index 1b31c22d2e..309499b472 100644
--- a/tests/Feature/Controllers/ModerationControllerTest.php
+++ b/tests/Feature/Controllers/ModerationControllerTest.php
@@ -44,7 +44,7 @@
});
it('shows moderators the event a pending commit announces, with its link and agenda', function (): void {
- $admin = User::factory()->create(['username' => 'admin']);
+ $admin = User::factory()->create(['handle' => 'admin']);
$commit = Commit::factory()->pending()->create();
$event = Event::factory()->for($commit)->create(['title' => 'Laravel night', 'url' => 'https://example.com/stream']);
EventAgendaItem::factory()->for($event)->create(['label' => 'Opening talk', 'starts_at' => $event->starts_at]);
From 640883bb52e3d9800a4eb6003ee31b12750412f7 Mon Sep 17 00:00:00 2001
From: Houssem Eddine BENABDALLAH
Date: Tue, 3 Nov 2026 16:02:44 +0100
Subject: [PATCH 6/6] fix: let an organization host its events, shown as the
organization and run by its members
---
.../CreateEventCancellationNotifications.php | 4 ++--
app/Http/Controllers/EventController.php | 3 ++-
app/Models/Event.php | 14 ++++++++-----
app/Queries/EventsQuery.php | 5 +++--
resources/js/pages/event/show.tsx | 8 ++++++--
resources/js/types/event.ts | 4 ++--
.../EventCancellationControllerTest.php | 20 +++++++++++++++++++
.../Controllers/EventControllerTest.php | 16 +++++++++++++++
.../Controllers/UserControllerTest.php | 3 ++-
9 files changed, 62 insertions(+), 15 deletions(-)
diff --git a/app/Actions/CreateEventCancellationNotifications.php b/app/Actions/CreateEventCancellationNotifications.php
index afee3dcf81..d4c1145550 100644
--- a/app/Actions/CreateEventCancellationNotifications.php
+++ b/app/Actions/CreateEventCancellationNotifications.php
@@ -15,12 +15,12 @@
final readonly class CreateEventCancellationNotifications
{
/**
- * Only the people going hear about it: an interested answer is private, so nothing acts on it. The host is the actor,
+ * Only the people going hear about it: an interested answer is private, so nothing acts on it. The host, a person or an organization, is the actor,
* and the announcement commit is the subject, so the notification goes once the commit does.
*/
public function handle(Event $event): void
{
- $hostId = $event->commit?->user_id;
+ $hostId = $event->commit?->author_id;
// A statement takes at most 65,535 bound values, so a large event's notifications go in chunks.
$event->attendees()
diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php
index fb4998236d..d82ab26194 100644
--- a/app/Http/Controllers/EventController.php
+++ b/app/Http/Controllers/EventController.php
@@ -12,6 +12,7 @@
use App\Http\Requests\ShowEventRequest;
use App\Models\Commit;
use App\Models\Event;
+use App\Models\Profile;
use App\Models\User;
use App\Queries\CommitPageQuery;
use App\Queries\CommitRepliesQuery;
@@ -77,7 +78,7 @@ public function show(ShowEventRequest $request, #[CurrentUser] ?User $currentUse
return Inertia::render('event/show', [
'event' => fn (): Event => $event->load('agendaItems')->loadCount(['attendees as going_count' => fn (Builder $query): Builder => $query->where('response', EventResponse::Going)]),
- 'host' => fn (): ?User => $commit->user,
+ 'host' => fn (): ?Profile => $commit->author,
'response' => fn (): ?string => $event->responseOf($currentUser)?->value,
'attendees' => fn (): Collection => $attendees->get($event, $currentUser),
'attendeesLimit' => EventAttendeesQuery::LIMIT,
diff --git a/app/Models/Event.php b/app/Models/Event.php
index d1cdbb788a..713cac6691 100644
--- a/app/Models/Event.php
+++ b/app/Models/Event.php
@@ -30,7 +30,7 @@
* @property-read CarbonInterface $updated_at
* @property-read int|null $going_count
* @property-read Commit|null $commit
- * @property-read User|null $host
+ * @property-read Profile|null $host
* @property-read Collection $agendaItems
* @property-read Collection $attendees
*/
@@ -94,13 +94,14 @@ public function commit(): BelongsTo
}
/**
- * Whoever posted the announcement, for lists that show only who hosts rather than the whole commit.
+ * The profile the announcement is from, for lists that show only who hosts rather than the whole commit: an
+ * organization's event is hosted by the organization, not by the member who posted it.
*
- * @return HasOneThrough
+ * @return HasOneThrough
*/
public function host(): HasOneThrough
{
- return $this->hasOneThrough(User::class, Commit::class, 'id', 'id', 'commit_id', 'user_id');
+ return $this->hasOneThrough(Profile::class, Commit::class, 'id', 'id', 'commit_id', 'author_id')->with(['user', 'organization']);
}
/**
@@ -137,9 +138,12 @@ public function isVisibleTo(?User $user): bool
return $this->commit instanceof Commit && $this->commit->isVisibleTo($user);
}
+ /**
+ * Like its announcement, an organization's event is each of its members' to run.
+ */
public function isHostedBy(User $user): bool
{
- return $this->commit?->user_id === $user->id;
+ return $this->commit?->isOwnedBy($user) ?? false;
}
public function responseOf(?User $user): ?EventResponse
diff --git a/app/Queries/EventsQuery.php b/app/Queries/EventsQuery.php
index eaa3557963..aa096d07b6 100644
--- a/app/Queries/EventsQuery.php
+++ b/app/Queries/EventsQuery.php
@@ -41,14 +41,15 @@ public function paginate(?User $viewer, bool $past = false): CursorPaginator
}
/**
- * The events a user hosts, as the viewer may see them, latest first.
+ * The events a user hosts as themselves, as the viewer may see them, latest first: an organization's stay off its members'
+ * profiles.
*
* @return CursorPaginator
*/
public function paginateHostedBy(User $host, ?User $viewer): CursorPaginator
{
return $this->builder($viewer)
- ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->whereBelongsTo($host))
+ ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->where('author_id', $host->id))
->latest('starts_at')
->orderByDesc('id')
->cursorPaginate(self::PER_PAGE)
diff --git a/resources/js/pages/event/show.tsx b/resources/js/pages/event/show.tsx
index a5549b4a8e..df0b906cbe 100644
--- a/resources/js/pages/event/show.tsx
+++ b/resources/js/pages/event/show.tsx
@@ -31,6 +31,7 @@ import {
} from '@/components/ui/dialog';
import { UserAvatar } from '@/components/user-avatar';
import { useNow } from '@/hooks/use-now';
+import { useOwnsCommit } from '@/hooks/use-owns-commit';
import AppLayout from '@/layouts/app-layout';
import {
agendaProgress,
@@ -42,6 +43,7 @@ import {
import { formatEventTime, formatEventWhen } from '@/lib/event-dates';
import { cn, formatRelativeTime, shortHash } from '@/lib/utils';
import type {
+ CommitAuthor,
Commit,
CursorPaginated,
Event,
@@ -61,7 +63,7 @@ export default function Show({
replies,
}: {
event: Event & { going_count: number; agenda_items: EventAgendaItem[] };
- host: User | null;
+ host: CommitAuthor | null;
response: EventResponse | null;
attendees: User[];
attendeesLimit: number;
@@ -70,10 +72,12 @@ export default function Show({
}) {
const { auth } = usePage().props;
const now = useNow();
+ const ownsCommit = useOwnsCommit();
const timeZone = auth.user?.timezone ?? undefined;
const place = eventPlace(event);
const state = eventStateAt(event, now);
- const isHost = auth.user !== null && host?.id === auth.user.id;
+ // An organization's event is each of its members' to run, like its announcement.
+ const isHost = host !== null && ownsCommit({ author: host });
const isOpen = state === 'upcoming' || state === 'live';
const goBack = () => {
diff --git a/resources/js/types/event.ts b/resources/js/types/event.ts
index 17cabd994d..3d3b749727 100644
--- a/resources/js/types/event.ts
+++ b/resources/js/types/event.ts
@@ -1,4 +1,4 @@
-import type { User } from '@/types/auth';
+import type { CommitAuthor } from '@/types/commit';
export type EventState = 'upcoming' | 'live' | 'ended' | 'cancelled';
@@ -25,6 +25,6 @@ export type Event = {
};
export type EventWithHost = Event & {
- host: User;
+ host: CommitAuthor;
going_count: number;
};
diff --git a/tests/Feature/Controllers/EventCancellationControllerTest.php b/tests/Feature/Controllers/EventCancellationControllerTest.php
index 2c4400e4b1..3d94577e94 100644
--- a/tests/Feature/Controllers/EventCancellationControllerTest.php
+++ b/tests/Feature/Controllers/EventCancellationControllerTest.php
@@ -4,9 +4,12 @@
use App\Enums\EventState;
use App\Enums\NotificationType;
+use App\Enums\OrganizationRole;
+use App\Models\Commit;
use App\Models\Event;
use App\Models\EventAttendee;
use App\Models\Notification;
+use App\Models\Organization;
use App\Models\User;
it('lets the host cancel an event', function (Closure $event): void {
@@ -23,6 +26,23 @@
'live' => fn (): Event => Event::factory()->live()->create(),
]);
+it('lets a member cancel their organization\'s event, which tells the people going it was the organization', function (): void {
+ $organization = Organization::factory()->create();
+ $member = User::factory()->create();
+ $organization->members()->attach($member->id, ['role' => OrganizationRole::Member]);
+ $event = Event::factory()->for(Commit::factory()->state(['author_id' => $organization->id]))->create();
+ $attendee = EventAttendee::factory()->for($event)->create();
+
+ $this->actingAs($member)
+ ->post(route('event-cancellation.store', $event))
+ ->assertRedirect();
+
+ expect($event->refresh()->state())->toBe(EventState::Cancelled)
+ ->and(Notification::query()->where('recipient_id', $attendee->user_id)->sole())
+ ->actor_id->toBe($organization->id)
+ ->type->toBe(NotificationType::EventCancelled);
+});
+
it('lets nobody else cancel an event', function (): void {
$event = Event::factory()->create();
diff --git a/tests/Feature/Controllers/EventControllerTest.php b/tests/Feature/Controllers/EventControllerTest.php
index 56b8e47a69..45187b53f4 100644
--- a/tests/Feature/Controllers/EventControllerTest.php
+++ b/tests/Feature/Controllers/EventControllerTest.php
@@ -7,6 +7,7 @@
use App\Models\Event;
use App\Models\EventAgendaItem;
use App\Models\EventAttendee;
+use App\Models\Organization;
use App\Models\User;
use App\Models\UserTimeout;
use Carbon\CarbonImmutable;
@@ -124,6 +125,21 @@ function newEvent(array $overrides = []): array
->where('replies.data.0.id', $reply->id));
});
+it('shows an organization\'s event as hosted by the organization, not by who posted it', function (): void {
+ $organization = Organization::factory()->create(['handle' => 'laravel']);
+ $event = Event::factory()->for(Commit::factory()->state(['author_id' => $organization->id]))->create();
+
+ $this->get(route('event.show', $event))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page): Assert => $page
+ ->where('host.type', 'organization')
+ ->where('host.handle', 'laravel'));
+ $this->get(route('event.index'))
+ ->assertInertia(fn (Assert $page): Assert => $page
+ ->where('events.data.0.host.type', 'organization')
+ ->where('events.data.0.host.handle', 'laravel'));
+});
+
it('shows events to guests', function (): void {
$event = Event::factory()->create();
diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php
index 3e9343a61d..0bbf04be53 100644
--- a/tests/Feature/Controllers/UserControllerTest.php
+++ b/tests/Feature/Controllers/UserControllerTest.php
@@ -831,11 +831,12 @@
->assertRedirect("/@OctoCat/{$list}");
})->with(['following', 'followers', 'mutuals']);
-it('lists the events a user hosts on the events tab, latest first', function (): void {
+it('lists the events a user hosts on the events tab, latest first, leaving out the ones they posted as an organization', function (): void {
$user = User::factory()->create();
$earlier = Event::factory()->for(Commit::factory()->for($user))->ended()->create();
$later = Event::factory()->for(Commit::factory()->for($user))->create();
Event::factory()->for(Commit::factory()->for($user)->pending())->create();
+ Event::factory()->for(Commit::factory()->for($user)->state(['author_id' => Organization::factory()]))->create();
Event::factory()->create();
$this->get(route('user.show', ['user' => $user, 'tab' => 'events']))