Skip to content
Merged
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
16 changes: 11 additions & 5 deletions app/views/redmine_gtt/hooks/_watch_nearby_preference.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@
</p>
<p>
<label for="pref_gtt_watch_radius"><%= l(:gtt_label_watch_nearby_radius) %></label>
<%= number_field_tag 'pref[gtt_watch_radius]', user.pref.gtt_watch_radius,
id: 'pref_gtt_watch_radius', min: 1,
max: RedmineGtt::Patches::UserPreferencePatch::NEARBY_WATCH_MAX_RADIUS_KM,
step: 1, disabled: !location_set %>
<%= l(:gtt_label_watch_nearby_radius_unit) %>
<%# entered in the configured display unit, stored in meters (#10) %>
<%# min = 1 meter expressed in the unit: the server treats smaller
values as unset, so don't offer them %>
<%= number_field_tag 'pref[gtt_watch_radius_in_unit]', user.pref.gtt_watch_radius_in_unit,
id: 'pref_gtt_watch_radius', step: 'any',
min: RedmineGtt::DistanceUnit.from_meters(1).round(6),
max: RedmineGtt::DistanceUnit.from_meters(
RedmineGtt::Patches::UserPreferencePatch::NEARBY_WATCH_MAX_RADIUS_M
).ceil,
disabled: !location_set %>
<%= RedmineGtt::DistanceUnit.current %>
</p>
<% unless location_set %>
<p><em class="info"><%= l(:gtt_text_watch_nearby_requires_location) %></em></p>
Expand Down
27 changes: 27 additions & 0 deletions app/views/settings/gtt/_general.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,31 @@
min: 0, max: 15, step: 1) %>
<em class="info"><%= l(:text_geojson_precision_info) %></em>
</p>

<p>
<%= content_tag(:label, l(:label_distance_unit)) %>
<%= select_tag 'settings[distance_unit]',
options_for_select(
RedmineGtt::DistanceUnit::METERS_PER_UNIT.keys.map { |u|
[l(:"gtt_distance_unit_#{u}"), u]
},
RedmineGtt::DistanceUnit.current
) %>
<em class="info"><%= l(:text_distance_unit_info) %></em>
</p>

<p>
<%= content_tag(:label, l(:label_api_distance_unit)) %>
<%# reserved: the API contract is meters-only for now; the setting
exists so a later version can offer more units without a
settings migration %>
<%= select_tag 'settings[api_distance_unit]',
options_for_select(
RedmineGtt::DistanceUnit::API_UNITS.map { |u|
[l(:"gtt_distance_unit_#{u}"), u]
},
RedmineGtt::DistanceUnit.api_unit
) %>
<em class="info"><%= l(:text_api_distance_unit_info) %></em>
</p>
</fieldset>
10 changes: 9 additions & 1 deletion config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,16 @@ de:
gtt_hide_map_for_invalid_geom_info: Bitte bearbeiten Sie die Anfrage und stellen
Sie die Geometrie ein, um die Karte zu sehen.
label_default_measure_enabled: Zeige Steuerung zum Messen von Länge und Fläche
label_distance_unit: "Anzeigeeinheit für Entfernungen"
text_distance_unit_info: "Einheit für angezeigte und eingegebene Entfernungen (Distanzfilter, Distanzspalte, Beobachtungsradius)."
label_api_distance_unit: "API-Entfernungseinheit"
text_api_distance_unit_info: "Die REST-API verwendet in dieser Version immer Meter."
gtt_distance_unit_m: "Meter (m)"
gtt_distance_unit_km: "Kilometer (km)"
gtt_distance_unit_ft: "Fuß (ft)"
gtt_distance_unit_mi: "Meilen (mi)"
gtt_distance_unit_nm: "Seemeilen (nm)"
gtt_label_watch_nearby_legend: "Automatisch beobachten: Tickets in der Nähe"
gtt_label_watch_nearby: "Neue Tickets in der Nähe meines Standorts beobachten"
gtt_label_watch_nearby_radius: "Entfernung"
gtt_label_watch_nearby_radius_unit: "km"
gtt_text_watch_nearby_requires_location: "Legen Sie zuerst Ihren Standort auf der Karte oben fest, um dies zu aktivieren."
10 changes: 9 additions & 1 deletion config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,19 @@ en:
text_geojson_precision_info: "Decimal places for coordinates in GeoJSON output (EPSG:4326). 6 is about 0.11 m; lower values shrink the payload."
gtt_hide_map_for_invalid_geom_info: "Please edit the issue and set the geometry
to see the map."
label_distance_unit: "Distance display unit"
text_distance_unit_info: "Unit for distances shown and entered in the UI (distance filter, distance column, watch radius)."
label_api_distance_unit: "API distance unit"
text_api_distance_unit_info: "The REST API always uses meters in this version."
gtt_distance_unit_m: "Meters (m)"
gtt_distance_unit_km: "Kilometers (km)"
gtt_distance_unit_ft: "Feet (ft)"
gtt_distance_unit_mi: "Miles (mi)"
gtt_distance_unit_nm: "Nautical miles (nm)"

