Scheduled Begin
diff --git a/lib/dev_round_web/live/user_events_live.ex b/lib/dev_round_web/live/user_events_live.ex
new file mode 100644
index 0000000..3ad9b1f
--- /dev/null
+++ b/lib/dev_round_web/live/user_events_live.ex
@@ -0,0 +1,275 @@
+defmodule DevRoundWeb.UserEventsLive do
+ use DevRoundWeb, :live_view
+ use DevRoundWeb.EventSessionCountdownLive, :relay_countdown_ticks
+
+ alias DevRound.Events
+ alias DevRound.Events.Event
+ alias DevRoundWeb.Layouts
+
+ def mount(_params, _session, socket) do
+ if connected?(socket) do
+ DevRoundWeb.Endpoint.subscribe("registrations")
+ DevRoundWeb.Endpoint.subscribe("admin.events")
+ DevRoundWeb.Endpoint.subscribe("event_sessions")
+
+ # Schedule midnight rollover check
+ schedule_midnight_check()
+ end
+
+ {:ok, socket}
+ end
+
+ def handle_params(params, _url, socket) do
+ expanded_ids =
+ case params["expanded"] do
+ ids when is_list(ids) -> ids
+ id when is_binary(id) -> [id]
+ _ -> []
+ end
+ |> Enum.map(&String.to_integer/1)
+ |> MapSet.new()
+
+ {:noreply,
+ socket
+ |> assign(:expanded_ids, expanded_ids)
+ |> assign_events()}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ Your Events
+
+
+
+ <%!-- underway Section --%>
+
+
+ Up Next
+
+
+ <.event_card :for={event <- @underway_events} event={event} teams_map={@teams_map} />
+
+
+
+ <%!-- Upcoming Section --%>
+
+
+ <%!-- Archived Section --%>
+
+
+
Archived Events
+
+ {length(@archived_events)}
+
+
+
+ <.event_card
+ :for={event <- @archived_events}
+ event={event}
+ teams_map={@teams_map}
+ collapsable={true}
+ expanded={MapSet.member?(@expanded_ids, event.id)}
+ />
+
+
+
+
+ Check out the <.link patch={~p"/events"} class="link">events page
+ to find your first event!
+
+
+
+ """
+ end
+
+ attr :event, Event, required: true
+ attr :teams_map, :map, required: true
+ attr :collapsable, :boolean, default: false
+ attr :expanded, :boolean, default: true
+
+ defp event_card(assigns) do
+ ~H"""
+
+
+
+
+ <.link patch={~p"/events/#{@event.slug}"}>
+ {@event.title}
+
+
+
{@event.teaser}
+
+
+ <.icon name="hero-calendar" />
+ {DevRound.Formats.format_datetime_range(@event.begin_local, @event.end_local)}
+
+
+ <.icon name="hero-map-pin" /> {@event.location}
+
+
+
+
+
+ <.button patch={~p"/events/#{@event.slug}"} variant="primary">
+ View Event
+
+
+
+
+
+ <.icon name="hero-list-bullet" class="size-4" /> Sessions & Teams
+
+ <%= for session <- @event.sessions do %>
+
+
+
+
+ <%= if Map.has_key?(@teams_map, session.id) do %>
+
+ <% else %>
+
+ The teams for this session have not been assigned yet.
+
+ <% end %>
+
+ <% end %>
+
+
+ """
+ end
+
+ defp toggle_expand(expanded, event_id) do
+ JS.push("toggle_expand", value: %{id: event_id, expanded: expanded})
+ end
+
+ def handle_event("toggle_expand", %{"id" => id, "expanded" => expanded}, socket) do
+ expanded_ids =
+ if expanded do
+ MapSet.delete(socket.assigns.expanded_ids, id)
+ else
+ MapSet.put(socket.assigns.expanded_ids, id)
+ end
+
+ params =
+ if Enum.empty?(expanded_ids),
+ do: %{},
+ else: %{expanded: MapSet.to_list(expanded_ids)}
+
+ {:noreply, push_patch(socket, to: ~p"/user/events?#{params}")}
+ end
+
+ def handle_info({:registrations, _operation, _event, attendee}, socket) do
+ if attendee.user_id == socket.assigns.current_user.id do
+ {:noreply, socket |> assign_events()}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info(_, socket) do
+ {:noreply, socket |> assign_events()}
+ end
+
+ defp assign_events(socket) do
+ user_id = socket.assigns.current_user.id
+ events = Events.list_registered_events_for_user(user_id)
+
+ tz = Application.get_env(:dev_round, :time_zone)
+ {:ok, now} = DateTime.now(tz)
+
+ underway =
+ Enum.filter(events, fn e ->
+ begin_local = e.begin |> DateTime.shift_zone!(tz)
+ end_local = e.end |> DateTime.shift_zone!(tz)
+
+ archival_datetime =
+ end_local
+ |> DateTime.to_date()
+ |> Date.add(1)
+ |> DateTime.new!(~T[00:00:00], tz)
+
+ DateTime.compare(now, begin_local) in [:gt, :eq] and
+ DateTime.compare(now, archival_datetime) == :lt
+ end)
+
+ upcoming =
+ Enum.filter(events, fn e ->
+ begin_local = e.begin |> DateTime.shift_zone!(tz)
+ DateTime.compare(begin_local, now) == :gt
+ end)
+
+ archived =
+ Enum.filter(events, fn e ->
+ end_local = e.end |> DateTime.shift_zone!(tz)
+
+ archival_datetime =
+ end_local
+ |> DateTime.to_date()
+ |> Date.add(1)
+ |> DateTime.new!(~T[00:00:00], tz)
+
+ DateTime.compare(now, archival_datetime) in [:gt, :eq]
+ end)
+
+ # Fetch teams for underway sessions + expanded archived sessions
+ expanded_ids = socket.assigns[:expanded_ids] || MapSet.new()
+
+ session_ids =
+ Enum.flat_map(underway, & &1.sessions)
+ |> Enum.concat(
+ Enum.filter(archived, &MapSet.member?(expanded_ids, &1.id))
+ |> Enum.flat_map(& &1.sessions)
+ )
+ |> Enum.filter(fn session -> session.teams_locked end)
+ |> Enum.map(& &1.id)
+
+ teams_map = DevRound.Hosting.list_teams_for_user_in_sessions(user_id, session_ids)
+
+ socket
+ |> assign(:underway_events, underway)
+ |> assign(:upcoming_events, upcoming)
+ |> assign(:archived_events, archived)
+ |> assign(:teams_map, teams_map)
+ end
+
+ defp schedule_midnight_check do
+ tz = Application.get_env(:dev_round, :time_zone)
+ {:ok, now} = DateTime.now(tz)
+
+ next_midnight =
+ now
+ |> DateTime.to_date()
+ |> Date.add(1)
+ |> DateTime.new!(~T[00:00:01], tz)
+
+ diff_ms = DateTime.diff(next_midnight, now, :millisecond)
+ Process.send_after(self(), :refresh_events, diff_ms)
+ end
+end
diff --git a/lib/dev_round_web/router.ex b/lib/dev_round_web/router.ex
index 46b8c44..596f190 100644
--- a/lib/dev_round_web/router.ex
+++ b/lib/dev_round_web/router.ex
@@ -30,6 +30,7 @@ defmodule DevRoundWeb.Router do
live_session :main,
on_mount: [{DevRoundWeb.UserAuth, :ensure_authenticated}] do
+ live "/user/events", UserEventsLive, :show
live "/users/settings", UserSettingsLive, :edit
live "/events", EventLive.Index, :index
live "/events/:slug", EventLive.Show, :show
diff --git a/test/dev_round/events_test.exs b/test/dev_round/events_test.exs
index 923baa3..0a43aff 100644
--- a/test/dev_round/events_test.exs
+++ b/test/dev_round/events_test.exs
@@ -53,6 +53,23 @@ defmodule DevRound.EventsTest do
event_fixture(%{published: false})
assert Events.list_events(:current) == []
end
+
+ test "list_registered_events_for_user/1 returns registered events" do
+ future_deadline = NaiveDateTime.add(NaiveDateTime.local_now(), 12, :hour)
+ event = event_fixture(%{registration_deadline_local: future_deadline})
+ user = user_fixture()
+
+ {:ok, _attendee} =
+ Events.create_event_attendee(event, user, %{
+ "lang_ids" => [Enum.at(event.langs, 0).id]
+ })
+
+ registered_events = Events.list_registered_events_for_user(user.id)
+ assert length(registered_events) == 1
+ assert hd(registered_events).id == event.id
+ assert hd(registered_events).sessions != []
+ assert hd(registered_events).langs != []
+ end
end
describe "get_event!" do
diff --git a/test/dev_round/formats_test.exs b/test/dev_round/formats_test.exs
index 06636ba..46ba447 100644
--- a/test/dev_round/formats_test.exs
+++ b/test/dev_round/formats_test.exs
@@ -106,6 +106,51 @@ defmodule DevRound.FormatsTest do
end
end
+ describe "format_datetime_range_compact/2" do
+ test "formats only time range if datetime is today" do
+ tz = Formats.time_zone()
+ {:ok, now} = DateTime.now(tz)
+ dt1 = DateTime.to_naive(now) |> NaiveDateTime.truncate(:second) |> DateTime.from_naive!(tz)
+ dt1 = %{dt1 | hour: 16, minute: 0, second: 0}
+ dt2 = %{dt1 | hour: 18, minute: 0, second: 0}
+
+ assert Formats.format_datetime_range_compact(dt1, dt2) == "16:00 – 18:00"
+ end
+
+ test "formats full datetime range if datetime is NOT today" do
+ tz = Formats.time_zone()
+ {:ok, now} = DateTime.now(tz)
+ yesterday = DateTime.add(now, -1, :day)
+
+ dt1 =
+ DateTime.to_naive(yesterday)
+ |> NaiveDateTime.truncate(:second)
+ |> DateTime.from_naive!(tz)
+
+ dt1 = %{dt1 | hour: 16, minute: 0, second: 0}
+ dt2 = %{dt1 | hour: 18, minute: 0, second: 0}
+
+ expected = Formats.format_datetime_range(dt1, dt2)
+ assert Formats.format_datetime_range_compact(dt1, dt2) == expected
+ end
+
+ test "formats full datetime range if start and end are on different days" do
+ tz = Formats.time_zone()
+ {:ok, now} = DateTime.now(tz)
+ dt1 = DateTime.to_naive(now) |> NaiveDateTime.truncate(:second) |> DateTime.from_naive!(tz)
+ dt1 = %{dt1 | hour: 23, minute: 0, second: 0}
+ tomorrow = DateTime.add(now, 1, :day)
+
+ dt2 =
+ DateTime.to_naive(tomorrow) |> NaiveDateTime.truncate(:second) |> DateTime.from_naive!(tz)
+
+ dt2 = %{dt2 | hour: 1, minute: 0, second: 0}
+
+ expected = Formats.format_datetime_range(dt1, dt2)
+ assert Formats.format_datetime_range_compact(dt1, dt2) == expected
+ end
+ end
+
describe "format_avatar_placeholder/1" do
test "generates initials from first and last name" do
user = %User{avatar: nil, full_name: "John Doe"}
diff --git a/test/dev_round_web/live/user_events_live_test.exs b/test/dev_round_web/live/user_events_live_test.exs
new file mode 100644
index 0000000..72e0541
--- /dev/null
+++ b/test/dev_round_web/live/user_events_live_test.exs
@@ -0,0 +1,224 @@
+defmodule DevRoundWeb.UserEventsLiveTest do
+ use DevRoundWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import DevRound.EventsFixtures
+ import DevRound.AccountsFixtures
+ alias DevRound.Events
+
+ setup %{conn: conn} do
+ user = user_fixture()
+ %{conn: log_in_user(conn, user), user: user}
+ end
+
+ describe "User Events" do
+ test "renders empty state when no registrations", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/user/events")
+ assert html =~ "No registrations found"
+ end
+
+ test "renders underway, upcoming and archived events with multi-day support", %{
+ conn: conn,
+ user: user
+ } do
+ tz = Application.get_env(:dev_round, :time_zone)
+ {:ok, now} = DateTime.now(tz)
+
+ # Underway: multi-day event starting yesterday, ending tomorrow
+ begin_underway = NaiveDateTime.add(DateTime.to_naive(now), -24, :hour)
+ end_underway = NaiveDateTime.add(DateTime.to_naive(now), 24, :hour)
+
+ event_underway =
+ event_fixture(%{
+ title: "Multi-day Underway Event",
+ begin_local: begin_underway,
+ end_local: end_underway,
+ registration_deadline_local: NaiveDateTime.add(begin_underway, -1, :day)
+ })
+
+ # Upcoming: starts in 2 days
+ begin_upcoming = NaiveDateTime.add(DateTime.to_naive(now), 2, :day)
+
+ event_upcoming =
+ event_fixture(%{
+ title: "Future Event",
+ begin_local: begin_upcoming,
+ registration_deadline_local: NaiveDateTime.add(begin_upcoming, -1, :day)
+ })
+
+ # Archived: ended 2 days ago
+ begin_archived = NaiveDateTime.add(DateTime.to_naive(now), -5, :day)
+ end_archived = NaiveDateTime.add(DateTime.to_naive(now), -2, :day)
+
+ event_archived =
+ event_fixture(%{
+ title: "Past Multi-day Event",
+ begin_local: begin_archived,
+ end_local: end_archived,
+ registration_deadline_local: NaiveDateTime.add(begin_archived, -1, :day)
+ })
+
+ # Register user for all
+ for event <- [event_underway, event_upcoming, event_archived] do
+ Events.create_event_attendee(
+ event,
+ user,
+ %{
+ "lang_ids" => [Enum.at(event.langs, 0).id]
+ },
+ :host
+ )
+ end
+
+ {:ok, view, _html} = live(conn, ~p"/user/events")
+
+ assert has_element?(view, "#underway-events")
+ assert has_element?(view, "a", "Multi-day Underway Event")
+
+ assert has_element?(view, "#upcoming-events")
+ assert has_element?(view, "a", "Future Event")
+
+ assert has_element?(view, "#archived-events")
+ assert has_element?(view, "a", "Past Multi-day Event")
+ end
+
+ test "updates in real-time on new registration", %{conn: conn, user: user} do
+ {:ok, view, html} = live(conn, ~p"/user/events")
+ assert html =~ "No registrations found"
+
+ future_begin = NaiveDateTime.add(NaiveDateTime.local_now(), 2, :day)
+
+ event =
+ event_fixture(%{
+ title: "New Registration",
+ begin_local: future_begin,
+ registration_deadline_local: NaiveDateTime.add(future_begin, -1, :day)
+ })
+
+ # Simulate registration
+ {:ok, attendee} =
+ Events.create_event_attendee(event, user, %{
+ "lang_ids" => [Enum.at(event.langs, 0).id]
+ })
+
+ # Broadcast
+ DevRoundWeb.Endpoint.broadcast("registrations", "created", {:created, event, attendee})
+
+ assert render(view) =~ "New Registration"
+ end
+
+ test "renders team information for underway sessions", %{conn: conn, user: user} do
+ tz = Application.get_env(:dev_round, :time_zone)
+ {:ok, now} = DateTime.now(tz)
+
+ # Underway event
+ begin = NaiveDateTime.add(DateTime.to_naive(now), -1, :hour)
+
+ event =
+ event_fixture(%{
+ title: "Team Display Event",
+ begin_local: begin,
+ end_local: NaiveDateTime.add(begin, 24, :hour),
+ registration_deadline_local: NaiveDateTime.add(begin, -1, :day)
+ })
+
+ # bypass deadline check for testing team display
+ %DevRound.Events.EventAttendee{}
+ |> Ecto.Changeset.change(%{
+ event_id: event.id,
+ user_id: user.id,
+ experience_level: 3,
+ is_remote: false
+ })
+ |> DevRound.Repo.insert!()
+
+ session = hd(event.sessions)
+ # Mark session as team locked so teams are loaded
+ session |> Ecto.Changeset.change(%{teams_locked: true}) |> DevRound.Repo.update!()
+
+ lang = Enum.at(event.langs, 0)
+
+ # Create team manually
+ {:ok, team} =
+ %DevRound.Hosting.Team{}
+ |> Ecto.Changeset.change(%{
+ name: "The Dream Team",
+ slug: "dream-team",
+ is_remote: false,
+ session_id: session.id,
+ lang_id: lang.id
+ })
+ |> DevRound.Repo.insert()
+
+ # Add user to team
+ %DevRound.Hosting.TeamMember{}
+ |> Ecto.Changeset.change(%{
+ team_id: team.id,
+ user_id: user.id,
+ experience_level: 3,
+ is_remote: false
+ })
+ |> DevRound.Repo.insert!()
+
+ {:ok, _view, html} = live(conn, ~p"/user/events")
+
+ assert html =~ "The Dream Team"
+ end
+
+ test "expands multiple archived events", %{conn: conn, user: user} do
+ tz = Application.get_env(:dev_round, :time_zone)
+ {:ok, now} = DateTime.now(tz)
+
+ # Archived events
+ begin_archived = NaiveDateTime.add(DateTime.to_naive(now), -10, :day)
+ end_archived = NaiveDateTime.add(DateTime.to_naive(now), -8, :day)
+
+ archived_attrs = %{
+ begin_local: begin_archived,
+ end_local: end_archived,
+ registration_deadline_local: NaiveDateTime.add(begin_archived, -1, :day)
+ }
+
+ event1 = event_fixture(Map.merge(archived_attrs, %{title: "Event 1"}))
+ event2 = event_fixture(Map.merge(archived_attrs, %{title: "Event 2"}))
+
+ for {event, index} <- Enum.with_index([event1, event2]) do
+ Events.create_event_attendee(
+ event,
+ user,
+ %{"lang_ids" => [Enum.at(event.langs, 0).id]},
+ :host
+ )
+
+ # Mark sessions as team locked and give unique title
+ for {session, s_index} <- Enum.with_index(event.sessions) do
+ session
+ |> Ecto.Changeset.change(%{
+ teams_locked: true,
+ title: "Archived Event #{index + 1} Session #{s_index + 1}"
+ })
+ |> DevRound.Repo.update!()
+ end
+ end
+
+ {:ok, view, _html} = live(conn, ~p"/user/events")
+
+ # Initially not expanded
+ refute has_element?(view, "h2", "Archived Event 1 Session 1")
+ refute has_element?(view, "h2", "Archived Event 1 Session 1")
+ refute has_element?(view, "h2", "Archived Event 2 Session 1")
+
+ # Expand event 1
+ view |> render_click("toggle_expand", %{"id" => to_string(event1.id), "expanded" => false})
+ assert has_element?(view, "h2", "Archived Event 1 Session 1")
+ assert has_element?(view, "h2", "Archived Event 1 Session 1")
+ refute has_element?(view, "h2", "Archived Event 2 Session 1")
+
+ # Expand event 2
+ view |> render_click("toggle_expand", %{"id" => to_string(event2.id), "expanded" => false})
+ assert has_element?(view, "h2", "Archived Event 1 Session 1")
+ assert has_element?(view, "h2", "Archived Event 1 Session 1")
+ assert has_element?(view, "h2", "Archived Event 2 Session 1")
+ end
+ end
+end