Skip to content
Merged

1.10.2 #3199

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
04e91a5
fix: quiet handled geocoder provider errors
Freika Jul 14, 2026
8756850
Retry reverse geocoding point write timeouts
Freika Jul 15, 2026
7ae4859
Retry transient OwnTracks point write timeouts
Freika Jul 17, 2026
dba153f
Prevent reverse-geocoding place deadlocks
Freika Jul 18, 2026
0ae855c
Prevent Null Island cleanup failures on legacy points
Freika Jul 20, 2026
bcf4982
Retry point write contention in the shared upsert path
Freika Jul 20, 2026
1f8b8bc
Move changelog entry to the Unreleased section
Freika Jul 20, 2026
c869059
Move changelog entry to the Unreleased section
Freika Jul 20, 2026
0e59ea5
Fold the Null Island changelog entry into a single line
Freika Jul 20, 2026
8977454
Cover lock-wait timeouts and retry exhaustion in reverse geocoding wr…
Freika Jul 20, 2026
2c40d7a
Merge pull request #3189 from Freika/fix/owntracks-write-timeout
Freika Jul 20, 2026
845a935
Merge branch 'dev' into fix/null-island-guards
Freika Jul 20, 2026
f8ab3f0
Merge pull request #3197 from Freika/fix/null-island-guards
Freika Jul 20, 2026
4a749a5
Merge pull request #3186 from Freika/fix/reverse-geocoding-lock-timeout
Freika Jul 20, 2026
eec1be0
Merge pull request #3183 from Freika/fix/reverse-geocoding-provider-e…
Freika Jul 20, 2026
1ec0814
Merge pull request #3193 from Freika/fix/place-bulk-write-deadlock
Freika Jul 20, 2026
93481cf
Stop the legacy lat/lon drop from crash-looping startup
Freika Jul 20, 2026
36d4e31
Only treat transient geocoder failures as handled provider errors
Freika Jul 20, 2026
43afcc6
Reuse the TLS helper and tighten the changelog wording
Freika Jul 20, 2026
9604b76
Merge branch 'dev' into fix/reverse-geocoding-provider-errors
Freika Jul 20, 2026
8416968
fix: delegate non-string DNS names to the original resolver
SAY-5 Jul 22, 2026
f35d7db
Fix legacy trial email retries
Freika Jul 25, 2026
5fff92f
Merge branch 'dev' into fix/legacy-trial-emails
Freika Jul 25, 2026
dfe9d2d
Merge pull request #3180 from Freika/fix/legacy-trial-emails
Freika Jul 25, 2026
59439e6
test: cover the DNS cache path and log the fix
Freika Jul 25, 2026
bf04182
Merge pull request #3205 from SAY-5/fix/dns-cache-nil-name
Freika Jul 25, 2026
f0256b5
Allow saving large-area posters
Freika Jul 25, 2026
a59c5e9
Add custom raster and style basemap URLs
Freika Jul 25, 2026
877e74e
Rescue QueryAborted and survive an unreachable queue
Freika Jul 25, 2026
5c5a424
Bound the drop by lock_timeout alone and retry quietly
Freika Jul 25, 2026
8a07b53
Bind the drop timeouts to the transaction that runs it
Freika Jul 27, 2026
d7bc525
fix: make visit suggestion consistent and predictable
Freika Jul 27, 2026
63d7684
feat: add a Poster Studio track width control
Freika Jul 27, 2026
b1a9f16
docs: reference the reported issues in the changelog entries
Freika Jul 27, 2026
c57ad0b
Merge branch 'dev' into fix/visits-consistency
Freika Jul 27, 2026
b15faf2
test: cover the track width clamp floor and negative guard
Freika Jul 27, 2026
66c0e7c
Shorten the lock wait and point the logs at the manual drop
Freika Jul 27, 2026
3f0b82e
Merge pull request #3238 from Freika/feat/poster-track-width
Freika Jul 27, 2026
e788640
Merge branch 'dev' into fix/poster-large-area-save
Freika Jul 27, 2026
6695be9
Wait long enough for the batched writers to let go
Freika Jul 27, 2026
5d35267
Merge pull request #3237 from Freika/fix/visits-consistency
Freika Jul 27, 2026
3f38965
fix: judge the poster area check against the rendered frame
Freika Jul 27, 2026
7fe2c4d
Merge branch 'dev' into fix/lat-lon-drop-lock-crashloop
Freika Jul 27, 2026
abea28d
Merge pull request #3198 from Freika/fix/lat-lon-drop-lock-crashloop
Freika Jul 27, 2026
3f38cad
Merge branch 'dev' into fix/poster-large-area-save
Freika Jul 27, 2026
d2f2bbd
Merge pull request #3230 from Freika/fix/poster-large-area-save
Freika Jul 27, 2026
36fcd3b
Update app version
Freika Jul 27, 2026
f91cacd
fix: keep custom basemaps loaded and restore layers after a style swap
Freika Jul 27, 2026
ffd8baf
Merge branch 'dev' into feature/custom-basemap-formats
Freika Jul 27, 2026
70597ac
Merge pull request #3231 from Freika/feature/custom-basemap-formats
Freika Jul 27, 2026
903d4b6
fix: lock place names customised before name locking existed
Freika Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .app_version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.10.1
1.10.2
23 changes: 21 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,30 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).


