Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 77 additions & 11 deletions lib/bike_brigade/riders/rider_search.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ defmodule BikeBrigade.Riders.RiderSearch do
preload: []
]

@weekdays %{
"monday" => 1,
"tuesday" => 2,
"wednesday" => 3,
"thursday" => 4,
"friday" => 5,
"saturday" => 6,
"sunday" => 7
}
@weekday_names Map.keys(@weekdays)
@timezone "America/Toronto"

# Thresholds for weekday filtering
# Applied to ALL weekday searches (solo and combined with time period)
@lookback_period_years 1
# Applied ONLY to solo weekday searches (min deliveries on a weekday)
@volume_threshold 3
# Applied ONLY to solo weekday searches (recent delivery requirement)
@recency_threshold_months 3

defmodule Filter do
@derive Jason.Encoder
defstruct [:type, :search, :id]
Expand Down Expand Up @@ -223,24 +243,24 @@ defmodule BikeBrigade.Riders.RiderSearch do

@spec filter_query(Ecto.Query.t(), list()) :: Ecto.Query.t()
defp filter_query(query, filters) do
Enum.reduce(filters, query, &apply_filter/2)
Enum.reduce(filters, query, fn filter, q -> apply_filter(filter, q, filters) end)
end

@spec apply_filter(Filter.t(), Ecto.Query.t()) :: Ecto.Query.t()
defp apply_filter(%Filter{type: :name, search: search}, query) do
@spec apply_filter(Filter.t(), Ecto.Query.t(), list()) :: Ecto.Query.t()
defp apply_filter(%Filter{type: :name, search: search}, query, _filters) do
query
|> where(
fragment("unaccent(?) ilike unaccent(?)", as(:rider).name, ^"#{search}%") or
fragment("unaccent(?) ilike unaccent(?)", as(:rider).name, ^"% #{search}%")
)
end

defp apply_filter(%Filter{type: :phone, search: search}, query) do
defp apply_filter(%Filter{type: :phone, search: search}, query, _filters) do
query
|> where(like(as(:rider).phone, ^"%#{search}%"))
end

defp apply_filter(%Filter{type: :name_or_phone, search: search}, query) do
defp apply_filter(%Filter{type: :name_or_phone, search: search}, query, _filters) do
query
|> where(
fragment("unaccent(?) ilike unaccent(?)", as(:rider).name, ^"#{search}%") or
Expand All @@ -249,37 +269,83 @@ defmodule BikeBrigade.Riders.RiderSearch do
)
end

defp apply_filter(%Filter{type: :program, id: id}, query) do
defp apply_filter(%Filter{type: :program, id: id}, query, _filters) do
query
|> join(:inner, [rider: r], rs in RiderStats,
on: rs.rider_id == r.id and rs.program_id == ^id
)
end

defp apply_filter(%Filter{type: :tag, search: tag}, query) do
defp apply_filter(%Filter{type: :tag, search: tag}, query, _filters) do
query
|> where(fragment("? = ANY(?)", ^tag, as(:tags).tags))
end

defp apply_filter(%Filter{type: :capacity, search: capacity}, query) do
defp apply_filter(%Filter{type: :capacity, search: capacity}, query, _filters) do
# TODO this may be easier with Ecto.Enum instead of EctoEnum
{:ok, capacity} = Rider.CapacityEnum.dump(capacity)

query
|> where(as(:rider).capacity == ^capacity)
end

defp apply_filter(%Filter{type: :active, search: "never"}, query) do
defp apply_filter(%Filter{type: :active, search: "never"}, query, _filters) do
query
|> where(is_nil(as(:latest_campaign).id))
end

defp apply_filter(%Filter{type: :active, search: "all_time"}, query) do
defp apply_filter(%Filter{type: :active, search: "all_time"}, query, _filters) do
query
|> where(not is_nil(as(:latest_campaign).id))
end

defp apply_filter(%Filter{type: :active, search: period}, query) do
defp apply_filter(%Filter{type: :active, search: weekday}, query, filters)
when weekday in @weekday_names do
day_number = @weekdays[weekday]

