Skip to content

Commit

Permalink
Add: Rider Home Page (#303)
Browse files Browse the repository at this point in the history
* Start: new home page

* Compressed itinerary into a more mobile friendly row

* Tidy UI and show link to campaigns

* Add empty state when rider has no campaigns for today

* Pair with Max on new home stats query

* Add: bike brigade week stat to rider home page.

* Copy change

* Add personalization to home page / change wording.

* Add: show urgent delivery callout

* Add: home page stats, CTA, and campaign_id list

* fmt

* Make urgent signup look more urgent

* Add "Home" to rider sidebar, remove itinerary

* Test: Home page.

* Fmt + clean up image on desktop

* Default to home page when logging in

* Differentiat campaign list from urgent campaign list

* Make suggested changes

* Add: tests for suggested changes.

* Simplify setup block

* Fix: make requested changes.

- Fix: reset showing_urgent_campaigns to false in handle params
- Fix: Dial back red on "available campaigns" in campaign signup index
- Add button to urgent campaign list to take rider back to full campaign list
- Use better string list joining func (see Util.join)

* Use ecto where rather than Enum.filter

* Remove random bg-red-400 🙃

* Remove unecessary filter

---------

Co-authored-by: Max Veytsman <[email protected]>
  • Loading branch information
teesloane and mveytsman authored Mar 30, 2024
1 parent d58e784 commit 1aeb7e0
Show file tree
Hide file tree
Showing 11 changed files with 560 additions and 22 deletions.
32 changes: 31 additions & 1 deletion lib/bike_brigade/delivery.ex
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ defmodule BikeBrigade.Delivery do

def list_campaigns(week \\ nil, opts \\ []) do
preload = Keyword.get(opts, :preload, [:program])
# sometimes we just want to fetch campaigns with specific ids
# (such as when we need to display campaigns that urgently need a rider)
campaign_ids = Keyword.get(opts, :campaign_ids, nil)

query =
from c in Campaign,
Expand All @@ -164,10 +167,38 @@ defmodule BikeBrigade.Delivery do
query
end

query = if campaign_ids do
query
|> where([campaign: c], c.id in ^campaign_ids)
else
query
end

Repo.all(query)
|> Repo.preload(preload)
end


@doc """
Gets campaigns that are happening today and have unassigned tasks.
Used on the rider's home page to let riders know about campaigns that could use the help.
"""
def list_urgent_campaigns() do
today = LocalizedDateTime.today()
start_of_today = LocalizedDateTime.new!(today, ~T[00:00:00])
end_of_tomorrow = Date.add(today, 1) |> LocalizedDateTime.new!(~T[23:59:59])

query =
from c in Campaign,
distinct: [asc: c.id],
join: t in assoc(c, :tasks),
where: c.delivery_start <= ^end_of_tomorrow and c.delivery_start >= ^start_of_today and is_nil(t.assigned_rider_id),
select: c

Repo.all(query)
|> Repo.preload([:program, :tasks])
end

@doc """
Fetches how many open vs filled tasks there are (optionally, by week)
and groups them by campaign ID.
Expand Down Expand Up @@ -222,7 +253,6 @@ defmodule BikeBrigade.Delivery do
CampaignRider |> Repo.get_by(campaign_id: campaign_id, rider_id: rider_id)
end


def get_campaign_rider!(token) do
query =
from cr in CampaignRider,
Expand Down
27 changes: 23 additions & 4 deletions lib/bike_brigade/stats.ex
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ defmodule BikeBrigade.Stats do
)
when sort_by in [:rider_name, :campaigns, :deliveries, :distance] and
sort_order in [:desc, :asc] do
leaderboard_aggregates()
leaderboard_subquery()
|> make_leaderboard(sort_by, sort_order)
|> Repo.all()
end
Expand All @@ -168,7 +168,7 @@ defmodule BikeBrigade.Stats do
)
when sort_by in [:rider_name, :campaigns, :deliveries, :distance] and
sort_order in [:desc, :asc] do
leaderboard_aggregates(start_date, end_date)
leaderboard_subquery(start_date, end_date)
|> make_leaderboard(sort_by, sort_order)
|> Repo.all()
end
Expand All @@ -189,12 +189,12 @@ defmodule BikeBrigade.Stats do
distance: st_distance(pl.coords, dl.coords)
}

defp leaderboard_aggregates() do
defp leaderboard_subquery() do
from r in @base_query,
where: as(:campaign).delivery_start <= ^LocalizedDateTime.now()
end

defp leaderboard_aggregates(%Date{} = start_date, %Date{} = end_date) do
defp leaderboard_subquery(%Date{} = start_date, %Date{} = end_date) do
from r in @base_query,
where:
as(:campaign).delivery_start >=
Expand Down Expand Up @@ -227,4 +227,23 @@ defmodule BikeBrigade.Stats do
distance: sum(a.distance)
}
end

@doc """
Stat data for the rider home.
"""
def home_stats() do
today = LocalizedDateTime.today()
yesterday = Date.add(today, -1)
week_ago = Date.add(yesterday, -7)

Repo.one(
from r in subquery(leaderboard_subquery(week_ago, yesterday)),
select: %{
riders: count(r.rider_id, :distinct),
tasks: count(r.task_id, :distinct),
campaigns: count(r.campaign_id, :distinct),
distance: sum(r.distance)
}
)
end
end
10 changes: 10 additions & 0 deletions lib/bike_brigade/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,14 @@ defmodule BikeBrigade.Utils do
ordered_group_by(rest, [{key, [head]} | [{last_key, Enum.reverse(group)} | groups]], fun)
end
end

@doc """
Formats a list of entities like [1, 2, 3] -> "1, 2 and 3"
"""
def join([]), do: ""
def join([a]), do: to_string(a)
def join([a, b]), do: "#{a} and #{b}"
def join(list), do: join(list, [])
def join([last], strl), do: to_string([strl, 'and ', to_string(last)])
def join([h | t], strl), do: join(t, [strl, to_string(h), ', '])
end
5 changes: 3 additions & 2 deletions lib/bike_brigade_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ defmodule BikeBrigadeWeb.Layouts do

def rider_links() do
[
%{name: "Campaigns", link: ~p"/campaigns/signup", icon: :inbox, current_page: :campaigns_signup},
%{name: "Home", link: ~p"/home", icon: :home, current_page: :home},
%{name: "Delivery Signup", link: ~p"/campaigns/signup", icon: :inbox, current_page: :campaigns_signup},
%{name: "Itinerary", link: ~p"/itinerary", icon: :calendar_days, current_page: :itinerary},
%{name: "Leaderboard", link: ~p"/leaderboard", icon: :trophy, current_page: :leaderboard}
]
Expand Down Expand Up @@ -118,7 +119,7 @@ defmodule BikeBrigadeWeb.Layouts do
def rider_links(assigns) do
~H"""
<div :for={link <- rider_links()}>
<.sidebar_link class="bg-red-400" selected={@current_page == link.current_page} navigate={link.link}>
<.sidebar_link selected={@current_page == link.current_page} navigate={link.link}>
<:icon>
<BikeBrigadeWeb.Components.Icons.dynamic_icon name={link.icon} />
</:icon>
Expand Down
38 changes: 29 additions & 9 deletions lib/bike_brigade_web/live/campaign_signup_live/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,28 @@ defmodule BikeBrigadeWeb.CampaignSignupLive.Index do
{:ok,
socket
|> assign(:page, :campaigns_signup)
|> assign(:page_title, "Campaign Signup List")
|> assign(:page_title, "Delivery Sign Up")
|> assign(:current_week, current_week)
|> assign(:campaign_task_counts, Delivery.get_total_tasks_and_open_tasks(current_week))
|> assign(:showing_urgent_campaigns, false)
|> assign(:campaigns, campaigns)}
end

@impl true
def handle_params(%{"campaign_ids" => campaign_ids}, _url, socket) do
campaigns = fetch_campaigns(socket.assigns.current_week, campaign_ids: campaign_ids)

{:noreply,
socket
|> assign(:campaigns, campaigns)
|> assign(:showing_urgent_campaigns, true)}
end

def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
{:noreply,
socket
|> assign(:showing_urgent_campaigns, false)
|> apply_action(socket.assigns.live_action, params)}
end

# -- Delivery callbacks
Expand Down Expand Up @@ -74,9 +87,10 @@ defmodule BikeBrigadeWeb.CampaignSignupLive.Index do
|> assign(:campaign, nil)
end

defp fetch_campaigns(current_week) do
defp fetch_campaigns(current_week, opts \\ []) do
Delivery.list_campaigns(current_week,
preload: [:program, :stats, :latest_message, :scheduled_message]
preload: [:program, :stats, :latest_message, :scheduled_message],
campaign_ids: opts[:campaign_ids]
)
|> Enum.reverse()
|> Utils.ordered_group_by(&LocalizedDateTime.to_date(&1.delivery_start))
Expand Down Expand Up @@ -107,20 +121,26 @@ defmodule BikeBrigadeWeb.CampaignSignupLive.Index do
attr :campaign, :any, required: true

defp tasks_filled_text(assigns) do
copy =
{class, copy} =
if assigns.filled_tasks == nil do
"N/A"
{"", "N/A"}
else
"#{assigns.total_tasks - assigns.filled_tasks} Available"
case assigns.total_tasks - assigns.filled_tasks do
0 -> {"text-gray-600", "Fully Assigned"}
_ -> {"text-red-400", "#{assigns.total_tasks - assigns.filled_tasks} Available"}
end
end

assigns = assign(assigns, :copy, copy)
assigns =
assigns
|> assign(:class, class)
|> assign(:copy, copy)

~H"""
<p class="flex flex-col md:flex-row items-center mt-0 text-sm text-gray-700">
<Icons.maki_bicycle_share class="flex-shrink-0 mb-2 mr-1.5 h-8 w-8 md:h-5 md:w-5 md:mb-0 text-gray-500" />
<span class="flex space-x-2 font-bold md:font-normal">
<span><%= @copy %></span>
<span class={@class}><%= @copy %></span>
</span>
</p>
"""
Expand Down
28 changes: 23 additions & 5 deletions lib/bike_brigade_web/live/campaign_signup_live/index.html.heex
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<nav
:if={@showing_urgent_campaigns == false}
class="flex flex-col md:flex-row md:items-center justify-between md:px-4 md:py-3 md:mb-4 md:border-b-2 md:border-gray-200 md:justify-end"
aria-label="Pagination"
>
Expand All @@ -16,8 +17,7 @@
}
class="w-full text-center relative items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
>