## Unreleased
## [1.10.2] - 2026-07-27, Berlin

### Added

- Poster Studio has a track width control: a 50–300% slider beside track opacity that scales the route line on the saved poster.
- The Map v2 custom basemap field now accepts raster XYZ tiles (`.png`/`.jpg`/`.jpeg`/`.webp`) and full MapLibre style URLs ending in `.json`, in addition to Protomaps-schema vector tiles. Previously a raster URL rendered as a blank grey map and a style URL was rejected. (#3146)

### Fixed

- Saving a zoomed-out Poster Studio view to the gallery no longer rejects routes that are visibly inside the poster frame (#3204). The area check now uses the same Mercator framing as the renderer and wraps across the antimeridian, so high-latitude and Pacific-centred posters are judged against the frame you actually see. Poster Studio also warns when a view is too wide for the largest poster area instead of silently zooming the saved poster in.
- Instances with heavy write traffic no longer crash-loop on the 1.10.1 upgrade. Dropping the legacy `points.latitude`/`points.longitude` columns needs an exclusive lock that busy instances could not win in one attempt, which aborted the migration and restarted the container in a loop. The drop is now retried, and if it still cannot get the lock it is handed to a background job so startup completes. If that job cannot get the lock either, the columns stay and the log prints the statement to run by hand — they are unused, so nothing breaks in the meantime (#3176)
- Trial lifecycle email jobs left over from older releases are now discarded instead of retrying forever in the background queue. Mail addressed to a record that has since been deleted is also discarded rather than retried.
- Reverse geocoding and place-name provider outages no longer flood error reporting with handled timeouts, dropped TLS connections, or invalid provider responses. A misconfigured or rate-limited provider — a bad API key, for example — is still reported.
- Reverse geocoding retries point updates that time out while waiting on concurrent writes.
- Google Semantic History and phone Timeline imports now tag points with a per-import tracker id instead of one shared constant, so tracks from different devices are no longer braided together. A one-time backfill rewrites existing points and regenerates affected tracks per user.
- Points at exactly (0,0) — a common GPS glitch — are no longer accepted from any ingestion path (API, OwnTracks, Overland, Traccar, file imports) and no longer produce suggested visits at "Null Island". Existing (0,0) points are flagged as anomalies by a one-time cleanup that also removes visits placed at (0,0) and recalculates affected stats and tracks.
- Place names you set yourself are no longer overwritten by nightly reverse geocoding. Renaming a place, creating one by hand, or picking one on the timeline locks its name; renaming it back to "Suggested place" hands it back to auto-naming. Map v2 and the place drawer show when a name is locked. A one-time backfill locks names that were customised before this release (#3086, #3175)
- Real-time visit detection no longer stops after the first run for users who track continuously — the debounce key is now released when the job runs.
- The nightly visit suggestion job no longer scans forward to the end of the calendar year; it processes only the day it was asked for.
- Merged visits now report the correct duration, centre, radius and suggested name instead of keeping the values of the first cluster in the merge.
- Visit suggestion failures no longer show a raw stack trace in your notifications, and repeated failures within an hour no longer create a notification each time (#3091)
- Points at exactly (0,0) — a common GPS glitch — are no longer accepted from any ingestion path (API, OwnTracks, Overland, Traccar, file imports) and no longer produce suggested visits at "Null Island". Existing (0,0) points are flagged as anomalies by a one-time cleanup that also removes visits placed at (0,0), tolerates legacy points without timestamps, and recalculates affected stats and tracks.
- Point uploads from all ingestion paths (REST API, OwnTracks, Overland, Traccar) now retry transient statement and lock-wait timeouts, not just deadlocks, instead of failing the upload.
- The DNS caching layer no longer crashes with a misleading `NoMethodError` when the SMTP server is not configured in the background worker, so email delivery surfaces the real configuration error instead. (#3038)


## [1.10.1] - 2026-07-19, Berlin

Expand Down Expand Up @@ -40,6 +58,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Track generation and user data recalculations now retry a bounded number of times when another job is already processing the same user's tracks, instead of dropping the request and reporting an error; a genuinely stuck lock is logged after the retries are exhausted. The per-user lock also renews itself while a job runs and frees within a minute if a worker dies, so a crashed job no longer blocks a user's track processing for up to half an hour.
- Dawarich added to an iOS Home Screen now opens Map v2 instead of an unrelated previously visited page (#3097)
- Point uploads (REST API, OwnTracks, Overland, Traccar) now write batches in a consistent order so concurrent uploads no longer deadlock each other, and both uploads and anomaly filtering recover automatically from any remaining transient database deadlocks instead of failing the upload or background job.
- Reverse geocoding overlapping places no longer exhausts retries because of concurrent database deadlocks.
- The app and Sidekiq containers no longer crash-loop on startup when `WEB_CONCURRENCY` or `BACKGROUND_PROCESSING_CONCURRENCY` reach the container as an unexpanded `${VAR:-default}` string (seen with some podman-compose versions); the entrypoint now warns and falls back to the default value (#3124)
- Cache preheating no longer times out for accounts with large location histories.
- Cloud: Changing plans resets the Lite archival-warning state, so a user downgraded to Lite again is notified about archived data again.
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/v1/places_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def show

def create
@place = current_api_user.places.build(place_params.except(:tag_ids))
@place.user_named = true

if @place.save
add_tags if tag_ids.present?
Expand Down Expand Up @@ -180,6 +181,7 @@ def serialize_place(place)
icon: place.tags.first&.icon,
color: place.tags.first&.color,
visits_count: place.visits.size,
name_locked: place.name_locked?,
created_at: place.created_at,
tags: place.tags.map do |tag|
{
Expand Down
19 changes: 18 additions & 1 deletion app/controllers/api/v1/settings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def update
unless valid_tiles_url?(settings)
return render json: {
message: 'Something went wrong',
errors: ['Tile URL must include {z}, {x}, and {y} placeholders']
errors: [TILE_URL_ERROR]
}, status: :unprocessable_content
end

Expand Down Expand Up @@ -65,6 +65,8 @@ def recalculation_status_manager
MAP_CUSTOMIZATION_KEYS = %i[maps_maplibre_custom_theme maps_maplibre_tiles_url
route_color track_color].freeze
TILE_URL_PLACEHOLDERS = %w[{z} {x} {y}].freeze
TILE_URL_ERROR = 'Tile URL must include {z}, {x}, and {y} placeholders, ' \
'or be a MapLibre style URL ending in .json'

def settings_params
permitted = params.require(:settings).permit(
Expand Down Expand Up @@ -119,7 +121,22 @@ def valid_tiles_url?(settings)
url = settings[:maps_maplibre_tiles_url]
return true if url.nil?
return false unless url.is_a?(String)
return true if style_json_url?(url)

TILE_URL_PLACEHOLDERS.all? { |placeholder| url.include?(placeholder) }
end

# Mirrors classifyBasemapUrl in app/javascript/maps_maplibre/utils/basemap_url.js:
# an absolute http(s) URL, or a root-relative path for a style served from
# this instance. Keep the two in sync or the browser accepts a URL this
# rejects.
def style_json_url?(url)
uri = URI.parse(url)
return false unless uri.path.to_s.downcase.end_with?('.json')
return uri.host.present? if uri.is_a?(URI::HTTP)

uri.scheme.nil? && uri.host.nil? && uri.path.start_with?('/')
rescue URI::InvalidURIError
false
end
end
1 change: 1 addition & 0 deletions app/controllers/places_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def show

def create
@place = current_user.places.build(place_params.except(:tag_ids))
@place.user_named = true

if @place.save
add_tags if tag_ids.present?
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/posters_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@ def destroy

def poster_params
params.require(:poster).permit(:name, :title, :lat, :lon, :distance, :theme, :start_at, :end_at, :source,
:route_fill, :route_opacity)
:route_fill, :route_opacity, :route_width)
end
end
1 change: 1 addition & 0 deletions app/javascript/controllers/maps/maplibre/data_loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ export class DataLoader {
latitude: place.latitude,
longitude: place.longitude,
note: place.note,
nameLocked: Boolean(place.name_locked),
// Stringify tags for MapLibre GL JS compatibility
tags: JSON.stringify(place.tags || []),
// Use first tag's color if available
Expand Down
5 changes: 5 additions & 0 deletions app/javascript/controllers/maps/maplibre/event_handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ export class EventHandlers {
<div class="space-y-2">
${properties.tag ? `<div class="badge badge-sm badge-primary">${escapeHtml(properties.tag)}</div>` : ""}
${properties.description ? `<div>${escapeHtml(properties.description)}</div>` : ""}
${
properties.nameLocked
? `<div class="text-xs opacity-70" data-testid="place-name-lock">🔒 You named this place, so automatic naming won't change it. Rename it to "Suggested place" to hand it back.</div>`
: ""
}
</div>
`

Expand Down
33 changes: 33 additions & 0 deletions app/javascript/controllers/maps/maplibre/map_initializer.js
Original file line number Diff line number Diff line change
@@ -1,103 +1,136 @@
import maplibregl from "maplibre-gl"
import { Toast } from "maps_maplibre/components/toast"
import { styleDocumentFailed } from "maps_maplibre/utils/basemap_url"
import { getMapStyle } from "maps_maplibre/utils/style_manager"

/**
* Handles map initialization for Maps V2
*/
export class MapInitializer {
/**
* Initialize MapLibre map instance
* @param {HTMLElement} container - The container element for the map
* @param {Object} settings - Map settings (style, center, zoom)
* @returns {Promise<maplibregl.Map>} The initialized map instance
*/
static async initialize(container, settings = {}) {
const {
mapStyle = "streets",
center = [0, 0],
zoom = 2,
showControls = true,
globeProjection = false,
hiddenTileCategories = [],
disabledPoiGroups = [],
customTheme = null,
vectorTilesUrl = null,
} = settings

const style = await getMapStyle(mapStyle, {
hiddenTileCategories,
disabledPoiGroups,
customTheme,
vectorTilesUrl,
})

const mapOptions = {
container,
style,
center,
zoom,
attributionControl: false,
}

const map = new maplibregl.Map(mapOptions)

if (typeof style === "string") {
let settled = false

const onStyleLoad = () => {
if (settled) return
settled = true
map.off("error", onError)
}
// Tile, sprite and glyph failures also surface as `error`. Reverting on
// those would discard a custom style that loaded perfectly well, so only
// a failure of the style document itself counts.
const onError = async (event) => {
if (settled || !styleDocumentFailed(event, style)) return
settled = true
map.off("style.load", onStyleLoad)
map.off("error", onError)
Toast.error(
"Custom map style could not be loaded; reverting to the default style.",
)
const fallbackStyle = await getMapStyle(mapStyle, {
hiddenTileCategories,
disabledPoiGroups,
customTheme,
})
map.setStyle(fallbackStyle, { diff: false })
}

map.once("style.load", onStyleLoad)
map.on("error", onError)
}

// 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")
}

map.addControl(
new maplibregl.AttributionControl({ compact: true }),
"bottom-right",
)

return map
}

/**
* Fit map to bounds of GeoJSON features
* @param {maplibregl.Map} map - The map instance
* @param {Object} geojson - GeoJSON FeatureCollection
* @param {Object} options - Fit bounds options
*/
static fitToBounds(map, geojson, options = {}) {
const { padding = 50, maxZoom = 15 } = options

if (!geojson?.features?.length) {
console.warn("[MapInitializer] No features to fit bounds to")
return
}

const coordinates = geojson.features.map((f) => f.geometry.coordinates)

const bounds = coordinates.reduce((bounds, coord) => {
return bounds.extend(coord)
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))

map.fitBounds(bounds, {
padding,
maxZoom,
})
}
}

Check warning on line 136 in app/javascript/controllers/maps/maplibre/map_initializer.js

View workflow job for this annotation

GitHub Actions / quality

lint/complexity/noStaticOnlyClass

Avoid classes that contain only static members.
98 changes: 81 additions & 17 deletions app/javascript/controllers/maps/maplibre/settings_manager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Toast } from "maps_maplibre/components/toast"
import { UpgradeBanner } from "maps_maplibre/components/upgrade_banner"
import {
classifyBasemapUrl,
styleDocumentFailed,
} from "maps_maplibre/utils/basemap_url"
import { isGatedPlan } from "maps_maplibre/utils/layer_gate"
import {
LAYER_COLOR_DEFAULTS,
Expand Down Expand Up @@ -1116,25 +1120,75 @@ export class SettingsController {
async applyMapStyle(styleName) {
this.syncStyleDependentToggles(styleName)
const style = await getMapStyle(styleName, {
hiddenTileCategories:
SettingsManager.getSetting("hiddenTileCategories") || [],
disabledPoiGroups: SettingsManager.getSetting("disabledPoiGroups") || [],
customTheme: SettingsManager.getSetting("customTheme"),
...this.mapStyleOptions(),
vectorTilesUrl: SettingsManager.getSetting("vectorTilesUrl"),
})

// Clear layer references
this.layerManager.clearLayerReferences()

this.map.setStyle(style)
if (typeof style === "string") {
this.applyUserStyleUrl(style, styleName)
return
}

// Reload layers after style change. setStyle replaces the whole style
// document — including the projection — so globe mode must be restored
// or every style/theme change silently drops back to mercator.
this.map.once("style.load", () => {
this.restoreGlobeProjection()
this.controller.loadMapData()
})
this.swapStyle(style)
}

// MapLibre only fires style.load when it builds a Style from scratch. Its
// default path diffs the new document into the live style and stays silent,
// which would leave the app layers stripped and never re-added, so the
// rebuild has to be forced.
swapStyle(style) {
this.map.once("style.load", () => this.restoreStyleLayers())
this.map.setStyle(style, { diff: false })
}

mapStyleOptions() {
return {
hiddenTileCategories:
SettingsManager.getSetting("hiddenTileCategories") || [],
disabledPoiGroups: SettingsManager.getSetting("disabledPoiGroups") || [],
customTheme: SettingsManager.getSetting("customTheme"),
}
}

// Reload layers after a style change. setStyle replaces the whole style
// document — including the projection — so globe mode must be restored
// or every style/theme change silently drops back to mercator.
restoreStyleLayers() {
this.restoreGlobeProjection()
this.controller.loadMapData()
}

applyUserStyleUrl(styleUrl, styleName) {
let settled = false

const onLoad = () => {
if (settled) return
settled = true
this.map.off("error", onError)
this.restoreStyleLayers()
}

// MapLibre reports every failed request through `error`, tiles included.
// Only a failure of the style document may discard the user's basemap —
// one unreachable tile from an otherwise valid style must not.
const onError = async (event) => {
if (settled || !styleDocumentFailed(event, styleUrl)) return
settled = true
this.map.off("style.load", onLoad)
this.map.off("error", onError)
Toast.error(
"Custom map style could not be loaded; reverting to the default style.",
)
const fallback = await getMapStyle(styleName, this.mapStyleOptions())
this.swapStyle(fallback)
}

this.map.once("style.load", onLoad)
this.map.on("error", onError)
this.map.setStyle(styleUrl, { diff: false })
}

restoreGlobeProjection() {
Expand Down Expand Up @@ -1319,15 +1373,22 @@ export class SettingsController {

/**
* The Custom style draws no labels or POIs, so their toggles are
* disabled while it's active, with a tooltip explaining why.
* disabled while it's active, with a tooltip explaining why. A raster or
* foreign-style basemap carries no Protomaps layers at all, so there every
* toggle goes dead, not just the unsupported ones.
*/
syncStyleDependentToggles(styleName) {
const basemap = classifyBasemapUrl(
SettingsManager.getSetting("vectorTilesUrl"),
)
const foreignBasemap = basemap === "raster" || basemap === "style"
const custom = styleName === "custom"
const inputs = this.controller.element.querySelectorAll(
"input[data-tile-category], input[data-poi-group]",
)
inputs.forEach((input) => {
const unavailable = custom && input.dataset.customSupported !== "true"
const unavailable =
foreignBasemap || (custom && input.dataset.customSupported !== "true")
input.disabled = unavailable

const label = input.closest("label")
Expand All @@ -1336,8 +1397,9 @@ export class SettingsController {
label.classList.toggle("tooltip", unavailable)
label.style.cursor = unavailable ? "not-allowed" : ""
if (unavailable) {
label.dataset.tip =
"Not available with the Custom map style — it draws no labels or points of interest"
label.dataset.tip = foreignBasemap
? "Not available with a custom raster or style basemap — these layers come from the built-in vector tiles"
: "Not available with the Custom map style — it draws no labels or points of interest"
} else {
delete label.dataset.tip
}
Expand All @@ -1354,7 +1416,9 @@ export class SettingsController {
const raw = event.target.value.trim()

if (!SettingsManager.validVectorTilesUrl(raw)) {
Toast.error("Tile URL must include {z}, {x}, and {y} placeholders")
Toast.error(
"Tile URL must include {z}, {x}, and {y} placeholders, or be a MapLibre style URL ending in .json",
)
return
}

Expand Down
Loading
Loading