Compare commits

..

70 commits

Author SHA1 Message Date
Evgenii Burmakin
24cbabf3b7
Map v2 will no longer block the UI when Immich/Photoprism integration has a bad URL or is unreachable (#2113) 2026-01-03 13:45:12 +01:00
dependabot[bot]
8ecb2e3765
Bump trix from 2.1.15 to 2.1.16 in the npm_and_yarn group across 1 directory (#2098)
* 0.37.1 (#2092)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

* Fix migrations and data verification to remove safety_assured blocks and handle missing points gracefully.

* Update version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump trix in the npm_and_yarn group across 1 directory

Bumps the npm_and_yarn group with 1 update in the / directory: [trix](https://github.com/basecamp/trix).


Updates `trix` from 2.1.15 to 2.1.16
- [Release notes](https://github.com/basecamp/trix/releases)
- [Commits](https://github.com/basecamp/trix/compare/v2.1.15...v2.1.16)

---
updated-dependencies:
- dependency-name: trix
  dependency-version: 2.1.16
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-03 13:33:16 +01:00
Evgenii Burmakin
b037be3299
Update calculation of time spent in a country for year-end digest email (#2110)
* Update calculation of time spent in a country for year-end digest email

* Add a filter to exclude raw data points when calculating yearly digests.
2026-01-03 13:28:21 +01:00
Eugene Burmakin
b4c2def2be Merge remote-tracking branch 'origin' into dev 2025-12-30 19:05:30 +01:00
Eugene Burmakin
8a1e42a2e8 Update version 2025-12-30 19:04:15 +01:00
Eugene Burmakin
2f11003c29 Fix migrations and data verification to remove safety_assured blocks and handle missing points gracefully. 2025-12-30 19:02:38 +01:00
Eugene Burmakin
7f277612fc Update rubocop issues 2025-12-30 17:33:17 +01:00
Eugene Burmakin
2f5487cd35 Update migration and version 2025-12-30 16:57:17 +01:00
Eugene Burmakin
5455228b80 Bring points_count to integer 2025-12-30 16:57:17 +01:00
Evgenii Burmakin
26062a1278
Add RailsPulse (#2079)
* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication
2025-12-28 17:49:51 +01:00
Eugene Burmakin
14a0bb6478 Update changelog 2025-12-28 17:34:11 +01:00
Evgenii Burmakin
18b13fb915
Add yearly digest (#2073)
* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page
2025-12-28 17:33:35 +01:00
Evgenii Burmakin
e857f520cc
Add new indicies to improve performance and remove unused ones to opt… (#2078)
* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling
2025-12-28 17:32:09 +01:00
Evgenii Burmakin
9e933aff9c
Fix/stats cache invalidation (#2072)
* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment
2025-12-27 13:33:54 +01:00
Eugene Burmakin
a722e19a93 Deduplicte points based on timestamp brought to unix time 2025-12-26 19:10:02 +01:00
Eugene Burmakin
67d7123e47 Add composite index to points on user_id and timestamp 2025-12-26 18:21:06 +01:00
Eugene Burmakin
573d527455 Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses. 2025-12-26 17:49:19 +01:00
Eugene Burmakin
4be58d4b4c Update changelog 2025-12-26 17:07:48 +01:00
Evgenii Burmakin
3f436c1d3a
Fix fog of war radius setting being ignored and applying settings causing errors (#2068) 2025-12-26 17:06:56 +01:00
Eugene Burmakin
fe9d7d2f79 Merge remote-tracking branch 'origin' into dev 2025-12-26 17:05:26 +01:00
Eugene Burmakin
fab0121113 Update migration to clean up duplicate stats before adding unique index 2025-12-26 16:28:34 +01:00
Evgenii Burmakin
9805c5524c
Validate trip start and end dates (#2066)
* Validate trip start and end dates

* Update changelog
2025-12-26 16:16:49 +01:00
Evgenii Burmakin
f325fd7a4f
Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)
* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md
2025-12-26 15:42:32 +01:00
Robin Tuszik
3c1d17b806
fix null type error and update heatmap styling (#2037)
* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo
2025-12-26 15:27:51 +01:00
Evgenii Burmakin
c9ba7914b6
Put import deletion into background job (#2045)
* Put import deletion into background job

* Update changelog
2025-12-26 15:27:09 +01:00
dependabot[bot]
03697ecef2
Bump redis from 5.4.0 to 5.4.1 (#1941)
Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-26 15:23:39 +01:00
dependabot[bot]
7347be9a87
Bump brakeman from 7.1.0 to 7.1.1 (#1942)
Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-26 15:23:19 +01:00
dependabot[bot]
ce74b3d846
Bump webmock from 3.25.1 to 3.26.1 (#1943)
Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>
2025-12-26 15:22:55 +01:00
dependabot[bot]
da9742bf4a
Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>
2025-12-26 15:22:05 +01:00
dependabot[bot]
e12b45f93e
Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-26 15:21:25 +01:00
Eugene Burmakin
32f5d2f89a Update tailwind file 2025-12-26 14:57:12 +01:00
Eugene Burmakin
ad385f4464 Update changelog 2025-12-26 14:41:55 +01:00
Eugene Burmakin
d4e87ce830 Return changelog 2025-12-26 14:39:36 +01:00
Eugene Burmakin
04fbe4d564 Merge remote-tracking branch 'origin' into dev 2025-12-26 14:39:16 +01:00
Eugene Burmakin
1471e4de40 Update changelog 2025-12-26 14:33:56 +01:00
Evgenii Burmakin
9ef0da27d6
Add family layer to MapLibre maps (#2055)
* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled
2025-12-26 14:27:16 +01:00
Eugene Burmakin
87baf8bb11 Update entrypoint to always sync static assets (not only new ones) 2025-12-16 18:30:07 +01:00
Eugene Burmakin
d40b2a1959 Update changelog 2025-12-14 22:22:16 +01:00
Eugene Burmakin
35995e7be8 Add composite index to stats table if not exists 2025-12-14 22:19:58 +01:00
Eugene Burmakin
20a4553921 Ensure file is being closed properly after reading in Archivable concern 2025-12-14 11:40:33 +01:00
Eugene Burmakin
c1bb7f3d87 Remove raw_data_archival_job 2025-12-14 11:37:34 +01:00
Eugene Burmakin
0b6149bfc0 Update changelog 2025-12-14 11:34:50 +01:00
Eugene Burmakin
f2d96e50f0 Add help section to navbar dropdown 2025-12-14 11:31:40 +01:00
Eugene Burmakin
1090bcd6e8 Use Toast instead of alert for notifications 2025-12-14 00:32:12 +01:00
Eugene Burmakin
b7f0b7ebc2 Return .keep files 2025-12-14 00:05:34 +01:00
Eugene Burmakin
b81d2580e3 Fix potential memory leak in js 2025-12-14 00:04:42 +01:00
Eugene Burmakin
acee848e72 Eliminate zip-bomb risk 2025-12-13 23:52:47 +01:00
Evgenii Burmakin
88f5e2a6ea
Add verification step to raw data archival process (#2028)
* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs
2025-12-13 21:25:06 +01:00
Robin Tuszik
353837e27f
fix(maplibre): update date format to ISO 8601 (#2029) 2025-12-12 00:21:21 +01:00
Evgenii Burmakin
2a4ed8bf82
Implement moving points in map v2 and fix route rendering logic to ma… (#2027)
* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec
2025-12-10 19:58:31 +01:00
Evgenii Burmakin
8af032a215
Fix kml kmz import issues (#2023)
* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability
2025-12-09 19:37:27 +01:00
Eugene Burmakin
bb980f2210 Update changelog 2025-12-09 00:22:47 +01:00
Eugene Burmakin
c6d09c341d Update redis client configuration to support unix socket connection 2025-12-09 00:19:32 +01:00
Evgenii Burmakin
516cfabb06
Fix/pre epoch time (#2019)
* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs
2025-12-09 00:17:24 +01:00
Evgenii Burmakin
9ac4566b5a
Use user timezone to show dates on maps (#2020) 2025-12-08 22:12:17 +01:00
Evgenii Burmakin
1c9843dde7
Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)
* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint
2025-12-08 21:38:56 +01:00
Eugene Burmakin
6cc8ba0fbd Merge branch 'master' into dev 2025-12-08 19:52:05 +01:00
Eugene Burmakin
913d60812a Fix storage configuration and file extraction 2025-12-08 19:51:28 +01:00
Eugene Burmakin
a7f77b042e Set raw_data to an empty hash instead of nil when archiving 2025-12-07 23:58:05 +01:00
Eugene Burmakin
cdf1428e35 Merge remote-tracking branch 'origin' into dev 2025-12-07 14:39:30 +01:00
Evgenii Burmakin
9661e8e7f7
Feature/raw data archive (#2009)
* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-07 14:33:23 +01:00
Eugene Burmakin
2debcd88fa Pull only necessary data for map v2 points 2025-12-06 21:23:17 +01:00
Evgenii Burmakin
672c308f67
Merge branch 'master' into dev 2025-12-06 20:54:29 +01:00
Eugene Burmakin
c5ef4d3861 Remove some console.log statements 2025-12-06 20:42:52 +01:00
Eugene Burmakin
9fb4bc517b Update changelog 2025-12-06 20:39:47 +01:00
Eugene Burmakin
97d52f9edc Update maplibre controller 2025-12-06 20:36:52 +01:00
Evgenii Burmakin
4421a5bf3c
Feature/maplibre frontend (#1953)
* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode
2025-12-06 20:34:49 +01:00
Eugene Burmakin
cebbc28912 Update changelog 2025-11-29 19:58:57 +01:00
Evgenii Burmakin
ac9b668c30
Update exporting code to stream points data to file in batches to red… (#1980)
* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog
2025-11-27 21:29:59 +01:00
Robin Tuszik
6772f2f7b7
fix: move foreman to global gems to fix startup crash (#1971) 2025-11-25 20:30:34 +01:00
32 changed files with 148 additions and 619 deletions

View file

@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.37.2] - 2026-01-04
# [0.37.2] - 2026-01-03
## Fixed
@ -12,7 +12,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Time spent in a country and city is now calculated correctly for the year-end digest email. #2104
- Updated Trix to fix a XSS vulnerability. #2102
- Map v2 UI no longer blocks when Immich/Photoprism integration has a bad URL or is unreachable. Added 10-second timeout to photo API requests and improved error handling to prevent UI freezing during initial load. #2085
- In Map v2 settings, you can now enable map to be rendered as a globe.
# [0.37.1] - 2025-12-30

View file

@ -12,7 +12,6 @@ gem 'aws-sdk-kms', '~> 1.96.0', require: false
gem 'aws-sdk-s3', '~> 1.177.0', require: false
gem 'bootsnap', require: false
gem 'chartkick'
gem 'connection_pool', '< 3' # Pin to 2.x - version 3.0+ has breaking API changes with Rails RedisCacheStore
gem 'data_migrate'
gem 'devise'
gem 'foreman'
@ -49,7 +48,7 @@ gem 'rswag-ui'
gem 'rubyzip', '~> 3.2'
gem 'sentry-rails', '>= 5.27.0'
gem 'sentry-ruby'
gem 'sidekiq', '8.0.10' # Pin to 8.0.x - sidekiq 8.1+ requires connection_pool 3.0+ which has breaking changes with Rails
gem 'sidekiq', '>= 8.0.5'
gem 'sidekiq-cron', '>= 2.3.1'
gem 'sidekiq-limit_fetch'
gem 'sprockets-rails'

View file

@ -109,7 +109,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.5.0)
bigdecimal (4.0.1)
bigdecimal (3.3.1)
bindata (2.5.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
@ -129,10 +129,10 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chartkick (5.2.1)
chartkick (5.2.0)
chunky_png (1.4.0)
coderay (1.1.3)
concurrent-ruby (1.3.6)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
crack (1.0.1)
bigdecimal
@ -215,7 +215,7 @@ GEM
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.8)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.2)
actionpack (>= 6.0.0)
@ -227,7 +227,7 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.18.0)
json (2.15.0)
json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
@ -273,12 +273,11 @@ GEM
method_source (1.1.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.1)
prism (~> 1.5)
minitest (5.26.2)
msgpack (1.7.3)
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
net-http (0.6.0)
uri
net-imap (0.5.12)
@ -357,7 +356,7 @@ GEM
json
yaml
parallel (1.27.0)
parser (3.3.10.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
patience_diff (1.2.0)
@ -370,7 +369,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.7.0)
prism (1.5.1)
prometheus_exporter (2.2.0)
webrick
pry (0.15.2)
@ -463,7 +462,7 @@ GEM
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.26.2)
redis-client (0.26.1)
connection_pool
regexp_parser (2.11.3)
reline (0.6.3)
@ -513,7 +512,7 @@ GEM
rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rubocop (1.82.1)
rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -521,20 +520,20 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-rails (2.34.2)
prism (~> 1.4)
rubocop-rails (2.33.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (1.13.0)
rubyzip (3.2.2)
rubyzip (3.2.0)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
base64 (~> 0.2)
@ -542,15 +541,15 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.2.0)
sentry-rails (6.1.1)
railties (>= 5.2.0)
sentry-ruby (~> 6.2.0)
sentry-ruby (6.2.0)
sentry-ruby (~> 6.1.1)
sentry-ruby (6.1.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (8.0.10)
sidekiq (8.0.8)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@ -614,7 +613,7 @@ GEM
unicode (0.4.4.5)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
unicode-emoji (4.1.0)
uri (1.1.1)
useragent (0.16.11)
validate_url (1.0.15)
@ -663,7 +662,6 @@ DEPENDENCIES
bundler-audit
capybara
chartkick
connection_pool (< 3)
data_migrate
database_consistency (>= 2.0.5)
debug
@ -713,7 +711,7 @@ DEPENDENCIES
sentry-rails (>= 5.27.0)
sentry-ruby
shoulda-matchers
sidekiq (= 8.0.10)
sidekiq (>= 8.0.5)
sidekiq-cron (>= 2.3.1)
sidekiq-limit_fetch
simplecov

View file

@ -31,7 +31,7 @@ class Api::V1::SettingsController < ApiController
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
:maps_v2_style, :maps_maplibre_style, :globe_projection,
:maps_v2_style, :maps_maplibre_style,
enabled_map_layers: []
)
end

View file

@ -6,7 +6,7 @@ class Users::DigestsController < ApplicationController
before_action :authenticate_user!
before_action :authenticate_active_user!, only: [:create]
before_action :set_digest, only: %i[show destroy]
before_action :set_digest, only: [:show]
def index
@digests = current_user.digests.yearly.order(year: :desc)
@ -30,12 +30,6 @@ class Users::DigestsController < ApplicationController
end
end
def destroy
year = @digest.year
@digest.destroy!
redirect_to users_digests_path, notice: "Year-end digest for #{year} has been deleted", status: :see_other
end
private
def set_digest
@ -48,7 +42,7 @@ class Users::DigestsController < ApplicationController
tracked_years = current_user.stats.select(:year).distinct.pluck(:year)
existing_digests = current_user.digests.yearly.pluck(:year)
(tracked_years - existing_digests - [Time.current.year]).sort.reverse
(tracked_years - existing_digests).sort.reverse
end
def valid_year?(year)

View file

@ -2,27 +2,6 @@
module Users
module DigestsHelper
PROGRESS_COLORS = %w[
progress-primary progress-secondary progress-accent
progress-info progress-success progress-warning
].freeze
def progress_color_for_index(index)
PROGRESS_COLORS[index % PROGRESS_COLORS.length]
end
def city_progress_value(city_count, max_cities)
return 0 unless max_cities&.positive?
(city_count.to_f / max_cities * 100).round
end
def max_cities_count(toponyms)
return 0 if toponyms.blank?
toponyms.map { |country| country['cities']&.length || 0 }.max
end
def distance_with_unit(distance_meters, unit)
value = Users::Digest.convert_distance(distance_meters, unit).round
"#{number_with_delimiter(value)} #{unit}"

View file

@ -16,35 +16,17 @@ export class MapInitializer {
mapStyle = 'streets',
center = [0, 0],
zoom = 2,
showControls = true,
globeProjection = false
showControls = true
} = settings
const style = await getMapStyle(mapStyle)
const mapOptions = {
const map = new maplibregl.Map({
container,
style,
center,
zoom
}
const map = new maplibregl.Map(mapOptions)
// Set globe projection after map loads
if (globeProjection === true || globeProjection === 'true') {
map.on('load', () => {
map.setProjection({ type: 'globe' })
// Add atmosphere effect
map.setSky({
'atmosphere-blend': [
'interpolate', ['linear'], ['zoom'],
0, 1, 5, 1, 7, 0
]
})
})
}
})
if (showControls) {
map.addControl(new maplibregl.NavigationControl(), 'top-right')

View file

@ -91,11 +91,6 @@ export class SettingsController {
mapStyleSelect.value = this.settings.mapStyle || 'light'
}
// Sync globe projection toggle
if (controller.hasGlobeToggleTarget) {
controller.globeToggleTarget.checked = this.settings.globeProjection || false
}
// Sync fog of war settings
const fogRadiusInput = controller.element.querySelector('input[name="fogOfWarRadius"]')
if (fogRadiusInput) {
@ -183,22 +178,6 @@ export class SettingsController {
}
}
/**
* Toggle globe projection
* Requires page reload to apply since projection is set at map initialization
*/
async toggleGlobe(event) {
const enabled = event.target.checked
await SettingsManager.updateSetting('globeProjection', enabled)
Toast.info('Globe view will be applied after page reload')
// Prompt user to reload
if (confirm('Globe view requires a page reload to take effect. Reload now?')) {
window.location.reload()
}
}
/**
* Update route opacity in real-time
*/

View file

@ -64,8 +64,6 @@ export default class extends Controller {
'speedColoredToggle',
'speedColorScaleContainer',
'speedColorScaleInput',
// Globe projection
'globeToggle',
// Family members
'familyMembersList',
'familyMembersContainer',
@ -149,8 +147,7 @@ export default class extends Controller {
*/
async initializeMap() {
this.map = await MapInitializer.initialize(this.containerTarget, {
mapStyle: this.settings.mapStyle,
globeProjection: this.settings.globeProjection
mapStyle: this.settings.mapStyle
})
}
@ -246,7 +243,6 @@ export default class extends Controller {
updateFogThresholdDisplay(event) { return this.settingsController.updateFogThresholdDisplay(event) }
updateMetersBetweenDisplay(event) { return this.settingsController.updateMetersBetweenDisplay(event) }
updateMinutesBetweenDisplay(event) { return this.settingsController.updateMinutesBetweenDisplay(event) }
toggleGlobe(event) { return this.settingsController.toggleGlobe(event) }
// Area Selection Manager methods
startSelectArea() { return this.areaSelectionManager.startSelectArea() }

View file

@ -14,8 +14,7 @@ const DEFAULT_SETTINGS = {
minutesBetweenRoutes: 60,
pointsRenderingMode: 'raw',
speedColoredRoutes: false,
speedColorScale: '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300',
globeProjection: false
speedColorScale: '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
}
// Mapping between v2 layer names and v1 layer names in enabled_map_layers array
@ -42,8 +41,7 @@ const BACKEND_SETTINGS_MAP = {
minutesBetweenRoutes: 'minutes_between_routes',
pointsRenderingMode: 'points_rendering_mode',
speedColoredRoutes: 'speed_colored_routes',
speedColorScale: 'speed_color_scale',
globeProjection: 'globe_projection'
speedColorScale: 'speed_color_scale'
}
export class SettingsManager {
@ -154,8 +152,6 @@ export class SettingsManager {
value = parseInt(value) || DEFAULT_SETTINGS.minutesBetweenRoutes
} else if (frontendKey === 'speedColoredRoutes') {
value = value === true || value === 'true'
} else if (frontendKey === 'globeProjection') {
value = value === true || value === 'true'
}
frontendSettings[frontendKey] = value
@ -223,8 +219,6 @@ export class SettingsManager {
value = parseInt(value).toString()
} else if (frontendKey === 'speedColoredRoutes') {
value = Boolean(value)
} else if (frontendKey === 'globeProjection') {
value = Boolean(value)
}
backendSettings[backendKey] = value

View file

@ -4,7 +4,6 @@ class Users::Digests::CalculatingJob < ApplicationJob
queue_as :digests
def perform(user_id, year)
recalculate_monthly_stats(user_id, year)
Users::Digests::CalculateYear.new(user_id, year).call
rescue StandardError => e
create_digest_failed_notification(user_id, e)
@ -12,12 +11,6 @@ class Users::Digests::CalculatingJob < ApplicationJob
private
def recalculate_monthly_stats(user_id, year)
(1..12).each do |month|
Stats::CalculateMonth.new(user_id, year, month).call
end
end
def create_digest_failed_notification(user_id, error)
user = User.find(user_id)

View file

@ -45,13 +45,18 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
def countries_visited
Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do
countries_visited_uncached
points
.without_raw_data
.where.not(country_name: [nil, ''])
.distinct
.pluck(:country_name)
.compact
end
end
def cities_visited
Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do
cities_visited_uncached
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
end
end
@ -134,47 +139,17 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
Time.zone.name
end
# Aggregate countries from all stats' toponyms
# This is more accurate than raw point queries as it uses processed data
def countries_visited_uncached
countries = Set.new
stats.find_each do |stat|
toponyms = stat.toponyms
next unless toponyms.is_a?(Array)
toponyms.each do |toponym|
next unless toponym.is_a?(Hash)
countries.add(toponym['country']) if toponym['country'].present?
end
end
countries.to_a.sort
points
.without_raw_data
.where.not(country_name: [nil, ''])
.distinct
.pluck(:country_name)
.compact
end
# Aggregate cities from all stats' toponyms
# This respects MIN_MINUTES_SPENT_IN_CITY since toponyms are already filtered
def cities_visited_uncached
cities = Set.new
stats.find_each do |stat|
toponyms = stat.toponyms
next unless toponyms.is_a?(Array)
toponyms.each do |toponym|
next unless toponym.is_a?(Hash)
next unless toponym['cities'].is_a?(Array)
toponym['cities'].each do |city|
next unless city.is_a?(Hash)
cities.add(city['city']) if city['city'].present?
end
end
end
cities.to_a.sort
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
end
def home_place_coordinates

View file

@ -132,11 +132,6 @@ class Users::Digest < ApplicationRecord
(all_time_stats['total_distance'] || 0).to_i
end
def untracked_days
days_in_year = Date.leap?(year) ? 366 : 365
[days_in_year - total_tracked_days, 0].max.round(1)
end
def distance_km
distance.to_f / 1000
end
@ -156,15 +151,4 @@ class Users::Digest < ApplicationRecord
def generate_sharing_uuid
self.sharing_uuid ||= SecureRandom.uuid
end
def total_tracked_days
(total_tracked_minutes / 1440.0).round(1)
end
def total_tracked_minutes
# Use total_country_minutes if available (new digests),
# fall back to summing top_countries_by_time (existing digests)
time_spent_by_location['total_country_minutes'] ||
top_countries_by_time.sum { |country| country['minutes'].to_i }
end
end

View file

@ -42,8 +42,7 @@ class Api::UserSerializer
photoprism_url: user.safe_settings.photoprism_url,
visits_suggestions_enabled: user.safe_settings.visits_suggestions_enabled?,
speed_color_scale: user.safe_settings.speed_color_scale,
fog_of_war_threshold: user.safe_settings.fog_of_war_threshold,
globe_projection: user.safe_settings.globe_projection
fog_of_war_threshold: user.safe_settings.fog_of_war_threshold
}
end

View file

@ -49,17 +49,6 @@ class CountriesAndCities
end
def calculate_duration_in_minutes(timestamps)
return 0 if timestamps.size < 2
sorted = timestamps.sort
total_minutes = 0
gap_threshold_seconds = ::MIN_MINUTES_SPENT_IN_CITY * 60
sorted.each_cons(2) do |prev_ts, curr_ts|
interval_seconds = curr_ts - prev_ts
total_minutes += (interval_seconds / 60) if interval_seconds < gap_threshold_seconds
end
total_minutes
((timestamps.max - timestamps.min).to_i / 60)
end
end

View file

@ -3,8 +3,6 @@
module Users
module Digests
class CalculateYear
MINUTES_PER_DAY = 1440
def initialize(user_id, year)
@user = ::User.find(user_id)
@year = year.to_i
@ -52,7 +50,7 @@ module Users
next unless toponym.is_a?(Hash)
country = toponym['country']
next if country.blank?
next unless country.present?
if toponym['cities'].is_a?(Array)
toponym['cities'].each do |city|
@ -66,7 +64,7 @@ module Users
end
end
country_cities.sort_by { |_country, cities| -cities.size }.map do |country, cities|
country_cities.sort_by { |country, _| country }.map do |country, cities|
{
'country' => country,
'cities' => cities.to_a.sort.map { |city| { 'city' => city } }
@ -90,69 +88,36 @@ module Users
end
def calculate_time_spent
country_minutes = calculate_actual_country_minutes
{
'countries' => format_top_countries(country_minutes),
'cities' => calculate_city_time_spent,
'total_country_minutes' => country_minutes.values.sum
'countries' => calculate_country_time_spent,
'cities' => calculate_city_time_spent
}
end
def format_top_countries(country_minutes)
country_minutes
def calculate_country_time_spent
country_days = build_country_days_map
# Convert days to minutes (days * 24 * 60) and return top 10
country_days
.transform_values { |days| days.size * 24 * 60 }
.sort_by { |_, minutes| -minutes }
.first(10)
.map { |name, minutes| { 'name' => name, 'minutes' => minutes } }
end
def calculate_actual_country_minutes
points_by_date = group_points_by_date
country_minutes = Hash.new(0)
def build_country_days_map
year_points = fetch_year_points_with_country
country_days = Hash.new { |h, k| h[k] = Set.new }
points_by_date.each do |_date, day_points|
countries_on_day = day_points.map(&:country_name).uniq
if countries_on_day.size == 1
# Single country day - assign full day
country_minutes[countries_on_day.first] += MINUTES_PER_DAY
else
# Multi-country day - calculate proportional time
calculate_proportional_time(day_points, country_minutes)
end
year_points.each do |point|
date = Time.zone.at(point.timestamp).to_date
country_days[point.country_name].add(date)
end
country_minutes
country_days
end
def group_points_by_date
points = fetch_year_points_with_country_ordered
points.group_by do |point|
Time.zone.at(point.timestamp).to_date
end
end
def calculate_proportional_time(day_points, country_minutes)
country_spans = Hash.new(0)
points_by_country = day_points.group_by(&:country_name)
points_by_country.each do |country, country_points|
timestamps = country_points.map(&:timestamp)
span_seconds = timestamps.max - timestamps.min
# Minimum 60 seconds (1 min) for single-point countries
country_spans[country] = [span_seconds, 60].max
end
total_spans = country_spans.values.sum.to_f
country_spans.each do |country, span|
proportional_minutes = (span / total_spans * MINUTES_PER_DAY).round
country_minutes[country] += proportional_minutes
end
end
def fetch_year_points_with_country_ordered
def fetch_year_points_with_country
start_of_year = Time.zone.local(year, 1, 1, 0, 0, 0)
end_of_year = start_of_year.end_of_year
@ -161,7 +126,6 @@ module Users
.where('timestamp >= ? AND timestamp <= ?', start_of_year.to_i, end_of_year.to_i)
.where.not(country_name: [nil, ''])
.select(:country_name, :timestamp)
.order(timestamp: :asc)
end
def calculate_city_time_spent
@ -216,8 +180,8 @@ module Users
def calculate_all_time_stats
{
'total_countries' => user.countries_visited_uncached.size,
'total_cities' => user.cities_visited_uncached.size,
'total_countries' => user.countries_visited.count,
'total_cities' => user.cities_visited.count,
'total_distance' => user.stats.sum(:distance).to_s
}
end

View file

@ -35,7 +35,7 @@ class Users::ExportData::Points
output_file.write('[')
user.points.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, _batch_index|
user.points.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, batch_index|
batch_sql = build_batch_query(batch.map(&:id))
result = ActiveRecord::Base.connection.exec_query(batch_sql, 'Points Export Batch')
@ -188,13 +188,13 @@ class Users::ExportData::Points
}
end
return unless row['visit_name']
point_hash['visit_reference'] = {
'name' => row['visit_name'],
'started_at' => row['visit_started_at'],
'ended_at' => row['visit_ended_at']
}
if row['visit_name']
point_hash['visit_reference'] = {
'name' => row['visit_name'],
'started_at' => row['visit_started_at'],
'ended_at' => row['visit_ended_at']
}
end
end
def log_progress(processed, total)

View file

@ -22,8 +22,7 @@ class Users::SafeSettings
'visits_suggestions_enabled' => 'true',
'enabled_map_layers' => %w[Routes Heatmap],
'maps_maplibre_style' => 'light',
'digest_emails_enabled' => true,
'globe_projection' => false
'digest_emails_enabled' => true
}.freeze
def initialize(settings = {})
@ -53,8 +52,7 @@ class Users::SafeSettings
speed_color_scale: speed_color_scale,
fog_of_war_threshold: fog_of_war_threshold,
enabled_map_layers: enabled_map_layers,
maps_maplibre_style: maps_maplibre_style,
globe_projection: globe_projection
maps_maplibre_style: maps_maplibre_style
}
end
# rubocop:enable Metrics/MethodLength
@ -143,10 +141,6 @@ class Users::SafeSettings
settings['maps_maplibre_style']
end
def globe_projection
ActiveModel::Type::Boolean.new.cast(settings['globe_projection'])
end
def digest_emails_enabled?
value = settings['digest_emails_enabled']
return true if value.nil?

View file

@ -365,19 +365,6 @@
</select>
</div>
<!-- Globe Projection -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
name="globeProjection"
class="toggle toggle-primary"
data-maps--maplibre-target="globeToggle"
data-action="change->maps--maplibre#toggleGlobe" />
<span class="label-text font-medium">Globe View</span>
</label>
<p class="text-sm text-base-content/60 mt-1">Render map as a 3D globe (requires page reload)</p>
</div>
<div class="divider"></div>
<!-- Route Opacity -->

View file

@ -142,19 +142,6 @@
<span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
</div>
<% end %>
<% if @digest.untracked_days > 0 %>
<div class="flex justify-between items-center p-3 bg-base-100 rounded-lg border-2 border-dashed border-gray-200">
<div class="flex items-center gap-3">
<span class="badge badge-lg badge-ghost">?</span>
<span class="text-gray-500 italic">No tracking data</span>
</div>
<span class="text-gray-500"><%= pluralize(@digest.untracked_days.round, 'day') %></span>
</div>
<p class="text-sm text-gray-500 mt-2 flex items-center justify-center gap-2">
<%= icon 'lightbulb' %> Track more in <%= @digest.year + 1 %> to see a fuller picture of your travels!
</p>
<% end %>
</div>
</div>
</div>
@ -168,7 +155,14 @@
</h2>
<div class="space-y-4 w-full">
<% if @digest.toponyms.present? %>
<% max_cities = @digest.toponyms.map { |country| country['cities']&.length || 0 }.max %>
<% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %>
<% @digest.toponyms.each_with_index do |country, index| %>
<% cities_count = country['cities']&.length || 0 %>
<% progress_value = max_cities&.positive? ? (cities_count.to_f / max_cities * 100).round : 0 %>
<% color_class = progress_colors[index % progress_colors.length] %>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="font-semibold">
@ -176,10 +170,10 @@
<%= country['country'] %>
</span>
<span class="text-sm">
<%= pluralize(country['cities']&.length || 0, 'city') %>
<%= pluralize(cities_count, 'city') %>
</span>
</div>
<progress class="progress <%= progress_color_for_index(index) %> w-full" value="<%= city_progress_value(country['cities']&.length || 0, max_cities_count(@digest.toponyms)) %>" max="100"></progress>
<progress class="progress <%= color_class %> w-full" value="<%= progress_value %>" max="100"></progress>
</div>
<% end %>
<% else %>
@ -220,12 +214,6 @@
<button class="btn btn-outline" onclick="sharing_modal.showModal()">
<%= icon 'share' %> Share
</button>
<%= button_to users_digest_path(year: @digest.year),
method: :delete,
class: 'btn btn-outline btn-error',
data: { turbo_confirm: "Are you sure you want to delete the #{@digest.year} digest? This cannot be undone." } do %>
<%= icon 'trash-2' %> Delete
<% end %>
</div>
</div>

View file

@ -250,24 +250,13 @@
<div class="stat-card">
<div class="stat-label">Where You Spent the Most Time</div>
<ul class="location-list">
<% @digest.top_countries_by_time.take(5).each do |country| %>
<% @digest.top_countries_by_time.take(3).each do |country| %>
<li>
<span><%= country_flag(country['name']) %> <%= country['name'] %></span>
<span><%= format_time_spent(country['minutes']) %></span>
</li>
<% end %>
<% if @digest.untracked_days > 0 %>
<li style="border-top: 2px dashed #e2e8f0; padding-top: 12px; margin-top: 4px;">
<span style="color: #94a3b8; font-style: italic;">No tracking data</span>
<span style="color: #94a3b8;"><%= pluralize(@digest.untracked_days.round, 'day') %></span>
</li>
<% end %>
</ul>
<% if @digest.untracked_days > 0 %>
<p style="color: #64748b; font-size: 13px; margin-top: 12px;">
💡 Track more in <%= @digest.year + 1 %> to see a fuller picture of your travels!
</p>
<% end %>
</div>
<% end %>

View file

@ -101,8 +101,8 @@ Rails.application.routes.draw do
# User digests routes (yearly/monthly digest reports)
scope module: 'users' do
resources :digests, only: %i[index create show destroy], param: :year, as: :users_digests,
constraints: { year: /\d{4}/ }
resources :digests, only: %i[index create], param: :year, as: :users_digests
get 'digests/:year', to: 'digests#show', as: :users_digest, constraints: { year: /\d{4}/ }
end
get 'shared/digest/:uuid', to: 'shared/digests#show', as: :shared_users_digest
patch 'digests/:year/sharing',

1
db/schema.rb generated
View file

@ -522,7 +522,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_03_114630) do
add_foreign_key "notifications", "users"
add_foreign_key "place_visits", "places"
add_foreign_key "place_visits", "visits"
add_foreign_key "points", "points_raw_data_archives", column: "raw_data_archive_id", name: "fk_rails_points_raw_data_archives", on_delete: :nullify, validate: false
add_foreign_key "points", "points_raw_data_archives", column: "raw_data_archive_id", on_delete: :nullify
add_foreign_key "points", "users"
add_foreign_key "points", "visits"

View file

@ -1,5 +1,4 @@
import { test as setup, expect } from '@playwright/test';
import { disableGlobeProjection } from '../v2/helpers/setup.js';
const authFile = 'e2e/temp/.auth/user.json';
@ -20,9 +19,6 @@ setup('authenticate', async ({ page }) => {
// Wait for successful navigation to map (v1 or v2 depending on user preference)
await page.waitForURL(/\/map(\/v[12])?/, { timeout: 10000 });
// Disable globe projection to ensure consistent E2E test behavior
await disableGlobeProjection(page);
// Save authentication state
await page.context().storageState({ path: authFile });
});

View file

@ -2,33 +2,6 @@
* Helper functions for Maps V2 E2E tests
*/
/**
* Disable globe projection setting via API
* This ensures consistent map rendering for E2E tests
* @param {Page} page - Playwright page object
*/
export async function disableGlobeProjection(page) {
// Get API key from the page (requires being logged in)
const apiKey = await page.evaluate(() => {
const metaTag = document.querySelector('meta[name="api-key"]');
return metaTag?.content;
});
if (apiKey) {
await page.request.patch('/api/v1/settings', {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
data: {
settings: {
globe_projection: false
}
}
});
}
}
/**
* Navigate to Maps V2 page
* @param {Page} page - Playwright page object

View file

@ -163,16 +163,12 @@ RSpec.describe User, type: :model do
describe '#countries_visited' do
subject { user.countries_visited }
let!(:stat) do
create(:stat, user:, toponyms: [
{ 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin', 'stayed_for' => 120 }] },
{ 'country' => 'France', 'cities' => [{ 'city' => 'Paris', 'stayed_for' => 90 }] },
{ 'country' => nil, 'cities' => [] },
{ 'country' => '', 'cities' => [] }
])
end
let!(:point1) { create(:point, user:, country_name: 'Germany') }
let!(:point2) { create(:point, user:, country_name: 'France') }
let!(:point3) { create(:point, user:, country_name: nil) }
let!(:point4) { create(:point, user:, country_name: '') }
it 'returns array of countries from stats toponyms' do
it 'returns array of countries' do
expect(subject).to include('Germany', 'France')
expect(subject.count).to eq(2)
end
@ -185,18 +181,12 @@ RSpec.describe User, type: :model do
describe '#cities_visited' do
subject { user.cities_visited }
let!(:stat) do
create(:stat, user:, toponyms: [
{ 'country' => 'Germany', 'cities' => [
{ 'city' => 'Berlin', 'stayed_for' => 120 },
{ 'city' => nil, 'stayed_for' => 60 },
{ 'city' => '', 'stayed_for' => 60 }
] },
{ 'country' => 'France', 'cities' => [{ 'city' => 'Paris', 'stayed_for' => 90 }] }
])
end
let!(:point1) { create(:point, user:, city: 'Berlin') }
let!(:point2) { create(:point, user:, city: 'Paris') }
let!(:point3) { create(:point, user:, city: nil) }
let!(:point4) { create(:point, user:, city: '') }
it 'returns array of cities from stats toponyms' do
it 'returns array of cities' do
expect(subject).to include('Berlin', 'Paris')
expect(subject.count).to eq(2)
end
@ -220,15 +210,11 @@ RSpec.describe User, type: :model do
describe '#total_countries' do
subject { user.total_countries }
let!(:stat) do
create(:stat, user:, toponyms: [
{ 'country' => 'Germany', 'cities' => [] },
{ 'country' => 'France', 'cities' => [] },
{ 'country' => nil, 'cities' => [] }
])
end
let!(:point1) { create(:point, user:, country_name: 'Germany') }
let!(:point2) { create(:point, user:, country_name: 'France') }
let!(:point3) { create(:point, user:, country_name: nil) }
it 'returns number of countries from stats toponyms' do
it 'returns number of countries' do
expect(subject).to eq(2)
end
end
@ -236,17 +222,11 @@ RSpec.describe User, type: :model do
describe '#total_cities' do
subject { user.total_cities }
let!(:stat) do
create(:stat, user:, toponyms: [
{ 'country' => 'Germany', 'cities' => [
{ 'city' => 'Berlin', 'stayed_for' => 120 },
{ 'city' => 'Paris', 'stayed_for' => 90 },
{ 'city' => nil, 'stayed_for' => 60 }
] }
])
end
let!(:point1) { create(:point, user:, city: 'Berlin') }
let!(:point2) { create(:point, user:, city: 'Paris') }
let!(:point3) { create(:point, user:, city: nil) }
it 'returns number of cities from stats toponyms' do
it 'returns number of cities' do
expect(subject).to eq(2)
end
end

View file

@ -27,14 +27,6 @@ RSpec.describe '/digests', type: :request do
expect(response.status).to eq(302)
end
end
describe 'DELETE /destroy' do
it 'redirects to the sign in page' do
delete users_digest_url(year: 2024)
expect(response).to redirect_to(new_user_session_path)
end
end
end
context 'when user is signed in' do
@ -145,40 +137,5 @@ RSpec.describe '/digests', type: :request do
end
end
end
describe 'DELETE /destroy' do
let!(:digest) { create(:users_digest, user:, year: 2024) }
it 'deletes the digest' do
expect do
delete users_digest_url(year: 2024)
end.to change(Users::Digest, :count).by(-1)
end
it 'redirects with success notice' do
delete users_digest_url(year: 2024)
expect(response).to redirect_to(users_digests_path)
expect(flash[:notice]).to eq('Year-end digest for 2024 has been deleted')
end
it 'returns not found for non-existent digest' do
delete users_digest_url(year: 2020)
expect(response).to redirect_to(users_digests_path)
expect(flash[:alert]).to eq('Digest not found')
end
it 'cannot delete another user digest' do
other_user = create(:user)
other_digest = create(:users_digest, user: other_user, year: 2023)
delete users_digest_url(year: 2023)
expect(response).to redirect_to(users_digests_path)
expect(flash[:alert]).to eq('Digest not found')
expect(other_digest.reload).to be_present
end
end
end
end

View file

@ -79,58 +79,6 @@ RSpec.describe CountriesAndCities do
)
end
end
context 'when points have a gap larger than threshold (passing through)' do
let(:points) do
[
# User in Berlin at 9:00, leaves, returns at 11:00
create(:point, city: 'Berlin', country: 'Germany', timestamp:),
create(:point, city: 'Berlin', country: 'Germany', timestamp: timestamp + 15.minutes),
# 105-minute gap here (user left the city)
create(:point, city: 'Berlin', country: 'Germany', timestamp: timestamp + 120.minutes),
create(:point, city: 'Berlin', country: 'Germany', timestamp: timestamp + 130.minutes)
]
end
it 'only counts time between consecutive points within threshold' do
# Old logic would count 130 minutes (span from first to last)
# New logic counts: 15 min (0->15) + 10 min (120->130) = 25 minutes
# Since 25 < 60, Berlin should be filtered out
expect(countries_and_cities).to eq(
[
CountriesAndCities::CountryData.new(
country: 'Germany',
cities: []
)
]
)
end
end
context 'when points span a long time but have continuous presence' do
let(:points) do
# Points every 30 minutes for 2.5 hours = continuous presence
(0..5).map do |i|
create(:point, city: 'Berlin', country: 'Germany', timestamp: timestamp + (i * 30).minutes)
end
end
it 'counts the full duration when all intervals are within threshold' do
# 5 intervals of 30 minutes each = 150 minutes total
expect(countries_and_cities).to eq(
[
CountriesAndCities::CountryData.new(
country: 'Germany',
cities: [
CountriesAndCities::CityData.new(
city: 'Berlin', points: 6, timestamp: (timestamp + 150.minutes).to_i, stayed_for: 150
)
]
)
]
)
end
end
end
end
end

View file

@ -61,18 +61,16 @@ RSpec.describe Points::RawData::Verifier do
end.not_to change { archive.reload.verified_at }
end
it 'still verifies successfully when points are deleted from database' do
it 'detects deleted points' do
# Force archive creation first
archive_id = archive.id
# Then delete one point from database
points.first.destroy
# Verification should still succeed - deleted points are acceptable
# (users should be able to delete their data without failing archive verification)
expect do
verifier.verify_specific_archive(archive_id)
end.to change { archive.reload.verified_at }.from(nil)
end.not_to change { archive.reload.verified_at }
end
it 'detects raw_data mismatch between archive and database' do

View file

@ -155,14 +155,10 @@ RSpec.describe Stats::CalculateMonth do
context 'when user visited multiple cities with mixed durations' do
let!(:mixed_points) do
[
# Berlin: 70 minutes with continuous presence (should be included)
# Points every 35 minutes: 0, 35, 70 = 70 min total
# Berlin: 70 minutes (should be included)
create(:point, user:, import:, timestamp: timestamp_base,
city: 'Berlin', country_name: 'Germany',
lonlat: 'POINT(13.404954 52.520008)'),
create(:point, user:, import:, timestamp: timestamp_base + 35.minutes,
city: 'Berlin', country_name: 'Germany',
lonlat: 'POINT(13.404954 52.520008)'),
create(:point, user:, import:, timestamp: timestamp_base + 70.minutes,
city: 'Berlin', country_name: 'Germany',
lonlat: 'POINT(13.404954 52.520008)'),
@ -175,17 +171,10 @@ RSpec.describe Stats::CalculateMonth do
city: 'Prague', country_name: 'Czech Republic',
lonlat: 'POINT(14.4378 50.0755)'),
# Vienna: 90 minutes with continuous presence (should be included)
# Points every 30 minutes: 150, 180, 210, 240 = 90 min total
# Vienna: 90 minutes (should be included)
create(:point, user:, import:, timestamp: timestamp_base + 150.minutes,
city: 'Vienna', country_name: 'Austria',
lonlat: 'POINT(16.3738 48.2082)'),
create(:point, user:, import:, timestamp: timestamp_base + 180.minutes,
city: 'Vienna', country_name: 'Austria',
lonlat: 'POINT(16.3738 48.2082)'),
create(:point, user:, import:, timestamp: timestamp_base + 210.minutes,
city: 'Vienna', country_name: 'Austria',
lonlat: 'POINT(16.3738 48.2082)'),
create(:point, user:, import:, timestamp: timestamp_base + 240.minutes,
city: 'Vienna', country_name: 'Austria',
lonlat: 'POINT(16.3738 48.2082)')

View file

@ -76,30 +76,22 @@ RSpec.describe Users::Digests::CalculateYear do
expect(calculate_digest.monthly_distances['3']).to eq('0') # Missing month
end
it 'calculates time spent by location using hybrid day-based approach' do
# Create points to test hybrid calculation
# Jan 1: single country day (Germany) -> full 1440 minutes
jan_1_10am = Time.zone.local(2024, 1, 1, 10, 0, 0).to_i
jan_1_11am = Time.zone.local(2024, 1, 1, 11, 0, 0).to_i
jan_1_12pm = Time.zone.local(2024, 1, 1, 12, 0, 0).to_i
# Feb 1: single country day (France) -> full 1440 minutes
feb_1_10am = Time.zone.local(2024, 2, 1, 10, 0, 0).to_i
it 'calculates time spent by location' do
# Create points to enable country time calculation based on unique days
jan_1 = Time.zone.local(2024, 1, 1, 10, 0, 0).to_i
jan_2 = Time.zone.local(2024, 1, 2, 10, 0, 0).to_i
feb_1 = Time.zone.local(2024, 2, 1, 10, 0, 0).to_i
create(:point, user: user, timestamp: jan_1_10am, country_name: 'Germany', city: 'Berlin')
create(:point, user: user, timestamp: jan_1_11am, country_name: 'Germany', city: 'Berlin')
create(:point, user: user, timestamp: jan_1_12pm, country_name: 'Germany', city: 'Munich')
create(:point, user: user, timestamp: feb_1_10am, country_name: 'France', city: 'Paris')
create(:point, user: user, timestamp: jan_1, country_name: 'Germany', city: 'Berlin')
create(:point, user: user, timestamp: jan_2, country_name: 'Germany', city: 'Munich')
create(:point, user: user, timestamp: feb_1, country_name: 'France', city: 'Paris')
countries = calculate_digest.time_spent_by_location['countries']
cities = calculate_digest.time_spent_by_location['cities']
# Germany: 1 full day = 1440 minutes
# Countries: based on unique days (2 days in Germany, 1 day in France)
germany_country = countries.find { |c| c['name'] == 'Germany' }
expect(germany_country['minutes']).to eq(1440)
# France: 1 full day = 1440 minutes
france_country = countries.find { |c| c['name'] == 'France' }
expect(france_country['minutes']).to eq(1440)
expect(germany_country['minutes']).to eq(2 * 24 * 60) # 2 days = 2880 minutes
# Cities: based on stayed_for from monthly stats (sum across months)
expect(cities.first['name']).to eq('Berlin')
@ -111,37 +103,35 @@ RSpec.describe Users::Digests::CalculateYear do
end
context 'when user visits same country across multiple months' do
it 'counts each day as a full day for single-country days' do
# Create hourly points across multiple days in March and July
it 'does not double-count days' do
# Create a user who was in Germany for 10 days in March and 10 days in July
# If we summed the stayed_for values from cities, we might get inflated numbers
# The fix counts unique days to prevent exceeding 365 days per year
mar_start = Time.zone.local(2024, 3, 1, 10, 0, 0).to_i
jul_start = Time.zone.local(2024, 7, 1, 10, 0, 0).to_i
# Create 3 days of hourly points in March
3.times do |day|
3.times do |hour|
timestamp = mar_start + (day * 24 * 60 * 60) + (hour * 60 * 60)
create(:point, user: user, timestamp: timestamp, country_name: 'Germany', city: 'Berlin')
end
# Create 10 days of points in March
10.times do |i|
timestamp = mar_start + (i * 24 * 60 * 60)
create(:point, user: user, timestamp: timestamp, country_name: 'Germany', city: 'Berlin')
end
# Create 3 days of hourly points in July
3.times do |day|
3.times do |hour|
timestamp = jul_start + (day * 24 * 60 * 60) + (hour * 60 * 60)
create(:point, user: user, timestamp: timestamp, country_name: 'Germany', city: 'Munich')
end
# Create 10 days of points in July
10.times do |i|
timestamp = jul_start + (i * 24 * 60 * 60)
create(:point, user: user, timestamp: timestamp, country_name: 'Germany', city: 'Munich')
end
# Create the monthly stats
# Create the monthly stats (simulating what would be created by the stats calculation)
create(:stat, user: user, year: 2024, month: 3, distance: 10_000, toponyms: [
{ 'country' => 'Germany', 'cities' => [
{ 'city' => 'Berlin', 'stayed_for' => 14_400 }
{ 'city' => 'Berlin', 'stayed_for' => 14_400 } # 10 days in minutes
] }
])
create(:stat, user: user, year: 2024, month: 7, distance: 15_000, toponyms: [
{ 'country' => 'Germany', 'cities' => [
{ 'city' => 'Munich', 'stayed_for' => 14_400 }
{ 'city' => 'Munich', 'stayed_for' => 14_400 } # 10 days in minutes
] }
])
@ -149,93 +139,13 @@ RSpec.describe Users::Digests::CalculateYear do
countries = digest.time_spent_by_location['countries']
germany = countries.find { |c| c['name'] == 'Germany' }
# Each single-country day = 1440 minutes
# 6 days total (3 in March + 3 in July) = 6 * 1440 = 8640 minutes
expect(germany['minutes']).to eq(6 * 1440)
# Should be 20 days total (10 unique days in Mar + 10 unique days in Jul)
expected_minutes = 20 * 24 * 60 # 28,800 minutes
expect(germany['minutes']).to eq(expected_minutes)
# Total should equal exactly 6 days
total_days = germany['minutes'] / 1440.0
expect(total_days).to eq(6)
end
end
context 'when there are large gaps between points on same day' do
it 'still counts the full day for single-country day' do
point_1 = Time.zone.local(2024, 1, 1, 10, 0, 0).to_i
point_2 = Time.zone.local(2024, 1, 1, 12, 0, 0).to_i # 2 hours later
point_3 = Time.zone.local(2024, 1, 1, 18, 0, 0).to_i # 6 hours later
create(:point, user: user, timestamp: point_1, country_name: 'Germany')
create(:point, user: user, timestamp: point_2, country_name: 'Germany')
create(:point, user: user, timestamp: point_3, country_name: 'Germany')
digest = calculate_digest
germany = digest.time_spent_by_location['countries'].find { |c| c['name'] == 'Germany' }
# Hybrid approach: single-country day = full 1440 minutes
# regardless of gaps between points
expect(germany['minutes']).to eq(1440)
end
end
context 'when transitioning between countries on same day' do
it 'calculates proportional time based on time spans' do
# Multi-country day: Germany 10:00-10:30, France 11:00-11:30
point_1 = Time.zone.local(2024, 1, 1, 10, 0, 0).to_i
point_2 = Time.zone.local(2024, 1, 1, 10, 30, 0).to_i # In Germany
point_3 = Time.zone.local(2024, 1, 1, 11, 0, 0).to_i # Now in France
point_4 = Time.zone.local(2024, 1, 1, 11, 30, 0).to_i # Still in France
create(:point, user: user, timestamp: point_1, country_name: 'Germany')
create(:point, user: user, timestamp: point_2, country_name: 'Germany')
create(:point, user: user, timestamp: point_3, country_name: 'France')
create(:point, user: user, timestamp: point_4, country_name: 'France')
digest = calculate_digest
countries = digest.time_spent_by_location['countries']
germany = countries.find { |c| c['name'] == 'Germany' }
france = countries.find { |c| c['name'] == 'France' }
# Germany span: 10:30 - 10:00 = 30 min = 1800 seconds
# France span: 11:30 - 11:00 = 30 min = 1800 seconds
# Total spans = 3600 seconds
# Each country gets 50% of 1440 = 720 minutes
expect(germany['minutes']).to eq(720)
expect(france['minutes']).to eq(720)
# Total = 1440 (exactly one day)
expect(germany['minutes'] + france['minutes']).to eq(1440)
end
end
context 'when visiting multiple countries on same day' do
it 'calculates proportional time and never exceeds one day total' do
# This tests the fix for the original bug: border crossing should not count double
# France: 8am-9am (1 hour span = 3600 seconds)
# Germany: 10am-11am (1 hour span = 3600 seconds)
jan_1_8am = Time.zone.local(2024, 1, 1, 8, 0, 0).to_i
jan_1_9am = Time.zone.local(2024, 1, 1, 9, 0, 0).to_i
jan_1_10am = Time.zone.local(2024, 1, 1, 10, 0, 0).to_i # Border crossing
jan_1_11am = Time.zone.local(2024, 1, 1, 11, 0, 0).to_i
create(:point, user: user, timestamp: jan_1_8am, country_name: 'France')
create(:point, user: user, timestamp: jan_1_9am, country_name: 'France')
create(:point, user: user, timestamp: jan_1_10am, country_name: 'Germany')
create(:point, user: user, timestamp: jan_1_11am, country_name: 'Germany')
digest = calculate_digest
countries = digest.time_spent_by_location['countries']
france = countries.find { |c| c['name'] == 'France' }
germany = countries.find { |c| c['name'] == 'Germany' }
# France span: 3600 seconds, Germany span: 3600 seconds
# Total spans: 7200 seconds
# Each gets 50% of 1440 = 720 minutes
expect(france['minutes']).to eq(720)
expect(germany['minutes']).to eq(720)
# Total = 1440 (exactly one day) - NOT 2 days as the bug would have caused
expect(france['minutes'] + germany['minutes']).to eq(1440)
# Verify this is less than 365 days (the bug would cause inflated numbers)
total_days = germany['minutes'] / (24 * 60)
expect(total_days).to be <= 365
end
end

View file

@ -31,8 +31,7 @@ RSpec.describe Users::SafeSettings do
speed_color_scale: nil,
fog_of_war_threshold: nil,
enabled_map_layers: %w[Routes Heatmap],
maps_maplibre_style: 'light',
globe_projection: false
maps_maplibre_style: 'light'
}
)
end
@ -83,8 +82,7 @@ RSpec.describe Users::SafeSettings do
'visits_suggestions_enabled' => false,
'enabled_map_layers' => %w[Points Routes Areas Photos],
'maps_maplibre_style' => 'light',
'digest_emails_enabled' => true,
'globe_projection' => false
'digest_emails_enabled' => true
}
)
end
@ -112,8 +110,7 @@ RSpec.describe Users::SafeSettings do
speed_color_scale: nil,
fog_of_war_threshold: nil,
enabled_map_layers: %w[Points Routes Areas Photos],
maps_maplibre_style: 'light',
globe_projection: false
maps_maplibre_style: 'light'
}
)
end