# Check if a time period filter exists (week, month, etc.)
has_period_filter =
Enum.any?(filters, fn f ->
f.type == :active and f.search in ["week", "month"]
end)

# Build base campaigns subquery (common structure)
# Lookback_period_years: applied to BOTH combined and solo filters
base_subquery =
from(c in BikeBrigade.Delivery.Campaign,
join: cr in "campaigns_riders",
on: cr.campaign_id == c.id,
where:
cr.rider_id == parent_as(:rider).id and
c.delivery_start > ago(@lookback_period_years, "year") and
fragment(
"EXTRACT(ISODOW FROM ? AT TIME ZONE ?) = ?",
c.delivery_start,
^@timezone,
^day_number
)
)

# Conditionally apply volume + recency thresholds (ONLY for solo weekday searches)
campaigns_subquery =
if has_period_filter do
base_subquery |> select(1)
else
base_subquery
|> group_by([c, cr], cr.rider_id)
|> having(
[c, cr],
count(c.id) >= ^@volume_threshold and
max(c.delivery_start) > ago(@recency_threshold_months, "month")
)
|> select(1)
end

query
|> where(exists(campaigns_subquery))
end

defp apply_filter(%Filter{type: :active, search: period}, query, _filters) do
query
|> where(as(:latest_campaign).delivery_start > ago(1, ^period))
end
Expand Down
2 changes: 1 addition & 1 deletion lib/bike_brigade_web/live/rider_live/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ defmodule BikeBrigadeWeb.RiderLive.Index do
end

defmodule Suggestions do
@actives ~w(hour day week month year all_time never)
@actives ~w(hour day week month year all_time never monday tuesday wednesday thursday friday saturday sunday)
|> Enum.map(&%Filter{type: :active, search: &1})
@capacities ~w(large medium small)
|> Enum.map(&%Filter{type: :capacity, search: &1})
Expand Down
147 changes: 147 additions & 0 deletions test/bike_brigade/riders/rider_search_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
defmodule BikeBrigade.Riders.RiderSearchTest do
use BikeBrigade.DataCase, async: true

alias BikeBrigade.Delivery
alias BikeBrigade.Riders.RiderSearch
alias BikeBrigade.Riders.RiderSearch.Filter

describe "monday + week combined filter" do
test "includes rider with 1 monday campaign in the current week" do
rider = fixture(:rider, %{name: "Rider"})
create_and_link_monday_campaign(rider.id)

{_rs, results} =
RiderSearch.new(
filters: [
%Filter{type: :active, search: "monday"},
%Filter{type: :active, search: "week"}
]
)
|> RiderSearch.fetch()

assert rider.id in Enum.map(results.page, & &1.id)
end

test "excludes rider with monday campaign outside the current week" do
rider = fixture(:rider, %{name: "Rider"})
create_and_link_monday_campaign(rider.id, weeks_ago: 2)

{_rs, results} =
RiderSearch.new(
filters: [
%Filter{type: :active, search: "monday"},
%Filter{type: :active, search: "week"}
]
)
|> RiderSearch.fetch()

refute rider.id in Enum.map(results.page, & &1.id)
end
end

describe "monday + month combined filter" do
test "includes rider with 1 monday campaign in the current month" do
rider = fixture(:rider, %{name: "Rider"})
create_and_link_monday_campaign(rider.id, weeks_ago: 2)

{_rs, results} =
RiderSearch.new(
filters: [
%Filter{type: :active, search: "monday"},
%Filter{type: :active, search: "month"}
]
)
|> RiderSearch.fetch()

assert rider.id in Enum.map(results.page, & &1.id)
end
end

describe "weekday filtering with thresholds" do
setup [:setup_riders_with_thresholds, :setup_campaigns_with_thresholds]

test "includes rider meeting all thresholds (volume, density, recency)", %{
rider_all_thresholds: rider
} do
{_rs, results} =
RiderSearch.new(filters: [%Filter{type: :active, search: "monday"}])
|> RiderSearch.fetch()