Week of <%= Calendar.strftime(@current_week, "%B %-d") %>
Week of <%= Calendar.strftime(@current_week, "%B %-d") %>
</.link>

<.link
Expand All @@ -30,7 +30,23 @@
</div>
</nav>

<%!-- Nav only for urgent list of campaigns --%>
<nav :if={@showing_urgent_campaigns}>
<.button
size={:small}
class="w-full rounded-none md:rounded-sm mb-2"
color={:secondary}
navigate={~p"/campaigns/signup"}
>
View all available deliveries ⏎
</.button>
</nav>

<%= if @campaigns != [] do %>
<div :if={@showing_urgent_campaigns} class="bg-red-300 p-2 rounded bg-opacity-40">
These deliveries need riders in the next 48 hours:
</div>

<%= for {date, campaigns} <- @campaigns do %>
<div class="flex flex-col lg:flex-row w-full sm:my-1 sm:rounded-md mb-4 md:mb-0 lg:mb-8">
<div class="flex w-full lg:w-40 justify-center py-4 my-4 lg:py-0 lg:my-0">
Expand All @@ -43,9 +59,9 @@
</div>
</div>

<ul role="list" class="w-full divide-y divide-gray-200">
<ul id="campaign-list" role="list" class="w-full divide-y divide-gray-200">
<%= for c <- campaigns do %>
<li id={"campaign-#{c.id}"} class="bg-white shadow mb-8 border last:mb-0">
<li id={"campaign-#{c.id}"} class="campaign-item bg-white shadow mb-8 border last:mb-0">
<div class="flex items-center space-x-1 px-4 py-2 bg-slate-100">
<div
class="flex items-center justify-between w-full text-sm"
Expand Down Expand Up @@ -98,5 +114,7 @@
</div>
<% end %>
<% else %>
<div class="flex items-center justify-center h-96 w-4/5 mx-auto text-center">No campaigns set up yet for the week of <%= Calendar.strftime(@current_week, "%B %-d") %>.</div>
<div class="flex items-center justify-center h-96 w-4/5 mx-auto text-center">
No campaigns set up yet for the week of <%= Calendar.strftime(@current_week, "%B %-d") %>.
</div>
<% end %>
92 changes: 92 additions & 0 deletions lib/bike_brigade_web/live/rider_home_live/index.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
defmodule BikeBrigadeWeb.RiderHomeLive.Index do
use BikeBrigadeWeb, :live_view

