mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-14 19:21:39 -05:00
Compare commits
54 commits
master
...
0.36.5-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573d527455 | ||
|
|
4be58d4b4c | ||
|
|
3f436c1d3a | ||
|
|
fe9d7d2f79 | ||
|
|
fab0121113 | ||
|
|
9805c5524c | ||
|
|
f325fd7a4f | ||
|
|
3c1d17b806 | ||
|
|
c9ba7914b6 | ||
|
|
03697ecef2 | ||
|
|
7347be9a87 | ||
|
|
ce74b3d846 | ||
|
|
da9742bf4a | ||
|
|
e12b45f93e | ||
|
|
32f5d2f89a | ||
|
|
ad385f4464 | ||
|
|
d4e87ce830 | ||
|
|
04fbe4d564 | ||
|
|
1471e4de40 | ||
|
|
9ef0da27d6 | ||
|
|
87baf8bb11 | ||
|
|
d40b2a1959 | ||
|
|
35995e7be8 | ||
|
|
20a4553921 | ||
|
|
c1bb7f3d87 | ||
|
|
0b6149bfc0 | ||
|
|
f2d96e50f0 | ||
|
|
1090bcd6e8 | ||
|
|
b7f0b7ebc2 | ||
|
|
b81d2580e3 | ||
|
|
acee848e72 | ||
|
|
88f5e2a6ea | ||
|
|
353837e27f | ||
|
|
2a4ed8bf82 | ||
|
|
8af032a215 | ||
|
|
bb980f2210 | ||
|
|
c6d09c341d | ||
|
|
516cfabb06 | ||
|
|
9ac4566b5a | ||
|
|
1c9843dde7 | ||
|
|
6cc8ba0fbd | ||
|
|
913d60812a | ||
|
|
a7f77b042e | ||
|
|
cdf1428e35 | ||
|
|
9661e8e7f7 | ||
|
|
2debcd88fa | ||
|
|
672c308f67 | ||
|
|
c5ef4d3861 | ||
|
|
9fb4bc517b | ||
|
|
97d52f9edc | ||
|
|
4421a5bf3c | ||
|
|
cebbc28912 | ||
|
|
ac9b668c30 | ||
|
|
6772f2f7b7 |
23 changed files with 523 additions and 81 deletions
|
|
@ -1 +1 @@
|
||||||
0.36.4
|
0.36.5
|
||||||
|
|
|
||||||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -4,7 +4,22 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
# [0.36.4] - Unreleased
|
# [0.36.5] - Unreleased
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Deleting an import will now be processed in the background to prevent request timeouts for large imports.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Deleting an import will no longer result in negative points count for the user.
|
||||||
|
- Updating stats. #2022
|
||||||
|
- Validate trip start date to be earlier than end date. #2057
|
||||||
|
- Fog of war radius slider in map v2 settings is now being respected correctly. #2041
|
||||||
|
- Applying changes in map v2 settings now works correctly. #2041
|
||||||
|
|
||||||
|
|
||||||
|
# [0.36.4] - 2025-12-26
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
|
@ -14,6 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
- Disable Family::Invitations::CleanupJob no invitations are in the database. #2043
|
- Disable Family::Invitations::CleanupJob no invitations are in the database. #2043
|
||||||
- User can now enable family layer in Maps v2 and center on family members by clicking their emails. #2036
|
- User can now enable family layer in Maps v2 and center on family members by clicking their emails. #2036
|
||||||
|
|
||||||
|
|
||||||
# [0.36.3] - 2025-12-14
|
# [0.36.3] - 2025-12-14
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
|
||||||
42
Gemfile.lock
42
Gemfile.lock
|
|
@ -108,12 +108,12 @@ GEM
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.1)
|
benchmark (0.5.0)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.18.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.0)
|
brakeman (7.1.1)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.2)
|
bundler-audit (0.9.2)
|
||||||
|
|
@ -133,8 +133,8 @@ GEM
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.5)
|
||||||
crack (1.0.0)
|
crack (1.0.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
|
|
@ -166,7 +166,7 @@ GEM
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
email_validator (2.2.4)
|
email_validator (2.2.4)
|
||||||
activemodel
|
activemodel
|
||||||
erb (5.1.3)
|
erb (6.0.0)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
|
|
@ -208,7 +208,7 @@ GEM
|
||||||
ffi (~> 1.9)
|
ffi (~> 1.9)
|
||||||
rgeo-geojson (~> 2.1)
|
rgeo-geojson (~> 2.1)
|
||||||
zeitwerk (~> 2.5)
|
zeitwerk (~> 2.5)
|
||||||
hashdiff (1.1.2)
|
hashdiff (1.2.1)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
httparty (0.23.1)
|
httparty (0.23.1)
|
||||||
csv
|
csv
|
||||||
|
|
@ -221,7 +221,7 @@ GEM
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.1)
|
io-console (0.8.1)
|
||||||
irb (1.15.2)
|
irb (1.15.3)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
|
|
@ -272,7 +272,7 @@ GEM
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.26.0)
|
minitest (5.26.2)
|
||||||
msgpack (1.7.3)
|
msgpack (1.7.3)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.7.1)
|
multi_xml (0.7.1)
|
||||||
|
|
@ -379,14 +379,14 @@ GEM
|
||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.2)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.2)
|
pundit (2.5.2)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.4)
|
||||||
rack-oauth2 (2.3.0)
|
rack-oauth2 (2.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
attr_required
|
attr_required
|
||||||
|
|
@ -440,16 +440,16 @@ GEM
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.3.1)
|
||||||
rdoc (6.15.0)
|
rdoc (6.16.1)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
redis (5.4.0)
|
redis (5.4.1)
|
||||||
redis-client (>= 0.22.0)
|
redis-client (>= 0.22.0)
|
||||||
redis-client (0.24.0)
|
redis-client (0.26.1)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.2)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
|
|
@ -525,10 +525,10 @@ GEM
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (6.0.0)
|
sentry-rails (6.1.1)
|
||||||
railties (>= 5.2.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 6.0.0)
|
sentry-ruby (~> 6.1.1)
|
||||||
sentry-ruby (6.0.0)
|
sentry-ruby (6.1.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
shoulda-matchers (6.5.0)
|
shoulda-matchers (6.5.0)
|
||||||
|
|
@ -565,7 +565,7 @@ GEM
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.8)
|
||||||
strong_migrations (2.5.1)
|
strong_migrations (2.5.1)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
super_diff (0.17.0)
|
super_diff (0.17.0)
|
||||||
|
|
@ -589,7 +589,7 @@ GEM
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
timeout (0.4.4)
|
timeout (0.4.4)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.17)
|
turbo-rails (2.0.20)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
|
|
@ -598,7 +598,7 @@ GEM
|
||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.1.0)
|
unicode-emoji (4.1.0)
|
||||||
uri (1.0.4)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
validate_url (1.0.15)
|
validate_url (1.0.15)
|
||||||
activemodel (>= 3.0.0)
|
activemodel (>= 3.0.0)
|
||||||
|
|
@ -610,7 +610,7 @@ GEM
|
||||||
activesupport
|
activesupport
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
webmock (3.25.1)
|
webmock (3.26.1)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,13 @@ class ImportsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
Imports::Destroy.new(current_user, @import).call
|
@import.deleting!
|
||||||
|
Imports::DestroyJob.perform_later(@import.id)
|
||||||
|
|
||||||
redirect_to imports_url, notice: 'Import was successfully destroyed.', status: :see_other
|
respond_to do |format|
|
||||||
|
format.html { redirect_to imports_url, notice: 'Import is being deleted.', status: :see_other }
|
||||||
|
format.turbo_stream
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,57 @@ export default class extends BaseController {
|
||||||
connect() {
|
connect() {
|
||||||
console.log("Datetime controller connected")
|
console.log("Datetime controller connected")
|
||||||
this.debounceTimer = null;
|
this.debounceTimer = null;
|
||||||
|
|
||||||
|
// Add validation listeners
|
||||||
|
if (this.hasStartedAtTarget && this.hasEndedAtTarget) {
|
||||||
|
// Validate on change to set validation state
|
||||||
|
this.startedAtTarget.addEventListener('change', () => this.validateDates())
|
||||||
|
this.endedAtTarget.addEventListener('change', () => this.validateDates())
|
||||||
|
|
||||||
|
// Validate on blur to set validation state
|
||||||
|
this.startedAtTarget.addEventListener('blur', () => this.validateDates())
|
||||||
|
this.endedAtTarget.addEventListener('blur', () => this.validateDates())
|
||||||
|
|
||||||
|
// Add form submit validation
|
||||||
|
const form = this.element.closest('form')
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
if (!this.validateDates()) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.endedAtTarget.reportValidity()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCoordinates(event) {
|
validateDates(showPopup = false) {
|
||||||
|
const startDate = new Date(this.startedAtTarget.value)
|
||||||
|
const endDate = new Date(this.endedAtTarget.value)
|
||||||
|
|
||||||
|
// Clear any existing custom validity
|
||||||
|
this.startedAtTarget.setCustomValidity('')
|
||||||
|
this.endedAtTarget.setCustomValidity('')
|
||||||
|
|
||||||
|
// Check if both dates are valid
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that start date is before end date
|
||||||
|
if (startDate >= endDate) {
|
||||||
|
const errorMessage = 'Start date must be earlier than end date'
|
||||||
|
this.endedAtTarget.setCustomValidity(errorMessage)
|
||||||
|
if (showPopup) {
|
||||||
|
this.endedAtTarget.reportValidity()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCoordinates() {
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (this.debounceTimer) {
|
if (this.debounceTimer) {
|
||||||
clearTimeout(this.debounceTimer);
|
clearTimeout(this.debounceTimer);
|
||||||
|
|
@ -25,6 +73,11 @@ export default class extends BaseController {
|
||||||
const endedAt = this.endedAtTarget.value
|
const endedAt = this.endedAtTarget.value
|
||||||
const apiKey = this.apiKeyTarget.value
|
const apiKey = this.apiKeyTarget.value
|
||||||
|
|
||||||
|
// Validate dates before making API call (don't show popup, already shown on change)
|
||||||
|
if (!this.validateDates(false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (startedAt && endedAt) {
|
if (startedAt && endedAt) {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,23 @@ export default class extends BaseController {
|
||||||
received: (data) => {
|
received: (data) => {
|
||||||
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
||||||
|
|
||||||
if (row) {
|
if (!row) return;
|
||||||
const pointsCell = row.querySelector('[data-points-count]');
|
|
||||||
if (pointsCell) {
|
|
||||||
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusCell = row.querySelector('[data-status-display]');
|
// Handle deletion complete - remove the row
|
||||||
if (statusCell && data.import.status) {
|
if (data.action === 'delete') {
|
||||||
statusCell.textContent = data.import.status;
|
row.remove();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle status and points updates
|
||||||
|
const pointsCell = row.querySelector('[data-points-count]');
|
||||||
|
if (pointsCell && data.import.points_count !== undefined) {
|
||||||
|
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCell = row.querySelector('[data-status-display]');
|
||||||
|
if (statusCell && data.import.status) {
|
||||||
|
statusCell.textContent = data.import.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ export class LayerManager {
|
||||||
// Always create fog layer for backward compatibility
|
// Always create fog layer for backward compatibility
|
||||||
if (!this.layers.fogLayer) {
|
if (!this.layers.fogLayer) {
|
||||||
this.layers.fogLayer = new FogLayer(this.map, {
|
this.layers.fogLayer = new FogLayer(this.map, {
|
||||||
clearRadius: 1000,
|
clearRadius: this.settings.fogOfWarRadius || 1000,
|
||||||
visible: this.settings.fogEnabled || false
|
visible: this.settings.fogEnabled || false
|
||||||
})
|
})
|
||||||
this.layers.fogLayer.add(pointsGeoJSON)
|
this.layers.fogLayer.add(pointsGeoJSON)
|
||||||
|
|
|
||||||
|
|
@ -244,8 +244,8 @@ export class SettingsController {
|
||||||
if (settings.fogOfWarRadius) {
|
if (settings.fogOfWarRadius) {
|
||||||
fogLayer.clearRadius = settings.fogOfWarRadius
|
fogLayer.clearRadius = settings.fogOfWarRadius
|
||||||
}
|
}
|
||||||
// Redraw fog layer
|
// Redraw fog layer if it has data and is visible
|
||||||
if (fogLayer.visible) {
|
if (fogLayer.visible && fogLayer.data) {
|
||||||
await fogLayer.update(fogLayer.data)
|
await fogLayer.update(fogLayer.data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ export class FogLayer {
|
||||||
this.ctx = null
|
this.ctx = null
|
||||||
this.clearRadius = options.clearRadius || 1000 // meters
|
this.clearRadius = options.clearRadius || 1000 // meters
|
||||||
this.points = []
|
this.points = []
|
||||||
|
this.data = null // Store original data for updates
|
||||||
}
|
}
|
||||||
|
|
||||||
add(data) {
|
add(data) {
|
||||||
|
this.data = data // Store for later updates
|
||||||
this.points = data.features || []
|
this.points = data.features || []
|
||||||
this.createCanvas()
|
this.createCanvas()
|
||||||
if (this.visible) {
|
if (this.visible) {
|
||||||
|
|
@ -24,6 +26,7 @@ export class FogLayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
update(data) {
|
update(data) {
|
||||||
|
this.data = data // Store for later updates
|
||||||
this.points = data.features || []
|
this.points = data.features || []
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +81,7 @@ export class FogLayer {
|
||||||
|
|
||||||
// Clear circles around visited points
|
// Clear circles around visited points
|
||||||
this.ctx.globalCompositeOperation = 'destination-out'
|
this.ctx.globalCompositeOperation = 'destination-out'
|
||||||
|
this.ctx.fillStyle = 'rgba(0, 0, 0, 1)' // Fully opaque to completely clear fog
|
||||||
|
|
||||||
this.points.forEach(feature => {
|
this.points.forEach(feature => {
|
||||||
const coords = feature.geometry.coordinates
|
const coords = feature.geometry.coordinates
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,10 @@ import { BaseLayer } from './base_layer'
|
||||||
/**
|
/**
|
||||||
* Heatmap layer showing point density
|
* Heatmap layer showing point density
|
||||||
* Uses MapLibre's native heatmap for performance
|
* Uses MapLibre's native heatmap for performance
|
||||||
* Fixed radius: 20 pixels
|
|
||||||
*/
|
*/
|
||||||
export class HeatmapLayer extends BaseLayer {
|
export class HeatmapLayer extends BaseLayer {
|
||||||
constructor(map, options = {}) {
|
constructor(map, options = {}) {
|
||||||
super(map, { id: 'heatmap', ...options })
|
super(map, { id: 'heatmap', ...options })
|
||||||
this.radius = 20 // Fixed radius
|
|
||||||
this.weight = options.weight || 1
|
|
||||||
this.intensity = 1 // Fixed intensity
|
|
||||||
this.opacity = options.opacity || 0.6
|
this.opacity = options.opacity || 0.6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,53 +27,52 @@ export class HeatmapLayer extends BaseLayer {
|
||||||
type: 'heatmap',
|
type: 'heatmap',
|
||||||
source: this.sourceId,
|
source: this.sourceId,
|
||||||
paint: {
|
paint: {
|
||||||
// Increase weight as diameter increases
|
// Fixed weight
|
||||||
'heatmap-weight': [
|
'heatmap-weight': 1,
|
||||||
'interpolate',
|
|
||||||
['linear'],
|
|
||||||
['get', 'weight'],
|
|
||||||
0, 0,
|
|
||||||
6, 1
|
|
||||||
],
|
|
||||||
|
|
||||||
// Increase intensity as zoom increases
|
// low intensity to view major clusters
|
||||||
'heatmap-intensity': [
|
'heatmap-intensity': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
['zoom'],
|
['zoom'],
|
||||||
0, this.intensity,
|
0, 0.01,
|
||||||
9, this.intensity * 3
|
10, 0.1,
|
||||||
|
15, 0.3
|
||||||
],
|
],
|
||||||
|
|
||||||
// Color ramp from blue to red
|
// Color ramp
|
||||||
'heatmap-color': [
|
'heatmap-color': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
['heatmap-density'],
|
['heatmap-density'],
|
||||||
0, 'rgba(33,102,172,0)',
|
0, 'rgba(0,0,0,0)',
|
||||||
0.2, 'rgb(103,169,207)',
|
0.4, 'rgba(0,0,0,0)',
|
||||||
0.4, 'rgb(209,229,240)',
|
0.65, 'rgba(33,102,172,0.4)',
|
||||||
0.6, 'rgb(253,219,199)',
|
0.7, 'rgb(103,169,207)',
|
||||||
0.8, 'rgb(239,138,98)',
|
0.8, 'rgb(209,229,240)',
|
||||||
|
0.9, 'rgb(253,219,199)',
|
||||||
|
0.95, 'rgb(239,138,98)',
|
||||||
1, 'rgb(178,24,43)'
|
1, 'rgb(178,24,43)'
|
||||||
],
|
],
|
||||||
|
|
||||||
// Fixed radius adjusted by zoom level
|
// Radius in pixels, exponential growth
|
||||||
'heatmap-radius': [
|
'heatmap-radius': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['exponential', 2],
|
||||||
['zoom'],
|
['zoom'],
|
||||||
0, this.radius,
|
10, 5,
|
||||||
9, this.radius * 3
|
15, 10,
|
||||||
|
20, 160
|
||||||
],
|
],
|
||||||
|
|
||||||
// Transition from heatmap to circle layer by zoom level
|
// Visible when zoomed in, fades when zoomed out
|
||||||
'heatmap-opacity': [
|
'heatmap-opacity': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
['zoom'],
|
['zoom'],
|
||||||
7, this.opacity,
|
0, 0.3,
|
||||||
9, 0
|
10, this.opacity,
|
||||||
|
15, this.opacity
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
app/jobs/imports/destroy_job.rb
Normal file
46
app/jobs/imports/destroy_job.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Imports::DestroyJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(import_id)
|
||||||
|
import = Import.find_by(id: import_id)
|
||||||
|
return unless import
|
||||||
|
|
||||||
|
import.deleting!
|
||||||
|
broadcast_status_update(import)
|
||||||
|
|
||||||
|
Imports::Destroy.new(import.user, import).call
|
||||||
|
|
||||||
|
broadcast_deletion_complete(import)
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
Rails.logger.warn "Import #{import_id} not found, may have already been deleted"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def broadcast_status_update(import)
|
||||||
|
ImportsChannel.broadcast_to(
|
||||||
|
import.user,
|
||||||
|
{
|
||||||
|
action: 'status_update',
|
||||||
|
import: {
|
||||||
|
id: import.id,
|
||||||
|
status: import.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_deletion_complete(import)
|
||||||
|
ImportsChannel.broadcast_to(
|
||||||
|
import.user,
|
||||||
|
{
|
||||||
|
action: 'delete',
|
||||||
|
import: {
|
||||||
|
id: import.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -17,7 +17,7 @@ class Import < ApplicationRecord
|
||||||
validate :file_size_within_limit, if: -> { user.trial? }
|
validate :file_size_within_limit, if: -> { user.trial? }
|
||||||
validate :import_count_within_limit, if: -> { user.trial? }
|
validate :import_count_within_limit, if: -> { user.trial? }
|
||||||
|
|
||||||
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
|
enum :status, { created: 0, processing: 1, completed: 2, failed: 3, deleting: 4 }
|
||||||
|
|
||||||
enum :source, {
|
enum :source, {
|
||||||
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
google_semantic_history: 0, owntracks: 1, google_records: 2,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class Trip < ApplicationRecord
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
validates :name, :started_at, :ended_at, presence: true
|
validates :name, :started_at, :ended_at, presence: true
|
||||||
|
validate :started_at_before_ended_at
|
||||||
|
|
||||||
after_create :enqueue_calculation_jobs
|
after_create :enqueue_calculation_jobs
|
||||||
after_update :enqueue_calculation_jobs, if: -> { saved_change_to_started_at? || saved_change_to_ended_at? }
|
after_update :enqueue_calculation_jobs, if: -> { saved_change_to_started_at? || saved_change_to_ended_at? }
|
||||||
|
|
@ -47,4 +48,11 @@ class Trip < ApplicationRecord
|
||||||
# to show all photos in the same height
|
# to show all photos in the same height
|
||||||
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
|
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def started_at_before_ended_at
|
||||||
|
return if started_at.blank? || ended_at.blank?
|
||||||
|
return unless started_at >= ended_at
|
||||||
|
|
||||||
|
errors.add(:ended_at, 'must be after start date')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,15 @@ class Imports::Destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
|
points_count = @import.points_count
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@import.points.delete_all
|
@import.points.destroy_all
|
||||||
@import.destroy!
|
@import.destroy!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Import #{@import.id} deleted with #{points_count} points"
|
||||||
|
|
||||||
Stats::BulkCalculator.new(@user.id).call
|
Stats::BulkCalculator.new(@user.id).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ class Stats::HexagonCalculator
|
||||||
# Try with lower resolution (larger hexagons)
|
# Try with lower resolution (larger hexagons)
|
||||||
lower_resolution = [h3_resolution - 2, 0].max
|
lower_resolution = [h3_resolution - 2, 0].max
|
||||||
Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}"
|
Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}"
|
||||||
# Create a new instance with lower resolution for recursion
|
# Recursively call with lower resolution
|
||||||
return self.class.new(user.id, year, month).calculate_hexagons(lower_resolution)
|
return calculate_hexagons(lower_resolution)
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info "Generated #{h3_hash.size} H3 hexagons at resolution #{h3_resolution} for user #{user.id}"
|
Rails.logger.info "Generated #{h3_hash.size} H3 hexagons at resolution #{h3_resolution} for user #{user.id}"
|
||||||
|
|
|
||||||
24
app/views/imports/destroy.turbo_stream.erb
Normal file
24
app/views/imports/destroy.turbo_stream.erb
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<%= turbo_stream.replace "import-#{@import.id}" do %>
|
||||||
|
<tr data-import-id="<%= @import.id %>"
|
||||||
|
id="import-<%= @import.id %>"
|
||||||
|
data-points-total="<%= @import.processed %>"
|
||||||
|
class="hover">
|
||||||
|
<td>
|
||||||
|
<%= @import.name %> (<%= @import.source %>)
|
||||||
|
|
||||||
|
<%= link_to '🗺️', map_path(import_id: @import.id) %>
|
||||||
|
|
||||||
|
<%= link_to '📋', points_path(import_id: @import.id) %>
|
||||||
|
</td>
|
||||||
|
<td><%= number_to_human_size(@import.file&.byte_size) || 'N/A' %></td>
|
||||||
|
<td data-points-count>
|
||||||
|
<%= number_with_delimiter @import.processed %>
|
||||||
|
</td>
|
||||||
|
<td data-status-display>deleting</td>
|
||||||
|
<td><%= human_datetime(@import.created_at) %></td>
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span class="text-sm text-gray-500">Deleting...</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
|
@ -72,10 +72,15 @@
|
||||||
<td data-status-display><%= import.status %></td>
|
<td data-status-display><%= import.status %></td>
|
||||||
<td><%= human_datetime(import.created_at) %></td>
|
<td><%= human_datetime(import.created_at) %></td>
|
||||||
<td class="whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
<% if import.file.present? %>
|
<% if import.deleting? %>
|
||||||
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span class="text-sm text-gray-500">Deleting...</span>
|
||||||
|
<% else %>
|
||||||
|
<% if import.file.present? %>
|
||||||
|
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to 'Delete', import, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= link_to 'Delete', import, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ module Dawarich
|
||||||
|
|
||||||
config.active_job.queue_adapter = :sidekiq
|
config.active_job.queue_adapter = :sidekiq
|
||||||
|
|
||||||
config.action_mailer.preview_paths << "#{Rails.root.join('spec/mailers/previews')}"
|
config.action_mailer.preview_paths << Rails.root.join('spec/mailers/previews').to_s
|
||||||
|
|
||||||
|
config.middleware.use Rack::Deflater
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,55 @@
|
||||||
class AddCompositeIndexToStats < ActiveRecord::Migration[8.0]
|
class AddCompositeIndexToStats < ActiveRecord::Migration[8.0]
|
||||||
disable_ddl_transaction!
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
BATCH_SIZE = 1000
|
||||||
|
|
||||||
def change
|
def change
|
||||||
# Add composite index for the most common stats lookup pattern:
|
total_duplicates = execute(<<-SQL.squish).first['count'].to_i
|
||||||
# Stat.find_or_initialize_by(year:, month:, user:)
|
SELECT COUNT(*) as count
|
||||||
# This query is called on EVERY stats calculation
|
FROM stats s1
|
||||||
#
|
WHERE EXISTS (
|
||||||
# Using algorithm: :concurrently to avoid locking the table during index creation
|
SELECT 1 FROM stats s2
|
||||||
# This is crucial for production deployments with existing data
|
WHERE s2.user_id = s1.user_id
|
||||||
|
AND s2.year = s1.year
|
||||||
|
AND s2.month = s1.month
|
||||||
|
AND s2.id > s1.id
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
if total_duplicates.positive?
|
||||||
|
Rails.logger.info(
|
||||||
|
"Found #{total_duplicates} duplicate stats records. Starting cleanup in batches of #{BATCH_SIZE}..."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
loop do
|
||||||
|
batch_deleted = execute(<<-SQL.squish).cmd_tuples
|
||||||
|
DELETE FROM stats s1
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM stats s2
|
||||||
|
WHERE s2.user_id = s1.user_id
|
||||||
|
AND s2.year = s1.year
|
||||||
|
AND s2.month = s1.month
|
||||||
|
AND s2.id > s1.id
|
||||||
|
)
|
||||||
|
LIMIT #{BATCH_SIZE}
|
||||||
|
SQL
|
||||||
|
|
||||||
|
break if batch_deleted.zero?
|
||||||
|
|
||||||
|
deleted_count += batch_deleted
|
||||||
|
Rails.logger.info("Cleaned up #{deleted_count}/#{total_duplicates} duplicate stats records")
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Completed cleanup: removed #{deleted_count} duplicate stats records") if deleted_count.positive?
|
||||||
|
|
||||||
add_index :stats, %i[user_id year month],
|
add_index :stats, %i[user_id year month],
|
||||||
name: 'index_stats_on_user_id_year_month',
|
name: 'index_stats_on_user_id_year_month',
|
||||||
unique: true,
|
unique: true,
|
||||||
algorithm: :concurrently
|
algorithm: :concurrently,
|
||||||
|
if_not_exists: true
|
||||||
|
|
||||||
|
BulkStatsCalculatingJob.perform_later
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,81 @@ test.describe('Advanced Layers', () => {
|
||||||
|
|
||||||
expect(await fogToggle.isChecked()).toBe(true)
|
expect(await fogToggle.isChecked()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('fog radius setting can be changed and applied', async ({ page }) => {
|
||||||
|
// Enable fog layer first
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="layers"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
const fogToggle = page.locator('label:has-text("Fog of War")').first().locator('input.toggle')
|
||||||
|
await fogToggle.check()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Go to advanced settings tab
|
||||||
|
await page.click('button[data-tab="settings"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Find fog radius slider
|
||||||
|
const fogRadiusSlider = page.locator('input[name="fogOfWarRadius"]')
|
||||||
|
await expect(fogRadiusSlider).toBeVisible()
|
||||||
|
|
||||||
|
// Change the slider value using evaluate to trigger input event
|
||||||
|
await fogRadiusSlider.evaluate((slider) => {
|
||||||
|
slider.value = '500'
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Verify display value updated
|
||||||
|
const displayValue = page.locator('[data-maps--maplibre-target="fogRadiusValue"]')
|
||||||
|
await expect(displayValue).toHaveText('500m')
|
||||||
|
|
||||||
|
// Verify slider value was set
|
||||||
|
expect(await fogRadiusSlider.inputValue()).toBe('500')
|
||||||
|
|
||||||
|
// Click Apply Settings button
|
||||||
|
const applyButton = page.locator('button:has-text("Apply Settings")')
|
||||||
|
await applyButton.click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Verify no errors in console
|
||||||
|
const consoleErrors = []
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') consoleErrors.push(msg.text())
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
expect(consoleErrors.filter(e => e.includes('fog_layer'))).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fog settings can be applied without errors when fog layer is not visible', async ({ page }) => {
|
||||||
|
await page.click('button[title="Open map settings"]')
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.click('button[data-tab="settings"]')
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Change fog radius slider without enabling fog layer
|
||||||
|
const fogRadiusSlider = page.locator('input[name="fogOfWarRadius"]')
|
||||||
|
await fogRadiusSlider.evaluate((slider) => {
|
||||||
|
slider.value = '750'
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Click Apply Settings - this should not throw an error
|
||||||
|
const applyButton = page.locator('button:has-text("Apply Settings")')
|
||||||
|
await applyButton.click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Verify no JavaScript errors occurred
|
||||||
|
const consoleErrors = []
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') consoleErrors.push(msg.text())
|
||||||
|
})
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
expect(consoleErrors.filter(e => e.includes('undefined') || e.includes('fog'))).toHaveLength(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Scratch Map', () => {
|
test.describe('Scratch Map', () => {
|
||||||
|
|
|
||||||
100
e2e/v2/trips.spec.js
Normal file
100
e2e/v2/trips.spec.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { closeOnboardingModal } from '../helpers/navigation.js'
|
||||||
|
|
||||||
|
test.describe('Trips Date Validation', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/trips/new')
|
||||||
|
await closeOnboardingModal(page)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates that start date is earlier than end date on new trip form', async ({ page }) => {
|
||||||
|
// Wait for the form to load
|
||||||
|
await page.waitForSelector('input[name="trip[started_at]"]')
|
||||||
|
|
||||||
|
// Fill in trip name
|
||||||
|
await page.fill('input[name="trip[name]"]', 'Test Trip')
|
||||||
|
|
||||||
|
// Set end date before start date
|
||||||
|
await page.fill('input[name="trip[started_at]"]', '2024-12-25T10:00')
|
||||||
|
await page.fill('input[name="trip[ended_at]"]', '2024-12-20T10:00')
|
||||||
|
|
||||||
|
// Get the current URL to verify we stay on the same page
|
||||||
|
const currentUrl = page.url()
|
||||||
|
|
||||||
|
// Try to submit the form
|
||||||
|
const submitButton = page.locator('input[type="submit"], button[type="submit"]')
|
||||||
|
await submitButton.click()
|
||||||
|
|
||||||
|
// Wait a bit for potential navigation
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Verify we're still on the same page (form wasn't submitted)
|
||||||
|
expect(page.url()).toBe(currentUrl)
|
||||||
|
|
||||||
|
// Verify the dates are still there (form wasn't cleared)
|
||||||
|
const startValue = await page.locator('input[name="trip[started_at]"]').inputValue()
|
||||||
|
const endValue = await page.locator('input[name="trip[ended_at]"]').inputValue()
|
||||||
|
expect(startValue).toBe('2024-12-25T10:00')
|
||||||
|
expect(endValue).toBe('2024-12-20T10:00')
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows valid date range on new trip form', async ({ page }) => {
|
||||||
|
// Wait for the form to load
|
||||||
|
await page.waitForSelector('input[name="trip[started_at]"]')
|
||||||
|
|
||||||
|
// Fill in trip name
|
||||||
|
await page.fill('input[name="trip[name]"]', 'Valid Test Trip')
|
||||||
|
|
||||||
|
// Set valid date range (start before end)
|
||||||
|
await page.fill('input[name="trip[started_at]"]', '2024-12-20T10:00')
|
||||||
|
await page.fill('input[name="trip[ended_at]"]', '2024-12-25T10:00')
|
||||||
|
|
||||||
|
// Trigger blur to validate
|
||||||
|
await page.locator('input[name="trip[ended_at]"]').blur()
|
||||||
|
|
||||||
|
// Give the validation time to run
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Check that the end date field has no validation error
|
||||||
|
const endDateInput = page.locator('input[name="trip[ended_at]"]')
|
||||||
|
const validationMessage = await endDateInput.evaluate(el => el.validationMessage)
|
||||||
|
const isValid = await endDateInput.evaluate(el => el.validity.valid)
|
||||||
|
|
||||||
|
expect(validationMessage).toBe('')
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates dates when updating end date to be earlier than start date', async ({ page }) => {
|
||||||
|
// Wait for the form to load
|
||||||
|
await page.waitForSelector('input[name="trip[started_at]"]')
|
||||||
|
|
||||||
|
// Fill in trip name
|
||||||
|
await page.fill('input[name="trip[name]"]', 'Test Trip')
|
||||||
|
|
||||||
|
// First set a valid range
|
||||||
|
await page.fill('input[name="trip[started_at]"]', '2024-12-20T10:00')
|
||||||
|
await page.fill('input[name="trip[ended_at]"]', '2024-12-25T10:00')
|
||||||
|
|
||||||
|
// Now change start date to be after end date
|
||||||
|
await page.fill('input[name="trip[started_at]"]', '2024-12-26T10:00')
|
||||||
|
|
||||||
|
// Get the current URL to verify we stay on the same page
|
||||||
|
const currentUrl = page.url()
|
||||||
|
|
||||||
|
// Try to submit the form
|
||||||
|
const submitButton = page.locator('input[type="submit"], button[type="submit"]')
|
||||||
|
await submitButton.click()
|
||||||
|
|
||||||
|
// Wait a bit for potential navigation
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
// Verify we're still on the same page (form wasn't submitted)
|
||||||
|
expect(page.url()).toBe(currentUrl)
|
||||||
|
|
||||||
|
// Verify the dates are still there (form wasn't cleared)
|
||||||
|
const startValue = await page.locator('input[name="trip[started_at]"]').inputValue()
|
||||||
|
const endValue = await page.locator('input[name="trip[ended_at]"]').inputValue()
|
||||||
|
expect(startValue).toBe('2024-12-26T10:00')
|
||||||
|
expect(endValue).toBe('2024-12-25T10:00')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,33 @@ RSpec.describe Trip, type: :model do
|
||||||
it { is_expected.to validate_presence_of(:name) }
|
it { is_expected.to validate_presence_of(:name) }
|
||||||
it { is_expected.to validate_presence_of(:started_at) }
|
it { is_expected.to validate_presence_of(:started_at) }
|
||||||
it { is_expected.to validate_presence_of(:ended_at) }
|
it { is_expected.to validate_presence_of(:ended_at) }
|
||||||
|
|
||||||
|
context 'date range validation' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
it 'is valid when started_at is before ended_at' do
|
||||||
|
trip = build(:trip, user: user, started_at: 1.day.ago, ended_at: Time.current)
|
||||||
|
expect(trip).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is invalid when started_at is after ended_at' do
|
||||||
|
trip = build(:trip, user: user, started_at: Time.current, ended_at: 1.day.ago)
|
||||||
|
expect(trip).not_to be_valid
|
||||||
|
expect(trip.errors[:ended_at]).to include('must be after start date')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is invalid when started_at equals ended_at' do
|
||||||
|
time = Time.current
|
||||||
|
trip = build(:trip, user: user, started_at: time, ended_at: time)
|
||||||
|
expect(trip).not_to be_valid
|
||||||
|
expect(trip.errors[:ended_at]).to include('must be after start date')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is valid when both dates are blank during initialization' do
|
||||||
|
trip = Trip.new(user: user, name: 'Test Trip')
|
||||||
|
expect(trip.errors[:ended_at]).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'associations' do
|
describe 'associations' do
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,39 @@ RSpec.describe Stats::HexagonCalculator do
|
||||||
expect(total_points).to eq(2)
|
expect(total_points).to eq(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when there are too many hexagons' do
|
||||||
|
let(:h3_resolution) { 15 } # Very high resolution to trigger MAX_HEXAGONS
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Stub to simulate too many hexagons on first call, then acceptable on second
|
||||||
|
allow_any_instance_of(described_class).to receive(:calculate_h3_indexes).and_call_original
|
||||||
|
call_count = 0
|
||||||
|
allow_any_instance_of(described_class).to receive(:calculate_h3_indexes) do |instance, points, resolution|
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1
|
||||||
|
# First call: return too many hexagons
|
||||||
|
Hash.new.tap do |hash|
|
||||||
|
(described_class::MAX_HEXAGONS + 1).times do |i|
|
||||||
|
hash[i.to_s(16)] = [1, timestamp1, timestamp1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# Second call with lower resolution: return acceptable amount
|
||||||
|
{ '8c2a1072b3f1fff' => [2, timestamp1, timestamp2] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'recursively reduces resolution when too many hexagons are generated' do
|
||||||
|
result = calculate_hexagons
|
||||||
|
|
||||||
|
expect(result).to be_an(Array)
|
||||||
|
expect(result).not_to be_empty
|
||||||
|
# Should have successfully reduced the hexagon count
|
||||||
|
expect(result.size).to be < described_class::MAX_HEXAGONS
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when H3 raises an error' do
|
context 'when H3 raises an error' do
|
||||||
before do
|
before do
|
||||||
allow(H3).to receive(:from_geo_coordinates).and_raise(StandardError, 'H3 error')
|
allow(H3).to receive(:from_geo_coordinates).and_raise(StandardError, 'H3 error')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue