diff --git a/assets/js/react/components/FilterModal.tsx b/assets/js/react/components/FilterModal.tsx index 3c68019..3c133fb 100644 --- a/assets/js/react/components/FilterModal.tsx +++ b/assets/js/react/components/FilterModal.tsx @@ -12,8 +12,8 @@ import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage" import { Slider } from "./Slider" import { - FIDE_RATING_MIN, - FIDE_RATING_MAX, + FIDE_RATING_MIN as RATING_MIN, + FIDE_RATING_MAX as RATING_MAX, SearchParams, } from "../types/SearchParams" @@ -55,8 +55,18 @@ export function FilterModal({ }: FilterModalProps) { const idPrefix = React.useId() - const { watch, reset, control, register, setValue, handleSubmit } = - useForm({ defaultValues }) + const { + watch, + reset, + control, + register, + setValue, + handleSubmit, + formState: { errors }, + } = useForm({ + mode: "onChange", + defaultValues, + }) // Default values are processed immediately despite the modal not being open // at the start. Furthermore, values are preserved after closing and @@ -80,7 +90,9 @@ export function FilterModal({ } const registerRating = register("rating") - const registerModes = register("modes") + const registerModes = register("modes", { + required: "Please select at least one mode.", + }) return ( + ), @@ -119,7 +135,7 @@ export function FilterModal({ - +

