Skip to content

Commit 8127446

Browse files
authored
Merge pull request #152 from denis1011101/fix/court-picker-country-city
feat: pick a court by country and city in the game form
2 parents 4f9f759 + 58d57ff commit 8127446

9 files changed

Lines changed: 266 additions & 10 deletions

File tree

app/controllers/concerns/location_filters.rb

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,25 +130,40 @@ module LocationFilters
130130
"ZW" => "Zimbabwe"
131131
}.freeze
132132

133+
# Геокодер иногда отдаёт район города или название, которого нет в справочнике
134+
# City, — тогда страну для фильтров подставляем вручную.
133135
CITY_COUNTRY_OVERRIDES = {
134-
"Koto" => "JP"
136+
"Acapulco" => "MX",
137+
"Chaoyang District" => "CN",
138+
"Futian District" => "CN",
139+
"Greater London" => "GB",
140+
"Hua Hin City Municipality" => "TH",
141+
"Jiang'an District" => "CN",
142+
"Klagenfurt" => "AT",
143+
"Koto" => "JP",
144+
"Montreal" => "CA",
145+
"New York" => "US",
146+
"Pak Kret City Municipality" => "TH",
147+
"Palilula Urban Municipality" => "RS",
148+
"Pudong" => "CN",
149+
"Yuexiu District" => "CN"
135150
}.freeze
136151

137152
CITY_ALIASES = {
138153
"yekaterinburg" => "ekaterinburg"
139154
}.freeze
140155

141156
included do
142-
helper_method :country_name_for, :location_filter_labels, :country_cities_map_for_select
157+
helper_method :country_name_for, :location_filter_labels, :country_cities_map_for_select,
158+
:city_country_map_for, :normalized_city
143159
end
144160

145161
private
146162

147163
def prepare_location_filters(city_names)
148164
city_names = Array(city_names).map(&:to_s).reject(&:blank?).uniq.sort
149165

150-
@city_country_map = City.country_codes_for(city_names)
151-
.merge(CITY_COUNTRY_OVERRIDES.slice(*city_names))
166+
@city_country_map = city_country_map_for(city_names)
152167

153168
@country_names_by_code = @city_country_map.values.uniq.compact.sort.each_with_object({}) do |country_code, names|
154169
names[country_code] = country_name_for(country_code)
@@ -157,6 +172,15 @@ def prepare_location_filters(city_names)
157172
@cities = params[:country].present? ? cities_for_country(params[:country]) : city_names
158173
end
159174

175+
# Города-тёзки живут в разных странах, поэтому берём самый населённый (City),
176+
# а районы и альтернативные написания добираем из CITY_COUNTRY_OVERRIDES.
177+
def city_country_map_for(city_names)
178+
city_names = Array(city_names).map(&:to_s).reject(&:blank?).uniq
179+
return {} if city_names.empty?
180+
181+
City.country_codes_for(city_names).merge(CITY_COUNTRY_OVERRIDES.slice(*city_names))
182+
end
183+
160184
def cities_for_country(country_code)
161185
@city_country_map.select { |_, code| code == country_code }.keys.sort
162186
end

app/helpers/games_helper.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,31 @@ def format_duration_minutes(minutes)
1414
parts << "#{rest}m" if rest > 0
1515
parts.join(" ")
1616
end
17+
18+
# Мини-фильтр «страна → город» над списком кортов в форме игры: справочник
19+
# стран, города каждой страны и город/страна каждого корта для JS.
20+
def court_picker_locations(courts)
21+
city_names = courts.filter_map { |court| court.city_name.presence }.uniq.sort
22+
city_country = city_country_map_for(city_names)
23+
24+
cities_by_country = city_names.group_by { |name| city_country[name].to_s }
25+
countries = cities_by_country.keys.reject(&:blank?)
26+
.map { |code| [ country_name_for(code), code ] }.sort_by(&:first)
27+
28+
{
29+
countries: countries,
30+
# Пустой ключ — «любая страна»: города без страны видны только там.
31+
cities_by_country: cities_by_country.merge("" => city_names),
32+
city_country: city_country
33+
}
34+
end
35+
36+
# Город пользователя записан свободным текстом ("Ekaterinburg" против
37+
# "Yekaterinburg" у кортов), поэтому сравниваем нормализованные названия.
38+
def default_court_city(city_names, user)
39+
target = normalized_city(user&.city_name)
40+
return nil if target.blank?
41+
42+
city_names.find { |name| normalized_city(name) == target }
43+
end
1744
end