gtt_label_watch_nearby_legend: "Auto watch: nearby issues"
gtt_label_watch_nearby: "Watch new issues near my location"
gtt_label_watch_nearby_radius: "Distance"
gtt_label_watch_nearby_radius_unit: "km"
gtt_text_watch_nearby_requires_location: "Set your location on the map above to enable this."

select_default_tracker_icon: "Select default tracker icon:"
Expand Down
10 changes: 9 additions & 1 deletion config/locales/ja.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,19 @@ ja:
label_geojson_precision: "GeoJSON座標の小数点桁数"
text_geojson_precision_info: "GeoJSON出力(EPSG:4326)の座標の小数点以下の桁数。6桁で約0.11m。小さくするとデータ量を削減できます。"
gtt_hide_map_for_invalid_geom_info: "地図を見るにはチケットを編集して位置情報を登録してください。"
label_distance_unit: "距離の表示単位"
text_distance_unit_info: "画面で表示・入力する距離の単位(距離フィルタ、距離カラム、ウォッチ半径)。"
label_api_distance_unit: "APIの距離単位"
text_api_distance_unit_info: "このバージョンではREST APIは常にメートルを使用します。"
gtt_distance_unit_m: "メートル (m)"
gtt_distance_unit_km: "キロメートル (km)"
gtt_distance_unit_ft: "フィート (ft)"
gtt_distance_unit_mi: "マイル (mi)"
gtt_distance_unit_nm: "海里 (nm)"

gtt_label_watch_nearby_legend: "自動ウォッチ: 近くのチケット"
gtt_label_watch_nearby: "登録した所在地の近くの新しいチケットをウォッチする"
gtt_label_watch_nearby_radius: "距離"
gtt_label_watch_nearby_radius_unit: "km"
gtt_text_watch_nearby_requires_location: "有効にするには上の地図で所在地を設定してください。"

select_default_tracker_icon: "トラッカーアイコンを選択:"
Expand Down
4 changes: 3 additions & 1 deletion init.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
'hide_map_for_invalid_geom' => false,
# Keep in sync with RedmineGtt::DEFAULT_GEOJSON_PRECISION (literal here to
# avoid autoloading the module during plugin registration).
'geojson_precision' => 6
'geojson_precision' => 6,
'distance_unit' => 'm',
'api_distance_unit' => 'm'
},
partial: 'settings/gtt/settings'
)
Expand Down
1 change: 1 addition & 0 deletions lib/redmine_gtt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def self.setup_controller_patches
RedmineGtt::Patches::ProjectsControllerPatch.apply
RedmineGtt::Patches::UsersControllerPatch.apply
RedmineGtt::Patches::IssuesHelperPatch.apply
RedmineGtt::Patches::QueriesHelperPatch.apply

