diff --git a/lib/bike_brigade_web/live/campaign_signup_live/index.ex b/lib/bike_brigade_web/live/campaign_signup_live/index.ex
new file mode 100644
index 00000000..e6e32f80
--- /dev/null
+++ b/lib/bike_brigade_web/live/campaign_signup_live/index.ex
@@ -0,0 +1,130 @@
+defmodule BikeBrigadeWeb.CampaignSignupLive.Index do
+ use BikeBrigadeWeb, :live_view
+
+ alias BikeBrigade.Utils
+ alias BikeBrigade.LocalizedDateTime
+ alias BikeBrigade.Delivery
+
+ import BikeBrigadeWeb.CampaignHelpers
+
+ @impl true
+ def mount(_params, _session, socket) do
+ if connected?(socket) do
+ Delivery.subscribe()
+ end
+
+ current_week =
+ LocalizedDateTime.today()
+ |> Date.beginning_of_week()
+
+ campaigns = fetch_campaigns(current_week)
+
+ {:ok,
+ socket
+ |> assign(:page, :campaigns)
+ |> assign(:page_title, "Campaign Signup List")
+ |> assign(:current_week, current_week)
+ # REVIEW: rename this to `campaign_meta` ?
+ |> assign(:campaign_task_counts, Delivery.get_total_tasks_and_open_tasks(current_week))
+ |> assign(:campaigns, campaigns)}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ # -- Delivery callbacks
+
+ @broadcasted_infos [
+ :task_created,
+ :task_deleted,
+ :task_updated,
+ :campaign_rider_created,
+ :campaign_rider_deleted
+ ]
+
+ @impl Phoenix.LiveView
+ def handle_info({event, entity}, socket) when event in @broadcasted_infos do
+ if entity_in_campaigns?(socket, entity.campaign_id) do
+ {:noreply, refetch_and_assign_data(socket)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ ## -- End Delivery callbacks
+
+ defp apply_action(socket, :index, params) do
+ socket =
+ case params do
+ %{"current_week" => week} ->
+ week = Date.from_iso8601!(week)
+
+ assign(socket,
+ current_week: week,
+ campaigns: fetch_campaigns(week),
+ campaign_task_counts: Delivery.get_total_tasks_and_open_tasks(week)
+ )
+
+ _ ->
+ socket
+ end
+
+ socket
+ |> assign(:campaign, nil)
+ end
+
+ defp fetch_campaigns(current_week) do
+ Delivery.list_campaigns(current_week,
+ preload: [:program, :stats, :latest_message, :scheduled_message]
+ )
+ |> Enum.reverse()
+ |> Utils.ordered_group_by(&LocalizedDateTime.to_date(&1.delivery_start))
+ |> Enum.reverse()
+ end
+
+ defp refetch_and_assign_data(socket) do
+ week = socket.assigns.current_week
+
+ socket
+ |> assign(:campaign_task_counts, Delivery.get_total_tasks_and_open_tasks(week))
+ |> assign(:campaigns, fetch_campaigns(week))
+ end
+
+ defp campaign_is_in_past(campaign) do
+ date_now = DateTime.utc_now()
+
+ case DateTime.compare(campaign.delivery_end, date_now) do
+ :gt -> false
+ :eq -> false
+ :lt -> true
+ end
+ end
+
+ defp get_signup_text(campaign_id, rider_id, campaign_task_counts) do
+ count_tasks_for_current_rider =
+ campaign_task_counts[campaign_id].rider_ids
+ |> Enum.count(fn i -> i == Integer.to_string(rider_id) end)
+
+ cond do
+ count_tasks_for_current_rider > 0 ->
+ "Signed up for #{count_tasks_for_current_rider} deliveries"
+
+ true ->
+ "Sign up"
+ end
+ end
+
+ defp campaign_tasks_fully_assigned?(c_id, campaign_task_count) do
+ campaign_task_count[c_id][:filled_tasks] == campaign_task_count[c_id][:total_tasks]
+ end
+
+ # Use this to determine if we need to refetch data to update the liveview.
+ # ex: dispatcher changes riders/tasks, or another rider signs up -> refetch.
+ defp entity_in_campaigns?(socket, entity_campaign_id) do
+ socket.assigns.campaigns
+ |> Enum.flat_map(fn {_date, campaigns} -> campaigns end)
+ |> Enum.find(false, fn c -> c.id == entity_campaign_id end)
+ end
+end
diff --git a/lib/bike_brigade_web/live/campaign_signup_live/index.html.heex b/lib/bike_brigade_web/live/campaign_signup_live/index.html.heex
new file mode 100644
index 00000000..95ae60a2
--- /dev/null
+++ b/lib/bike_brigade_web/live/campaign_signup_live/index.html.heex
@@ -0,0 +1,113 @@
+
+<%= if @campaigns != [] do %>
+ <%= for {date, campaigns} <- @campaigns do %>
+
+
+ <.date date={date} />
+
+
+ <%= for c <- campaigns do %>
+ -
+
+
+
+
+ <%= cond do %>
+ <% campaign_is_in_past(c) -> %>
+ <.button
+ size={:xsmall}
+ color={:disabled}>
+ Completed
+
+
+ <% campaign_tasks_fully_assigned?(c.id, @campaign_task_counts) -> %>
+ <.button
+ size={:xsmall}
+ color={:primary}
+
+ navigate={~p"/campaigns/signup/#{c}/"}>
+ Campaign Filled
+
+
+ <% true -> %>
+ <.button
+ size={:xsmall}
+ color={:primary}
+ navigate={~p"/campaigns/signup/#{c}/"}>
+ <%= get_signup_text(c.id, @current_user.rider_id, @campaign_task_counts) %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+ <%= @campaign_task_counts[c.id][:filled_tasks] %> /
+ <%= @campaign_task_counts[c.id][:total_tasks] %> Tasks filled
+
+
+
+
+
+ Pickup time: <%= pickup_window(c) %>
+
+
+
+
+
+ <% end %>
+
+
+ <% end %>
+<% else %>
+ No campaigns found
+<% end %>
diff --git a/test/bike_brigade_web/live/campaign_signup_live_test.exs b/test/bike_brigade_web/live/campaign_signup_live_test.exs
new file mode 100644
index 00000000..ee474484
--- /dev/null
+++ b/test/bike_brigade_web/live/campaign_signup_live_test.exs
@@ -0,0 +1,107 @@
+defmodule BikeBrigadeWeb.CampaignSignupLiveTest do
+ use BikeBrigadeWeb.ConnCase, only: []
+
+ import Phoenix.LiveViewTest
+
+ @week_in_sec 604_900
+
+ describe "Index - General" do
+ setup ctx do
+ program = fixture(:program, %{name: "ACME Delivery"})
+ res = login_as_rider(ctx)
+ Map.merge(res, %{program: program})
+ end
+
+ test "It displays the expected number of campaigns for this week", ctx do
+ campaigns =
+ for _n <- 1..3 do
+ fixture(:campaign, %{program_id: ctx.program.id})
+ end
+
+ {:ok, live, _html} = live(ctx.conn, ~p"/campaigns/signup")
+
+ for c <- campaigns do
+ assert has_element?(live, "#campaign-#{c.id}")
+ end
+ end
+
+ test "It displays a campaign in a future week", ctx do
+ campaign =
+ fixture(:campaign, %{
+ program_id: ctx.program.id,
+ delivery_start: DateTime.utc_now() |> DateTime.add(@week_in_sec),
+ delivery_end:
+ DateTime.utc_now() |> DateTime.add(@week_in_sec) |> DateTime.add(60, :second)
+ })
+
+ {:ok, live, _html} = live(ctx.conn, ~p"/campaigns/signup")
+ refute has_element?(live, "#campaign-#{campaign.id}")
+
+ week_ahead = Date.utc_today() |> Date.add(7)
+ {:ok, live, _html} = live(ctx.conn, ~p"/campaigns/signup?current_week=#{week_ahead}")
+ assert has_element?(live, "#campaign-#{campaign.id}")
+ end
+
+ test "It displays a campaign in a previous week; button says 'Completed'", ctx do
+ campaign =
+ fixture(:campaign, %{
+ program_id: ctx.program.id,
+ delivery_start: DateTime.utc_now() |> DateTime.add(-@week_in_sec),
+ delivery_end:
+ DateTime.utc_now() |> DateTime.add(-@week_in_sec) |> DateTime.add(60, :second)
+ })
+
+ {:ok, live, _html} = live(ctx.conn, ~p"/campaigns/signup")
+ refute has_element?(live, "#campaign-#{campaign.id}")
+
+ week_ago = Date.utc_today() |> Date.add(-7)
+ {:ok, live, html} = live(ctx.conn, ~p"/campaigns/signup?current_week=#{week_ago}")
+ assert has_element?(live, "#campaign-#{campaign.id}")
+ assert html =~ "Completed"
+ end
+ end
+
+ describe "Index - Campaign shows correct signup button" do
+ setup ctx do
+ program = fixture(:program, %{name: "ACME Delivery"})
+ res = login_as_rider(ctx)
+ Map.merge(res, %{program: program})
+ end
+
+ test "A campaign shows the correct filled to total tasks", ctx do
+ campaign = fixture(:campaign, %{program_id: ctx.program.id})
+ rider_1 = fixture(:rider, %{name: "Hannah Bannana"})
+ _rider_2 = fixture(:rider, %{name: "Kiwi Stevie"})
+ fixture(:task, %{campaign: campaign, rider: rider_1})
+ fixture(:task, %{campaign: campaign, rider: nil})
+
+ {:ok, _live, html} = live(ctx.conn, ~p"/campaigns/signup")
+
+ # HACK to cleanup html with tons of whitespace.
+ # Could also just use Floki to find the element and test it's there.
+ normalized_html = html |> String.split() |> Enum.join(" ")
+ assert normalized_html =~ "1 / 2 Tasks filled"
+ end
+
+ test "'signup' when rider hasn't signed up and there are open tasks", ctx do
+ campaign = fixture(:campaign, %{program_id: ctx.program.id})
+ rider_1 = fixture(:rider, %{name: "Hannah Bannana"})
+ fixture(:rider, %{name: "Kiwi Stevie"})
+ fixture(:task, %{campaign: campaign, rider: rider_1})
+ fixture(:task, %{campaign: campaign, rider: nil})
+
+ {:ok, _live, html} = live(ctx.conn, ~p"/campaigns/signup")
+ assert html =~ "Sign up"
+ end
+
+ test "'signed up for N deliveries' if open deliveries and rider has at least one.", ctx do
+ campaign = fixture(:campaign, %{program_id: ctx.program.id})
+ fixture(:task, %{campaign: campaign, rider: ctx.rider})
+ fixture(:task, %{campaign: campaign, rider: ctx.rider})
+ fixture(:task, %{campaign: campaign, rider: nil})
+
+ {:ok, _live, html} = live(ctx.conn, ~p"/campaigns/signup")
+ assert html =~ "Signed up for 2 deliveries"
+ end
+ end
+end