app/javascript/controllers/court_picker_controller.js

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Controller } from "@hotwired/stimulus"
22

33
export default class extends Controller {
4-
static targets = ["map", "select", "surfaceSelect", "environmentSelect"]
4+
static targets = ["map", "select", "surfaceSelect", "environmentSelect", "countrySelect", "citySelect"]
55
static values = {
66
courts: Array,
7+
countryCities: Object,
8+
cityPlaceholder: String,
79
surfaceLabels: Object,
810
environmentLabels: Object,
911
surfaceSelected: String,
@@ -14,6 +16,7 @@ export default class extends Controller {
1416
}
1517

1618
connect() {
19+
this.filterCourts()
1720
this.initMap()
1821
this.updateSurfaceOptions({ initial: true })
1922
}
@@ -48,14 +51,50 @@ export default class extends Controller {
4851
this.markersById.set(String(c.id), marker)
4952
})
5053

54+
this._syncMarkers()
5155
if (this.selectTarget.value) this._focusSelected()
5256
}
5357

58+
// Смена страны или города: пересобираем города под страну и список кортов.
59+
locationChanged(event) {
60+
if (this.hasCountrySelectTarget && event.target === this.countrySelectTarget) this._syncCityOptions()
61+
this.filterCourts()
62+
}
63+
5464
selectChanged() {
5565
this._focusSelected()
5666
this.updateSurfaceOptions()
5767
}
5868

69+
// Оставляет в списке корты выбранной страны/города, группируя их по городам.
70+
// Если выбранный корт отфильтровался, берём первый доступный.
71+
filterCourts() {
72+
const country = this.hasCountrySelectTarget ? this.countrySelectTarget.value : ""
73+
const city = this.hasCitySelectTarget ? this.citySelectTarget.value : ""
74+
const previous = this.selectTarget.value
75+
const courts = (this.courtsValue || []).filter(c => this._matchesLocation(c, country, city))
76+
const groups = this._groupByCity(courts)
77+
// Города разделяем заголовками только когда их несколько — иначе это лишний шум.
78+
const grouped = groups.length > 1
79+
80+
this.selectTarget.innerHTML = ""
81+
groups.forEach(([cityName, cityCourts]) => {
82+
const parent = grouped && cityName ? this._appendGroup(cityName) : this.selectTarget
83+
cityCourts.forEach(c => parent.appendChild(this._buildOption(String(c.id), c.name)))
84+
})
85+
86+
// Порядок опций задают группы, поэтому запасной вариант берём из самого списка.
87+
const stillListed = courts.some(c => String(c.id) === String(previous))
88+
if (stillListed) this.selectTarget.value = previous
89+
else this.selectTarget.selectedIndex = 0
90+
91+
this._syncMarkers()
92+
if (!stillListed) {
93+
this.updateSurfaceOptions()
94+
this._focusSelected()
95+
}
96+
}
97+
5998
// Заполняет селекты покрытия и среды вариантами выбранного корта.
6099
// preselect (сохранённое значение игры) применяется только при initial connect;
61100
// после смены корта пользователем ориентируемся только на текущее значение селекта.
@@ -81,6 +120,59 @@ export default class extends Controller {
81120
}
82121
}
83122