alias BikeBrigade.{Delivery, Riders, Stats, LocalizedDateTime}

import BikeBrigadeWeb.CampaignHelpers

alias BikeBrigade.Utils

@impl true
def mount(_params, _session, socket) do
today = LocalizedDateTime.today()
rider_id = socket.assigns.current_user.rider_id

{:ok,
socket
|> assign(:page, :home)
|> assign(:page_title, "Home")
|> assign(:stats, Stats.home_stats())
|> assign(:rider, Riders.get_rider!(rider_id))
|> assign(:urgent_campaigns, Delivery.list_urgent_campaigns())
|> load_itinerary(today)}
end

defp load_itinerary(socket, date) do
rider_id = socket.assigns.current_user.rider_id

if rider_id do
socket
|> assign(
:campaign_riders,
Riders.get_itinerary(rider_id, date)
)
else
socket
|> assign(:campaign_riders, [])
|> put_flash(:error, "User is not associated with a rider!")
end
|> assign(:date, date)
end

defp get_location(assigns) do
~H"""
<div class="sm:flex sm:justify-between">
<p class="flex items-center">
<Heroicons.map_pin aria-label="Location" class="flex-shrink-0 w-4 h-4 mr-1 text-gray-400" />
<%= @campaign.location.address %>
</p>
</div>
"""
end

