Conversation
Centralising copy folded distinct strftime literals onto shared I18n format names, which changed rendered dates at eight call sites, and switched the location search formatter to a lang-derived locale that resolves to en-US and swaps day and month. - Add month_day_year, month_day_padded, short_month_day_padded and medium_padded so the call sites needing a different padding keep it without disturbing the sites that share the original format names. - Correct day_month_year, human, human_with_seconds and long_with_time in place, since each has a single consumer. - Pin the location search formatter back to en-GB so 3 August renders as 03/08/2026 rather than 08/03/2026. - Add the four missing battery_status enum values; the family map popup rendered the raw key for anything other than charging or full. Cover both with regression specs and a JS test for the date ordering.
chore: extract UI strings to Rails I18n
Bump load_defaults from 8.0 to 8.1 and drop the now-vestigial new_framework_defaults_8_0.rb: all three of its settings, including Regexp.timeout, are already applied by load_defaults 8.0. Checked the two behaviour-changing defaults before flipping. raise_on_missing_required_finder_order_columns only fires on models with no primary key, query_constraints or implicit_order_column, and every model here has an id. action_on_path_relative_redirect = :raise leaves the single bare-string redirect_to in the app untouched, since it is an absolute MANAGER_URL passed with allow_other_host. YJIT is unaffected: the container already sets RUBY_YJIT_ENABLE=1.
Rails 8.1 ships ActiveJob continuations and sidekiq 8.1 implements the stopping? hook they need, so a job interrupted by a deploy can now checkpoint and pick up where it stopped instead of restarting from the first row. Adds a Resumable concern and moves the five batch-walking jobs onto step/cursor. The altitude backfill had already hand-rolled a last_id PK cursor; that is now the step cursor. The stats rebucket previously re-enqueued every Stats::CalculatingJob it had already fanned out when it was restarted. Two things worth knowing for anyone adding a job here: Resumable sets resume_errors_after_advancing = false. The Rails default of true turns any StandardError raised after the cursor has advanced into a silent self-requeue, which stops the exception reaching the caller. That broke the failure path of DataMigrations::RecalculateAnomaliesUserJob, which calls Points::AnomalyBackfillUserJob and branches on the result. Resumption is for interruptions, not for errors. Splitting a job into steps can duplicate a destructive prologue. AnomalyBackfillUserJob clears anomaly flags before re-evaluating them, so the reset is its own step and is skipped on resume; otherwise every resumption wipes the flags it had just recalculated. Deliberately not converted: EnhancedImport::ExtractJob, Tracks::BoundaryResolverJob and DataMigrations::RecalculateAnomaliesUserJob. Their requeues are lock contention backoff, chunk-completion polling and dispatcher claim release. Continuations resume interrupted work; they do not help a job that is waiting on something else.
Turbo 8 can morph the body on a page refresh instead of replacing it, so a same-path redirect keeps scroll position and leaves the surrounding DOM alone. Deleting an import or a notification now updates the list in place rather than repainting the page and jumping to the top. Opt-in per controller rather than on globally, because Chartkick is not compatible with morphing: it destroys every chart on turbo:before-render and morphing does not re-run the inline scripts that construct them, so stats, insights and digests would refresh into blank charts. Those stay on replace. Map pages are already excluded by their own layout. turbo_refreshes_with writes into the :head content buffer, which the application layout was not yielding, so the layout now does. Note that morphing only applies to same-path visits with a replace action. A browser reload is a full document load and is unaffected. The main form on settings/general carries data-turbo: false, so saving settings still does a full load; only the smaller turbo-enabled forms on that page morph today.
Add explicit locale selection and persist user preferences without mutating unrelated settings. Propagate locale choices to mail and background notifications. Correct German UI copy, sentence structure, relative times, and digest labels, with regression coverage for the affected flows.
Make French selectable and persist locale preferences across authentication, mailers, jobs, and JavaScript-rendered UI. Polish user-facing translations, pluralization, and responsive navigation, with regression coverage for locale and rendering behavior.
Build the navbar language link from request.path instead of url_for, so query parameters can no longer reach route generation: ?host= produced an off-site absolute link and ?controller=/?action= raised UrlGenerationError on every page carrying the navbar. Non-GET requests fall back to the root path, because a form re-rendered after a validation error sits on a route that only answers to POST/PATCH. Localize notifications to their recipient at every construction site. The translations are interpolated at argument position, so wrapping the Notifications::Create service would not have taken effect; services that notify several recipients now scope each one separately. Pin API responses to the default locale, keep I18n.locale in the stats and insights fragment cache keys, skip persisting a locale preference on prefetched requests, and localize the insights weekday labels. Drop 44 locale keys left unreferenced by the rewritten views.
A hovered link is prefetched by Turbo before the user commits to it, and the session write happened ahead of the prefetch guard, so the language survived into the next request even though the account preference did not. Both writes now sit behind the same check, while the prefetched response is still rendered in the requested language so the preview stays accurate. Localize the two notification sites the previous pass missed: the Google Timeline importer, and the recalculation lock-contention warning whose content is translated before the notification is built. Give the year count in the recalculation notice a pluralized key instead of splicing an English "N years" into a German sentence. Pin the shared-link API to the default locale, as ApiController already is. Call areas Gebiete in the map settings panel to match the map itself, and the plan Family-Tarif on the lapsed-family page to match every other mention.
English stays the interface language until the reader picks something else. The Accept-Language header no longer takes part in resolving the locale; it only decides whether there is anything worth offering. When the browser asks for a language Dawarich speaks, and the reader has made no choice of their own, a dismissible banner offers the switch. It is written in the language being offered, since that is the one the reader is being asked to understand, and it links through the same path helper as the navbar switcher so the reader stays on the page they were reading. Dismissal reuses the existing localStorage-backed dismissible controller and is keyed per language, so a later Spanish or French offer does not arrive pre-dismissed. Two locale-selection examples asserted the old automatic switch; they now assert that English survives a German browser.
feat: add complete German locale
Spanish made the two-way toggle untenable: it sent every reader to the other of exactly two locales, and the English catalogue hardcoded the German label. The navbar now lists every registered language except the one being read, and each language is named in its own words, so a reader can find their own without first understanding the one in front of them. Each locale file carries its own name, so adding a language touches only that language's file. Register :es, translate the keys English gained while German was being finished, and drop the fragmented keys that were replaced there.
DaisyUI styles the direct child of a menu item, so wrapping the language links in a shared span swallowed their padding, rounding and hover state and left them looking unlike every sibling in the navbar. The partial now emits one list item per language and the three call sites no longer wrap it, which also gives each link a testid of its own instead of repeating one identifier across the page. Name the Spanish legal notice Aviso legal; Imprenta is a printing press. Default `language_name` to the locale code, since fallbacks are on in production and a locale that forgets the key would otherwise offer itself under English's name.
The previous attempt left the duplicate identifiers it set out to remove: the navbar renders the switcher in three places, so every id appeared twice on a signed-in page. Each render site now names its own scope, and the spec asserts the ids are distinct rather than merely present. That spec also could not see the bug it was written for. Nokogiri repairs `<li><li>…</li></li>` while parsing, so a caller that wrapped the partial again produced a tree that looked correct; the nesting is now checked against the raw response instead. Fold the guest navbar's languages behind one dropdown, since that menu is laid out inline and would otherwise grow by an item per language. Disable fallbacks when reading `language_name`: they are on in production, where a locale missing the key resolved to the English value and offered itself as "English" instead of reaching the default. Call an area an área rather than a zona in the creation dialog, and lower case the family plan to match its other mentions.
`request.path` is interpolated straight into the href, and a path beginning with `//` turned the link into a protocol-relative URL that would carry the reader to another site. Leading slashes are now collapsed to one, so the link can only ever point back here. Found while checking a reflected-XSS alert on the same line. The escaping there holds — the query string is percent-encoded before it reaches the attribute and the tag builder escapes it again — but the path had this separate hole next to it.
Language selection moves out of the navbar and into Settings → General, where it sits with the other account preferences and is saved by the same Save changes button. Each language is a card carrying its flag and its own name, chosen with a plain radio group so the picker needs no JavaScript and stays reachable from the keyboard. Each locale declares its own flag next to its own name, so adding a language remains a change to that language's file alone. The setting is written by the controller rather than left to the locale around_action: that one saves with `update_all`, which the save at the end of the update action would overwrite with the settings the request loaded. Rebuild Tailwind for the utilities the picker needs — the compiled stylesheet is committed here, and without the rebuild the radios stayed visible and the selected card lost its outline. The suggestion banner still offers a language to anyone whose browser asks for one, and remains the only language control for signed-out readers.
/map now serves the MapLibre map directly and /map/v1 permanently redirects to /map/v2 (query string preserved). The Settings -> Maps page (v1-only custom tiles and version toggle) is removed; map settings live in the map's settings panel. Monthly statistics maps and publicly shared month maps are rewritten as standalone MapLibre controllers reusing the v2 style pipeline. Shared format helpers moved out of the deleted maps/ directory into maps_maplibre/utils. All Leaflet assets are purged: importmap pins, vendored JS, CDN links in both layouts, stylesheets, and the npm dependency.
feat: add complete Spanish locale
Resolves the French locale branch against the German and Spanish work that
landed on dev in the meantime.
The two branches had grown separate locale mechanisms. dev's wins: the
language is chosen in Settings -> General and the browser's language is only
ever offered through a dismissible banner, never applied on the reader's
behalf. So this drops the French branch's LocalesController, PATCH /locale
route, navbar switcher, locale cookies and after_action auto-persistence, and
keeps its translations and string extraction.
French joins the shipped locales: available_locales, language_name/flag, the
suggestion banner copy, and the Settings language cards (now a 2x2 grid so
four languages fill it evenly).
Along the way, resolving the two mechanisms surfaced a few real defects:
- German and Spanish were missing all 86 keys this branch extracts, so those
readers would have seen English across insights, stats, trips and visits.
Translated, matching each locale's own terminology (Aufenthalt, not Besuch).
- families.show.family_at_capacity read "Familie ist voll (" in German and
"Familia a capacidad (" in Spanish -- truncated fragments with a dangling
paren and no counts.
- full_history_redetect_job's German and Spanish strings interpolated
%{visits_created}/%{size} while the caller passes visits/months, so the job
raised I18n::MissingInterpolationArgument for those users.
- Imports::Create and Families::Invite built a notification's body before
entering I18n.with_locale, giving it a title in the reader's language and a
body in the request's.
- The yearly digest interpolated the untranslated literal 'Year-End Digest'
into a translated sentence.
- stale_jobs_recovery_job and create_location_request translated strings just
outside the locale block for the same reason.
- User#preferred_locale raised on rows whose settings are not a Hash, and
persist_locale! merged into non-object settings with || (which appends to a
JSON array rather than setting a key). It also left the in-memory instance
stale, which Devise then reused for the rest of the session.
- date.order in fr.yml used YAML symbols, which made YAML.safe_load_file fail
for every locale file and broke the German and Spanish coverage specs.
- Removed locale keys holding Ruby source that string extraction had picked up
("width: 20,", "suffix: \" #{@distance_unit}\"") -- translators had begun
translating them.
Sign-up pinned settings['locale'] to whatever the request happened to run in — almost always English. suggested_locale treats any stored preference as "the reader has chosen", so from then on the banner stayed silent and someone who signed up from a French browser was never offered French again. Only an explicit choice is recorded now. Also: - The French coverage spec loaded its YAML with load_file while the German and Spanish ones use safe_load_file(aliases: true). The looser loader is what let the date.order symbols through, so it now matches its siblings. - CHANGELOG: French was missing from the language list; the German/Spanish fixes and the navigation breakpoint change were unmentioned.
The sign-up guard tested that a locale param was present, not that it was one we ship, so `?locale=xx` still stored the English fallback and silenced the suggestion banner for good. It now asks the same `supported_locale` helper the rest of the locale chain uses. Covers the session half of the guard too: choosing a language on the sign-up page and then submitting the form sends no locale param, and that choice has to survive.
feat: add French locale
fix: google play URL
Transportation mode detection rewrite + flattened timeline UX
| WITH target AS ( | ||
| SELECT id, track_id, start_index, end_index, corrected_at | ||
| FROM track_segments | ||
| WHERE id IN (#{ids.join(',')}) AND start_at IS NULL |
One detection pipeline (Visits::Detection::Runner) replaces the stay-point detector, its DBSCAN fallback and the separate area/place visit writers: dwell sweep -> same-place gap bridging (up to 7 days) -> transportation-segment reconciliation (confident movement vetoes stays, boundaries snap to movement edges) -> single merge layer -> evidence-gated place attribution -> confidence scoring -> overlay-aware persistence. Machine visits are regenerable (detection_version stamp); confirmed, renamed and deleted visits are anchors that survive every re-run. A same-place tracking silence becomes one continuous visit; a displaced silence renders as an explicit untracked stretch instead of a fabricated POI visit. Visits are named after venues only when the evidence supports it - otherwise they get a street address. The timeline gates on confidence (medium renders subdued, low collapses into a per-day disclosure), the untracked-gap threshold drops to 45 minutes, and the stay_max_gap_minutes slider and density-fill toggle are retired. Fleet re-detection ships as low-priority jobs: enqueued by the release migration when self-hosted, started from the console on Cloud.
The retired nightly area detector rescanned raw points, which is how a newly drawn area used to accumulate its history by the next midnight. Attribution now happens at detection time, so creating or resizing an area enqueues a relabel pass over the user's existing visit rows instead: additive only (visits already attributed keep their area), tombstones and declines untouched, no re-detection and no point scanning.
Bumps the bundler group with 1 update in the / directory: [json](https://github.com/ruby/json). Updates `json` from 2.20.0 to 2.21.2 - [Release notes](https://github.com/ruby/json/releases) - [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md) - [Commits](ruby/json@v2.20.0...v2.21.2) --- updated-dependencies: - dependency-name: json dependency-version: 2.21.2 dependency-type: direct:production dependency-group: bundler ... Signed-off-by: dependabot[bot] <support@github.com>
…dates Bumps the npm_and_yarn group with 2 updates in the / directory: [postcss](https://github.com/postcss/postcss) and [dompurify](https://github.com/cure53/DOMPurify). Updates `postcss` from 8.5.15 to 8.5.26 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](postcss/postcss@8.5.15...8.5.26) Updates `dompurify` from 3.4.11 to 3.4.13 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](cure53/DOMPurify@3.4.11...3.4.13) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.26 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: dompurify dependency-version: 3.4.13 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] <support@github.com>
Persister: skip anchors that no longer intersect the trimmed interval so a stay overlapped by several anchors can never re-widen over one; replace machine rows in bulk (points nullified, join rows removed, one deduped orphan-cleanup batch, one cache bust per month) instead of per-row destroy callbacks. Attaching an area from the map confirms the visit, so the choice survives re-detection. The 45-minute untracked-gap bar applies only after a user's history has been re-detected; legacy timelines keep 90. Confidence backfill batch-loads slim point rows; area relabeling resolves point-backed centers in one grouped centroid query. A fleet redetect lock collision re-enqueues with a delay instead of silently skipping the user, and the release migration survives an unreachable job backend. GapBridger returns fragments directly — the untracked-interval output had no consumer.
Importer-written visits (Google Timeline, Polarsteps) can't be re-derived from points, so detection now treats them as anchors: excluded from wholesale replacement and from the manual redetect's pre-delete, with machine stays trimmed around them. The manual redetect button clears suggested visits through the same bulk wipe the Persister uses (extracted as MachineVisitWipe) instead of per-row destroy callbacks. The fleet rollout staggers per-user jobs 30s apart, and SKIP_VISITS_FLEET_REDETECT skips the migration enqueue for admins who want to run it by hand. Confidence now reflects what actually persists: stays trimmed around anchors and visits stitched across batch edges are rescored from the surviving interval (shared StayScoring / VisitRescore, which the legacy backfill reuses). Editing a suggested visit through the API confirms it, matching the web controller, so place/area choices from the map panel survive re-detection. visits_redetected_at predates v3, so a release migration clears it — the tighter 45-minute untracked-gap bar waits for a real v3 redetect. Place attribution skips the reverse geocoder whenever point geodata already names the stay.
Days per Country was buried in the map's Create/tools tab, which is an odd home for a yearly report: it sits next to visit and area creation tools, and nothing about "Create" suggests a day counter. Insights is where the rest of the per-year analytics live, so the entry point moves there, next to the year selector. - Move the partial from map/maplibre/_residency_modal to shared/_days_per_country, since it is no longer map-specific, and fold the trigger button into it so the trigger and the modal stay together. - Trigger now uses data-action instead of an inline onclick dispatching a custom document event; the now-unused document listener is removed from the Stimulus controller. - Move the i18n keys to shared.days_per_country across en, de, es and fr, preserving the existing translations. The turbo frame still loads from map/residency; moving that route and controller is left out of this change to keep the diff reviewable.
A debounced re-run that would recreate exactly the machine rows already standing is now a no-op: the Persister compares the regenerated stays against the window's rows and skips the delete+insert, so ids, point claims and the daily bulk re-scan stop churning points.visit_id. The "new visits suggested" notification (and per-place geocoding enqueues) fire only for visits that don't overlap any machine row that stood before the run — an ongoing stay growing every few minutes is not news. Full-history redetection wipes machine rows whose backing points are gone before stamping success, so stale visits can't outlive the points that produced them. Rescoring skips point-less visits entirely — an unscored row renders at full strength instead of a fabricated low band. Relabeling an area now also names suggested machine visits after it, matching what detection does for area-attributed stays.
Visit.machine_detected is now the single scope for rows detection may regenerate: suggested, not imported, not demo-seeded, and not annotated with a note. Everything outside it anchors re-detection, so demo data and visits the user wrote notes on survive every run with their notes attached. The bulk wipe also collects suggested-place join ids so photon places referenced only through them get their orphan cleanup. Boundary snapping now drops a stay's fixes that fall outside the snapped interval — they are movement, not dwell, and no longer shape the center or get claimed. The no-change fast-path additionally compares claimed-point counts, so a late-arriving point inside an otherwise identical stay still forces regeneration and gets claimed. The fleet redetect worker respects the visit-suggestions opt-out and stamps visits_redetected_at only after a clean run, so a partial redetect doesn't unlock the tighter timeline gap bar.
StayAssembler finalizes before applying the dwell and point floors, so a boundary-snapped stay is judged on the fixes that actually remain in its interval; the Persister applies the same point floor to stays it trims around anchors. Confidence gating on the timeline activates only after the history has been re-detected — legacy rows carry scores from the retired scorer, and those must not subdue or collapse visits on upgrade day. A month the CandidateLoader refuses over its point cap now counts as failed, so partial redetects report honestly and never stamp visits_redetected_at.
Reworks the previous commit's entry point. Rather than a button on Insights that opens a modal, the report is now part of the page, laid out like the Activity Overview and Activity Streak row above it: countries with their collapsible stay periods on the left, the year heatmap on the right. - The report takes its year from the page's year selector instead of carrying its own dropdown, so the two can no longer disagree. - Hidden for All Time, which has no calendar year to count, matching how the heatmap and streak cards already behave. - Loaded in a lazy turbo frame, since counting a year of points is a grouped query over the points table and it sits below the fold. - The modal partial and the residency Stimulus controller are gone: with no modal to open or close and no year select to react to, neither had any work left to do. - Card copy moves to the map.residency.show scope across en, de, es and fr, keeping the existing "Not tax advice" line as the heatmap card's subtitle.
…n path New accounts default visits_redetected_at to their creation time — they only ever have v3 output, so confidence gating and the tighter gap bar apply from day one instead of never activating for post-rollout signups. Upgraded accounts still start cleared and unlock on redetect. Anchor trimming is now exact interval subtraction: an anchor strictly inside a stay yields both flanks as separate visits instead of silently discarding the shorter one. Batch-edge stitching refuses to bridge a silence that holds a point-less anchor (imported and manual visits own no points), closing the last way a machine visit could overlap one. The re-detect button no longer wipes everything up front: windows are replaced as they regenerate, so a failed or capped month keeps its existing rows instead of losing them, and orphan-place cleanup rides the Persister's batched path. Area relabeling busts the month summary cache for the days it touches.
Move Days per Country from the map to Insights
MachineVisitWipe.flush_side_effects is the single place wiped rows' side effects run — orphan-place cleanup (direct and suggested-place joins) plus month cache busts — so the out-of-range purge no longer leaks photon places the way the removed upfront wipe used to cover. The re-detect button stamps visits_redetected_at only after a clean run, matching the fleet worker: a partial redetect neither unlocks the tighter gap bar and confidence gating nor starts the retry cooldown.
Boundary snapping moved a stay's end before the fixes reacquired on the way out of a GPS-dark venue, and the post-snap point filter then dropped the stay below the point floor — a planetarium visit with one arrival fix, a 78-minute dark-dome silence and four departure fixes vanished entirely (about 1,100 such stays across one real history). A fragment's fixes are same-place by construction (DwellSweep clusters, GapBridger and chain_merge only join same-place fragments), so the snapped interval is a statement about time, not about evidence: keep every fix for the floors, the center and the claim. Movement pollution stays handled where it belongs, in the reconciler's veto.
New labeled scenario: one fix on arrival, 78 minutes of GPS-dark silence, four fixes reacquired with the departure walk already under way — the shape whose evidence sits entirely at the snapped-off edges. The eval gate requires one stay of at least 70 minutes, so no future cleanup anywhere in the pipeline can silently drop it again; verified red against the reverted assembler filter, green against the fix.
Visit detection rewrite: bridged stays, movement reconciliation, honest gaps
| .where(timestamp: start_at..end_at) | ||
| .where.not(lonlat: nil) | ||
| .where('anomaly IS NULL OR anomaly = FALSE') | ||
| .where("NOT #{Points::NullIsland.sql_predicate}") |
…aad51 chore(deps): bump json from 2.20.0 to 2.21.2 in the bundler group across 1 directory
…yarn-116291eeda chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Age spec users out of the cooldown the born-redetected default now starts at creation, add dark_venue to the documented scenario list so the shared invariants cover it, and give the eval baseline JSON its trailing newline for biome.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 1.12.0 — everything merged to
devsince 1.11.0. 129 commits, 811 files, +38,444/−27,125.Added
Removed
/mapnow opens the MapLibre map,/map/v1redirects to/map/v2. The Settings → Maps page (v1-only custom tile URL and version toggle) is gone; map appearance settings live in the map's own settings panel. All maps now require WebGL. (Remove Map v1 (Leaflet), migrate remaining maps to MapLibre #3311)Changed
Fixed
OIDC_ISSUERwritten with a#fragment no longer fails sign-in with "Issuer mismatch". (OIDC_ISSUER variable is not working as expected #3289)/metricsno longer reports duplicate series when web and Sidekiq exporters merge; shared series carry aprocesslabel. (Duplicate Prometheus series with conflicting values on /metrics #3304)unknown OID 28forxmax. (unknown OID 28: failed to recognize type of 'xmax'. It will be treated as String. #3309)Internal
Upgrade notes (self-hosted)
SKIP_VISITS_FLEET_REDETECT=1before migrating to opt out of the latter (then runVisits::FleetRedetectJob.perform_latermanually when convenient).TransportationModes::FleetReclassifyJob.perform_laterafter the segment backfill finishes. Dawarich Cloud is re-detected in a staged rollout.