123+
_matchesLocation(court, country, city) {
124+
if (city) return court.city === city
125+
if (country) return court.country === country
126+
return true
127+
}
128+
129+
_groupByCity(courts) {
130+
const groups = new Map()
131+
courts.forEach(c => {
132+
const key = c.city || ""
133+
if (!groups.has(key)) groups.set(key, [])
134+
groups.get(key).push(c)
135+
})
136+
137+
return [...groups.entries()]
138+
.sort((a, b) => a[0].localeCompare(b[0]))
139+
.map(([cityName, cityCourts]) => [cityName, cityCourts.sort((a, b) => a.name.localeCompare(b.name))])
140+
}
141+
142+
_appendGroup(label) {
143+
const group = document.createElement("optgroup")
144+
group.label = label
145+
this.selectTarget.appendChild(group)
146+
return group
147+
}
148+
149+
_syncCityOptions() {
150+
if (!this.hasCitySelectTarget) return
151+
152+
const selectedCity = this.citySelectTarget.value
153+
const cities = (this.countryCitiesValue || {})[this.countrySelectTarget.value] || []
154+
155+
this.citySelectTarget.innerHTML = ""
156+
this.citySelectTarget.appendChild(this._buildOption("", this.cityPlaceholderValue))
157+
cities.forEach(city => this.citySelectTarget.appendChild(this._buildOption(city, city)))
158+
this.citySelectTarget.value = cities.includes(selectedCity) ? selectedCity : ""
159+
}
160+
161+
// Маркеры отфильтрованных кортов убираем с карты, чтобы она совпадала со списком.
162+
_syncMarkers() {
163+
if (!this.markersById || !this.map) return
164+
165+
const listed = new Set([...this.selectTarget.options].map(option => option.value))
166+
this.markersById.forEach((marker, id) => marker.setMap(listed.has(id) ? this.map : null))
167+
}
168+
169+
_buildOption(value, label) {
170+
const option = document.createElement("option")
171+
option.value = value
172+
option.textContent = label
173+
return option
174+
}
175+
84176
_fillSelect(select, values, labels, preselect) {
85177
const previous = select.value || preselect || ""
86178
const blank = select.querySelector('option[value=""]')
@@ -99,15 +191,16 @@ export default class extends Controller {
99191
}
100192

101193
_focusSelected() {
102-
const marker = this.markersById.get(String(this.selectTarget.value))
194+
const marker = this.markersById && this.markersById.get(String(this.selectTarget.value))
103195
if (!marker || !this.map) return
104196
this.map.setCenter(marker.getPosition())
105197
this.map.setZoom(Math.max(this.map.getZoom(), 14))
106198
// Можно открыть infoWindow, но для простоты пропустим
107199
}
108200

109201
_center() {
110-
const withCoords = (this.courtsValue || []).find(this._valid)
202+
const selected = (this.courtsValue || []).find(c => String(c.id) === String(this.selectTarget.value))
203+
const withCoords = this._valid(selected) ? selected : (this.courtsValue || []).find(this._valid)
111204
return withCoords ? [withCoords.lat, withCoords.lng] : [this.defaultLatValue, this.defaultLngValue]
112205
}
113206

@@ -118,4 +211,4 @@ export default class extends Controller {
118211
disconnect() {
119212
// Очистка не требуется
120213
}
121-
}
214+
}

app/models/court.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ class Court < ApplicationRecord
3030
scope :indoor_only, -> { where(indoor: true) }
3131

3232
def self.sorted_for_user(user)
33-
courts = all.to_a
33+
# По id корты идут вперемешку, поэтому раскладываем их по городу и названию.
34+
courts = all.to_a.sort_by { |court| [ court.city_name.to_s, court.name.to_s ] }
3435
user_city = user&.city_name.to_s.strip.downcase.presence
3536
return courts unless user_city
3637

app/views/games/_form.html.erb

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,18 +165,47 @@
165165
</div>
166166