defp get_pickup_window(assigns) do
~H"""
<div class="sm:flex sm:justify-between">
<p class="flex items-center">
<Heroicons.clock aria-label="Pickup Time" class="flex-shrink-0 w-4 h-4 mr-1 text-gray-400" />
<%= pickup_window(@campaign) %>
</p>
</div>
"""
end

# Note Utils has a `humanized_task_count/1` which is similar but breaks
# things down by delivery type
defp delivery_count(tasks) do
task_count = Utils.task_count(tasks)
"#{task_count} #{Inflex.inflect("delivery", task_count)}"
end

defp has_urgent_campaigns?(urgent_campaigns) do
Enum.count(urgent_campaigns) > 0
end

defp num_unassigned_tasks_and_campaigns(urgent_campaigns) do
# formats a string so that we see: "program 1, program 2, and program 3" (ie, we want that 'and') in there.
campaign_ids = urgent_campaigns |> Enum.map(& &1.id)

campaign_names =
urgent_campaigns
|> Enum.map(& &1.program.name)
|> Enum.uniq()
|> Utils.join()

deliveries_without_riders =
urgent_campaigns
|> Enum.flat_map(& &1.tasks)
|> Enum.count(fn task -> task.assigned_rider_id == nil end)

{deliveries_without_riders, campaign_names, campaign_ids}
end
end
Loading

0 comments on commit 1aeb7e0

Please sign in to comment.