[
IssuesController,
Expand Down
50 changes: 50 additions & 0 deletions lib/redmine_gtt/distance_unit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module RedmineGtt
# Converts between meters and the display unit configured in the plugin
# settings (#10). Meters are the internal unit everywhere: storage, SQL,
# the filter wire format and the REST API all stay metric; only what the
# user sees and types is converted.
module DistanceUnit

METERS_PER_UNIT = {
'm' => 1.0,
'km' => 1000.0,
'ft' => 0.3048,
'mi' => 1609.344,
'nm' => 1852.0
}.freeze

DEFAULT = 'm'

# Units the REST API may use. Reserved for future flexibility: the API
# contract is meters and only meters for now, but the setting exists so
# a later version can widen this list without a settings migration.
API_UNITS = %w(m).freeze

# The configured display unit, falling back to meters for anything
# unknown (empty setting, typo from a manually edited setting hash).
def self.current
unit = Setting.plugin_redmine_gtt['distance_unit'].to_s
METERS_PER_UNIT.key?(unit) ? unit : DEFAULT
end

# The unit used by the REST API. Clamped to meters regardless of the
# stored value while API_UNITS has a single entry.
def self.api_unit
unit = Setting.plugin_redmine_gtt['api_distance_unit'].to_s
API_UNITS.include?(unit) ? unit : DEFAULT
end

# Unknown units fall back to meters instead of raising: the setting can
# be edited by hand and callers may pass user-supplied unit strings.
def self.from_meters(meters, unit = current)
meters.to_f / METERS_PER_UNIT.fetch(unit, 1.0)
end

def self.to_meters(value, unit = current)
value.to_f * METERS_PER_UNIT.fetch(unit, 1.0)
end

end
end
8 changes: 4 additions & 4 deletions lib/redmine_gtt/nearby_watchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ def self.subscribe(issue)

def self.nearby_users(issue)
candidates(issue).select do |user|
radius_km = user.pref.gtt_watch_nearby? && user.pref.gtt_watch_radius_km
radius_km &&
user['gtt_distance_m'].to_f <= radius_km * 1000 &&
radius_m = user.pref.gtt_watch_nearby? && user.pref.gtt_watch_radius_m
radius_m &&
user['gtt_distance_m'].to_f <= radius_m &&
issue.visible?(user)
end
end
Expand All @@ -30,7 +30,7 @@ def self.nearby_users(issue)
# The issue geometry is bound as an EWKB hex parameter; the distance is
# measured on the geography type (meters, any geometry type).
def self.candidates(issue)
max_meters = Patches::UserPreferencePatch::NEARBY_WATCH_MAX_RADIUS_KM * 1000
max_meters = Patches::UserPreferencePatch::NEARBY_WATCH_MAX_RADIUS_M
distance = User.send(:sanitize_sql_array, [
"ST_Distance(#{User.table_name}.geom::geography, ?::geometry::geography)",
ewkb_hex(issue.geom)
Expand Down
3 changes: 2 additions & 1 deletion lib/redmine_gtt/patches/issue_query_patch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ def available_columns
caption: :field_geom
)
columns << QueryColumn.new(:distance,
caption: :label_gtt_distance,
# header shows the configured display unit, e.g. "Distance (km)"
caption: -> { "#{l(:label_gtt_distance)} (#{DistanceUnit.current})" },
sortable: lambda{
lng, lat = find_center_point
distance_query lng, lat
Expand Down
38 changes: 38 additions & 0 deletions lib/redmine_gtt/patches/queries_helper_patch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
module RedmineGtt
module Patches

# Renders the distance column in the configured display unit (#10).
# The underlying value stays meters (it is what the query selects and
# what the REST API returns); only the rendered list/CSV cell converts.
module QueriesHelperPatch

def self.apply
QueriesHelper.prepend self unless QueriesHelper < self
end

def column_value(column, item, value)
if column.name == :distance && value.present?
gtt_format_distance(value)
else
super
end
end

def csv_value(column, object, value)
if column.name == :distance && value.present?
# mirror core's CSV float formatting incl. the decimal separator
gtt_format_distance(value).gsub('.', l(:general_csv_decimal_separator))
else
super
end
end

private

def gtt_format_distance(meters)
sprintf('%.2f', RedmineGtt::DistanceUnit.from_meters(meters))
end

end
end
end
51 changes: 40 additions & 11 deletions lib/redmine_gtt/patches/user_preference_patch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@ module Patches
# Stores the "auto watch nearby issues" opt-in (#14) in the serialized
# UserPreference#others hash, so no schema change is needed. The user's
# stored location (users.geom) is the center of the watch area; the
# radius is kept in kilometers as entered on the My account page.
# radius is stored in meters (the plugin's internal unit, #10) and
# entered/displayed in the configured display unit on My account.
#
# This patch only adds the preference storage and validated readers.
# The watcher assignment itself hooks into issue creation separately.
module UserPreferencePatch

# Upper bound for the watch radius, enforced server-side (the form's
# max attribute mirrors it, but client-side limits are easy to bypass)
# so an arbitrarily large radius can't make the watcher query expensive.
NEARBY_WATCH_MAX_RADIUS_KM = 1000
# Upper bound for the watch radius (1000 km), enforced server-side
# (the form's max attribute mirrors it, but client-side limits are
# easy to bypass) so an arbitrarily large radius can't make the
# watcher query expensive.
NEARBY_WATCH_MAX_RADIUS_M = 1_000_000

def self.apply
unless UserPreference < self
UserPreference.prepend self
UserPreference.safe_attributes 'gtt_watch_nearby', 'gtt_watch_radius'
UserPreference.safe_attributes 'gtt_watch_nearby', 'gtt_watch_radius_in_unit'
end
end

Expand All @@ -28,6 +30,8 @@ def self.apply
def gtt_watch_nearby; self[:gtt_watch_nearby]; end
def gtt_watch_nearby=(value); self[:gtt_watch_nearby] = value; end

# Raw stored radius (meters). Not mass-assignable; the account form
# goes through gtt_watch_radius_in_unit.
def gtt_watch_radius; self[:gtt_watch_radius]; end
def gtt_watch_radius=(value); self[:gtt_watch_radius] = value; end

Expand All @@ -36,12 +40,37 @@ def gtt_watch_nearby?
gtt_watch_nearby.to_s == '1'
end

# Validated radius in kilometers: a positive Integer capped at
# NEARBY_WATCH_MAX_RADIUS_KM, or nil when the preference is unset or
# Validated radius in meters: a positive number capped at
# NEARBY_WATCH_MAX_RADIUS_M, or nil when the preference is unset or
# holds a non-numeric/non-positive value.
def gtt_watch_radius_km
value = Integer(gtt_watch_radius.to_s, exception: false)
value.clamp(1, NEARBY_WATCH_MAX_RADIUS_KM) if value&.positive?
def gtt_watch_radius_m
value = Float(gtt_watch_radius.to_s, exception: false)
[value, NEARBY_WATCH_MAX_RADIUS_M].min if value&.positive?
end

# Form-facing virtual attribute: the radius in the configured display
# unit. Whole numbers render without a decimal part; fractions keep
# 6 decimals (enough for 1 m in any supported unit), so a read->save
# round trip cannot drift the stored meters.
def gtt_watch_radius_in_unit
meters = gtt_watch_radius_m
return nil unless meters

value = DistanceUnit.from_meters(meters)
value == value.to_i ? value.to_i : value.round(6)
end

def gtt_watch_radius_in_unit=(value)
stripped = value.to_s.strip
self.gtt_watch_radius =
if stripped.empty?
nil
elsif (number = Float(stripped, exception: false))
DistanceUnit.to_meters(number).round.to_s
else
# keep the garbage; gtt_watch_radius_m rejects it as before
stripped
end
end

end
Expand Down
35 changes: 35 additions & 0 deletions src/components/gtt-client/redmine/filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';

import { METERS_PER_UNIT, metersToUnitValue } from './filters';

describe('METERS_PER_UNIT', () => {
it('mirrors the server-side unit table', () => {
expect(METERS_PER_UNIT).toEqual({
m: 1,
km: 1000,
ft: 0.3048,
mi: 1609.344,
nm: 1852,
});
});
});

describe('metersToUnitValue', () => {
it('converts meters into the unit', () => {
expect(metersToUnitValue('1500', METERS_PER_UNIT.km)).toBe('1.5');
expect(metersToUnitValue('1609.344', METERS_PER_UNIT.mi)).toBe('1');
expect(metersToUnitValue('1852', METERS_PER_UNIT.nm)).toBe('1');
expect(metersToUnitValue('25', METERS_PER_UNIT.m)).toBe('25');
});

it('trims float noise from round trips', () => {
// 25 km -> 25000 m -> back: must not display 24.999999999
expect(metersToUnitValue('25000', METERS_PER_UNIT.km)).toBe('25');
expect(metersToUnitValue('804672', METERS_PER_UNIT.mi)).toBe('500');
});

it('passes empty and non-numeric values through unchanged', () => {
expect(metersToUnitValue('', METERS_PER_UNIT.km)).toBe('');
expect(metersToUnitValue('abc', METERS_PER_UNIT.km)).toBe('abc');
});
});
Loading