167167
<% sorted_courts = tournament&.courts&.any? ? tournament.courts.to_a : Court.sorted_for_user(current_user) %>
168+
<% locations = court_picker_locations(sorted_courts) %>
169+
<%# Кортов много и они из разных стран, поэтому список сужаем парой селектов.
170+
Стартуем с города выбранного корта, а для новой игры — с города игрока. %>
171+
<% selected_city = game.court&.city_name.presence || default_court_city(locations[:cities_by_country][""], current_user) %>
172+
<% selected_country = locations[:city_country][selected_city].to_s %>
173+
<% location_filter = locations[:countries].size > 1 || locations[:cities_by_country][""].size > 1 %>
168174
<div class="space-y-3"
169175
data-controller="court-picker"
170176
data-court-picker-default-lat-value="56.838011"
171177
data-court-picker-default-lng-value="60.597465"
172178
data-court-picker-courts-value="<%= json_escape(sorted_courts.map { |c|
173179
lat, lng = c.coordinates.to_s.split(',').map(&:to_f)
174-
{ id: c.id, name: c.name, lat: lat, lng: lng, surfaces: c.surfaces, environments: c.environments }
180+
{ id: c.id, name: c.name, lat: lat, lng: lng, surfaces: c.surfaces, environments: c.environments,
181+
city: c.city_name.to_s, country: locations[:city_country][c.city_name].to_s }
175182
}.to_json) %>"
183+
data-court-picker-country-cities-value="<%= json_escape(locations[:cities_by_country].to_json) %>"
184+
data-court-picker-city-placeholder-value="<%= t("games.form.court_city_any") %>"
176185
data-court-picker-surface-labels-value="<%= json_escape(Court::SURFACES.index_with { |s| t("courts.surfaces.#{s}") }.to_json) %>"
177186
data-court-picker-environment-labels-value="<%= json_escape(Game::ENVIRONMENTS.index_with { |e| t("courts.index.#{e}_badge") }.to_json) %>"
178187
data-court-picker-surface-selected-value="<%= game.surface %>"
179188
data-court-picker-environment-selected-value="<%= game.environment %>">
189+
<% if location_filter %>
190+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
191+
<div>
192+
<%= label_tag :court_country, t("games.form.court_country"), class: "block text-sm font-medium text-gray-700 dark:text-slate-300" %>
193+
<%# Фильтры не уходят на сервер: без name они не попадают в параметры игры. %>
194+
<%= select_tag nil, options_for_select(locations[:countries], selected_country),
195+
include_blank: t("games.form.court_country_any"), id: "court_country",
196+
class: "mt-2 block w-full rounded-md border border-gray-300 dark:border-white/15 dark:bg-slate-700 dark:text-slate-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500",
197+
data: { "court-picker-target": "countrySelect", action: "court-picker#locationChanged" } %>
198+
</div>
199+
<div>
200+
<%= label_tag :court_city, t("games.form.court_city"), class: "block text-sm font-medium text-gray-700 dark:text-slate-300" %>
201+
<%= select_tag nil, options_for_select(locations[:cities_by_country][selected_country] || [], selected_city),
202+
include_blank: t("games.form.court_city_any"), id: "court_city",
203+
class: "mt-2 block w-full rounded-md border border-gray-300 dark:border-white/15 dark:bg-slate-700 dark:text-slate-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500",
204+
data: { "court-picker-target": "citySelect", action: "court-picker#locationChanged" } %>
205+
</div>
206+
</div>
207+
<% end %>
208+
180209
<div>
181210
<%= form.label :court_id, t("games.form.court"), class: "block text-sm font-medium text-gray-700 dark:text-slate-300" %>
182211
<%= form.collection_select :court_id, sorted_courts, :id, :name,

config/locales/en.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,10 @@ en:
297297
one: "👥 %{count} spot left"
298298
other: "👥 %{count} spots left"
299299
form:
300+
court_country: "Country"
301+
court_country_any: "— any country —"
302+
court_city: "City"
303+
court_city_any: "— any city —"
300304
surface: "Surface"
301305
surface_any: "— any surface —"
302306
surface_hint: "Choose from surfaces available at the selected court."

config/locales/es.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ es:
272272
one: "👥 falta %{count} jugador"
273273
other: "👥 faltan %{count} jugadores"
274274
form:
275+
court_country: "País"
276+
court_country_any: "— cualquier país —"
277+
court_city: "Ciudad"
278+
court_city_any: "— cualquier ciudad —"
275279
surface: "Superficie"
276280
surface_any: "— cualquier superficie —"
277281
surface_hint: "Elige entre las superficies disponibles en la pista seleccionada."

config/locales/ru.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ ru:
269269
many: "👥 нужно %{count} игроков"
270270
other: "👥 %{count} игроков"
271271
form:
272+
court_country: "Страна"
273+
court_country_any: "— любая страна —"
274+
court_city: "Город"
275+
court_city_any: "— любой город —"
272276
surface: "Покрытие"
273277
surface_any: "— любое покрытие —"
274278
surface_hint: "Выберите из покрытий выбранного корта."

0 commit comments

Comments
 (0)