Find coaches that have a rating within the specified range. Keep in mind, a higher rating does not necessarily mean a better coach{" "} @@ -141,14 +157,11 @@ export function FilterModal({ setValue("rating.1", newValue[1]) }} step={10} - min={FIDE_RATING_MIN} - max={FIDE_RATING_MAX} - marks={computeStepLabels( - FIDE_RATING_MIN, - FIDE_RATING_MAX, - 7, - 50 - ).map((s) => ({ value: s, label: `${s}` }))} + min={RATING_MIN} + max={RATING_MAX} + marks={computeStepLabels(RATING_MIN, RATING_MAX, 7, 50).map( + (s) => ({ value: s, label: `${s}` }) + )} /> )} /> @@ -169,12 +182,13 @@ export function FilterModal({ -

-

+

+ +

Prefer a specific game mode? We{"'"}ll prioritize coaches that specialize in the modes selected.

-
+
{(Object.keys(Mode) as Mode[]).map((m) => (
diff --git a/assets/js/react/types/SearchParams.ts b/assets/js/react/types/SearchParams.ts index e8cd7fc..6323703 100644 --- a/assets/js/react/types/SearchParams.ts +++ b/assets/js/react/types/SearchParams.ts @@ -14,3 +14,18 @@ export const defaultSearchParams: SearchParams = { modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET], languages: [], } + +export function toQueryParams(p: SearchParams) { + const queryParams: { [key: string]: any } = {} + + for (const mode of p.modes) { + queryParams[`${mode.toLowerCase()}_gte`] = p.rating[0] + queryParams[`${mode.toLowerCase()}_lte`] = p.rating[1] + } + + if (p.languages.length > 0) { + queryParams["languages"] = p.languages.join(",") + } + + return queryParams +} diff --git a/assets/js/react/utils/queries.ts b/assets/js/react/utils/queries.ts index 33a7ccf..4e0ddb0 100644 --- a/assets/js/react/utils/queries.ts +++ b/assets/js/react/utils/queries.ts @@ -3,27 +3,30 @@ import { useQuery, useInfiniteQuery } from "@tanstack/react-query" import type { Coach } from "../types/Coach" import type { Language } from "../types/Language" -import type { SearchParams } from "../types/SearchParams" +import { type SearchParams, toQueryParams } from "../types/SearchParams" export const useCoachesInfiniteQuery = (searchParams: SearchParams) => { + const queryParams = toQueryParams(searchParams) + return useInfiniteQuery({ - queryKey: ["coaches", searchParams], + queryKey: ["coaches", queryParams], queryFn: async ({ pageParam = 1 }) => { const response = await axios.get<{ data: Coach[] }>("/api/coaches/", { params: { page_no: pageParam, page_size: 15, + ...queryParams, }, }) return response.data.data }, - initialPageParam: 1, - getNextPageParam: (lastPage, _pages, lastPageParam) => { + getNextPageParam: (lastPage, _allPages, lastPageParam) => { if (lastPage.length === 0) { return undefined } return lastPageParam + 1 }, + initialPageParam: 1, }) } diff --git a/lib/boardwise/coaches.ex b/lib/boardwise/coaches.ex index cf019b5..85d4b3c 100644 --- a/lib/boardwise/coaches.ex +++ b/lib/boardwise/coaches.ex @@ -7,24 +7,71 @@ defmodule BoardWise.Coaches do alias BoardWise.Repo alias BoardWise.Coaches.Coach + alias BoardWise.Coaches.QueryParams @prefix "coach_scraper" + defmacrop rating_fragment(field, gte, lte) do + quote do + fragment( + """ + CASE + WHEN ? IS NULL THEN 0 + WHEN ? IS NULL THEN 0 + WHEN ? >= ? AND ? <= ? THEN 5 + ELSE 0 + END + """, + type(unquote(gte), :integer), + type(unquote(lte), :integer), + unquote(field), + type(unquote(gte), :integer), + unquote(field), + type(unquote(lte), :integer) + ) + end + end + @doc """ - Return the list of coaches at the given page, based on page size. + Return the list of coaches according to the specified params. ## Examples - iex> page_coaches(1, 25) + iex> list_coaches(%QueryParams{...}) [%Coach{}, ...] """ - def page_coaches(page_no, page_size) do - offset = (page_no - 1) * page_size - + def list_coaches(%QueryParams{ + :rapid_gte => rapid_gte, + :rapid_lte => rapid_lte, + :blitz_gte => blitz_gte, + :blitz_lte => blitz_lte, + :bullet_gte => bullet_gte, + :bullet_lte => bullet_lte, + :languages => languages, + :page_no => page_no, + :page_size => page_size + }) do Coach + |> select([c], c) + |> select_merge( + [c], + %{ + score: + fragment( + """ + ? + ? + ? + """, + rating_fragment(c.rapid, ^rapid_gte, ^rapid_lte), + rating_fragment(c.blitz, ^blitz_gte, ^blitz_lte), + rating_fragment(c.bullet, ^bullet_gte, ^bullet_lte) + ) + |> selected_as(:score) + } + ) + |> order_by(desc: selected_as(:score)) |> limit(^page_size) - |> offset(^offset) + |> offset(^((page_no - 1) * page_size)) |> Repo.all(prefix: @prefix) end diff --git a/lib/boardwise/coaches/coach.ex b/lib/boardwise/coaches/coach.ex index f145f8f..0b28c5c 100644 --- a/lib/boardwise/coaches/coach.ex +++ b/lib/boardwise/coaches/coach.ex @@ -15,14 +15,20 @@ defmodule BoardWise.Coaches.Coach do import Ecto.Changeset schema "export" do + # required fields field :site, :string field :username, :string + + # optional fields field :name, :string field :image_url, :string field :languages, {:array, :string} field :blitz, :integer field :bullet, :integer field :rapid, :integer + + # virtual fields + field :score, :integer, virtual: true end @doc false diff --git a/lib/boardwise/coaches/query_params.ex b/lib/boardwise/coaches/query_params.ex new file mode 100644 index 0000000..5a277cf --- /dev/null +++ b/lib/boardwise/coaches/query_params.ex @@ -0,0 +1,13 @@ +defmodule BoardWise.Coaches.QueryParams do + defstruct [ + :rapid_gte, + :rapid_lte, + :blitz_gte, + :blitz_lte, + :bullet_gte, + :bullet_lte, + :languages, + page_no: 1, + page_size: 15 + ] +end diff --git a/lib/boardwise_web/controllers/coach_controller.ex b/lib/boardwise_web/controllers/coach_controller.ex index b361c8f..d2697d4 100644 --- a/lib/boardwise_web/controllers/coach_controller.ex +++ b/lib/boardwise_web/controllers/coach_controller.ex @@ -1,28 +1,42 @@ defmodule BoardWiseWeb.CoachController do use BoardWiseWeb, :controller - require Logger alias BoardWise.Coaches - - plug :fetch_query_params + alias BoardWise.Coaches.QueryParams def index(conn, params) do - page_no = get_integer_param(params, "page_no", 1) - page_size = get_integer_param(params, "page_size", 10) - coaches = Coaches.page_coaches(page_no, page_size) + query_params = + %QueryParams{} + |> override_param(:rapid_gte, params, :integer) + |> override_param(:rapid_lte, params, :integer) + |> override_param(:blitz_gte, params, :integer) + |> override_param(:blitz_lte, params, :integer) + |> override_param(:bullet_gte, params, :integer) + |> override_param(:bullet_lte, params, :integer) + |> override_param(:languages, params, :strlist) + |> override_param(:page_no, params, :integer) + |> override_param(:page_size, params, :integer) + + # Ensure we never attempt to query too large of a response all at once. + query_params = %{query_params | page_size: Enum.min([query_params.page_size, 25])} + + coaches = Coaches.list_coaches(query_params) render(conn, :index, coaches: coaches) end - defp get_integer_param(params, key, default) do - val = Map.get(params, key) + defp override_param(query_params, key, params, type) do + case Map.get(params, Atom.to_string(key)) do + nil -> + query_params - if is_nil(val) do - default - else - case Integer.parse(val) do - {parsed, ""} -> parsed - _ -> default - end + val when type == :strlist -> + %{query_params | key => String.split(val, ",")} + + val when type == :integer -> + case Integer.parse(val) do + {parsed, ""} -> %{query_params | key => parsed} + _ -> query_params + end end end end