assert rider.id in Enum.map(results.page, & &1.id)
end

test "excludes rider failing volume threshold (< 3 deliveries)", %{
rider_low_volume: rider
} do
{_rs, results} =
RiderSearch.new(filters: [%Filter{type: :active, search: "monday"}])
|> RiderSearch.fetch()

refute rider.id in Enum.map(results.page, & &1.id)
end

test "excludes riders with no campaigns", %{rider_none: rider_none} do
{_rs, results} =
RiderSearch.new(filters: [%Filter{type: :active, search: "monday"}])
|> RiderSearch.fetch()

refute rider_none.id in Enum.map(results.page, & &1.id)
end
end

defp setup_riders_with_thresholds(_context) do
%{
rider_all_thresholds: fixture(:rider, %{name: "All Thresholds Rider"}),
rider_low_volume: fixture(:rider, %{name: "Low Volume Rider"}),
rider_none: fixture(:rider, %{name: "No Campaign Rider"})
}
end

defp setup_campaigns_with_thresholds(context) do
monday_campaigns =
for week_offset <- 0..5 do
monday_date = get_monday_date(Date.add(Date.utc_today(), -week_offset * 7))
create_campaign_for_date(monday_date)
end

# rider_all_thresholds: 3 campaigns (meets ≥3 threshold)
Enum.each(Enum.take(monday_campaigns, 3), fn campaign ->
link_rider_to_campaign(context.rider_all_thresholds.id, campaign.id)
end)

# rider_low_volume: 2 campaigns (fails <3 threshold)
Enum.each(Enum.take(monday_campaigns, 2), fn campaign ->
link_rider_to_campaign(context.rider_low_volume.id, campaign.id)
end)

%{monday_campaigns: monday_campaigns}
end

defp get_monday_date(date) do
days_since_monday = Date.day_of_week(date) - 1
Date.add(date, -days_since_monday)
end

defp create_campaign_for_date(date) do
datetime = DateTime.new!(date, ~T[12:00:00], "Etc/UTC")
fixture(:campaign, %{delivery_start: datetime})
end

defp link_rider_to_campaign(rider_id, campaign_id) do
Delivery.create_campaign_rider(%{
campaign_id: campaign_id,
rider_id: rider_id
})
end

defp create_and_link_monday_campaign(rider_id, opts \\ []) do
campaign =
opts
|> Keyword.get(:weeks_ago, 0)
|> then(&Date.add(Date.utc_today(), -&1 * 7))
|> get_monday_date()
|> create_campaign_for_date()

link_rider_to_campaign(rider_id, campaign.id)
end
end
48 changes: 48 additions & 0 deletions test/bike_brigade_web/live/rider_live/index/suggestions_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
defmodule BikeBrigadeWeb.RiderLive.Index.SuggestionsTest do
use ExUnit.Case, async: true

alias BikeBrigadeWeb.RiderLive.Index.Suggestions
alias BikeBrigade.Riders.RiderSearch.Filter

describe "weekday suggestions" do
test "typing 'active:mon' suggests 'monday' and 'month'" do
searches = suggestions_for("active:mon")

assert Enum.all?(~w(monday month), &(&1 in searches))
refute "tuesday" in searches
end

test "typing 'active:tue' suggests 'tuesday'" do
searches = suggestions_for("active:tue")

assert "tuesday" in searches
assert length(searches) == 1
end

test "all weekdays are available in suggestions" do
searches = suggestions_for("active:")

assert Enum.all?(
~w(monday tuesday wednesday thursday friday saturday sunday),
&(&1 in searches)
)
end

test "weekday suggestions have correct Filter type" do
suggestions = Suggestions.suggest(%Suggestions{}, "active:monday")

assert [%Filter{type: :active, search: "monday"}] = suggestions.active
end

test "time period suggestions still work" do
searches = suggestions_for("active:hour")

assert "hour" in searches
end
end

defp suggestions_for(input) do
suggestions = Suggestions.suggest(%Suggestions{}, input)
Enum.map(suggestions.active, & &1.search)
end
end
Loading