Skip to content

1.3.2#2358

Merged
Freika merged 55 commits intomasterfrom
dev
Mar 8, 2026
Merged

1.3.2#2358
Freika merged 55 commits intomasterfrom
dev

Conversation

@Freika
Copy link
Owner

@Freika Freika commented Mar 8, 2026

Summary by CodeRabbit

Release Notes

New Features

  • Introduced two-tier plan system: Pro and Lite plans with feature gating
  • Lite plan provides 12-month data window with archival warnings for older data
  • Plan-based API rate limiting: 200 requests/hour for Lite, 1000 for Pro
  • Gated map layers (Heatmap, Fog of War, Scratch Map, Globe) with timed preview for Lite users
  • Upgrade prompts displayed throughout the app to encourage plan migration

Chores

  • Version bumped to 1.3.2
  • Added rate-limiting infrastructure via rack-attack dependency

Freika and others added 30 commits March 1, 2026 21:25
Branch naming uses hyphens (feature/lite-*) instead of slashes
(feature/lite/*) because git can't create refs under an existing
branch name. Mark all plan-enum tasks as complete.
…miting

- Add `require_write_api!` guard to PointsController for create, update,
  destroy, and bulk_destroy actions. Lite users receive 403 with clear
  JSON error and upgrade URL.
- Add `scoped_points` helper to ApiController that filters Lite users to
  a 12-month timestamp window. Pro and self-hoster users see all data.
- Add rack-attack gem with Redis backend for per-plan rate limiting:
  Lite 200 req/hr, Pro 1,000 req/hr, self-hosters exempt.
- Add X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
  headers on all API responses for rate-limited plans.
- Custom 429 response with Retry-After header and upgrade URL.
- Full test coverage: 14 new specs (points write gating, read scoping,
  rate limit headers, throttle behavior).
Lite users see a lock badge + "Pro" label on gated layer toggles
(heatmap, fog-of-war, scratch map, globe view). Clicking a gated
toggle shows a 20-second live preview using the user's actual data,
then auto-hides with an upgrade prompt. Globe view (which requires
a page reload) shows the upgrade prompt immediately instead.

- Add layer_gate.js utility with gatedToggle() and isGatedPlan()
- Update routes_manager.js toggleHeatmap/toggleFog/toggleScratch
  to use gatedToggle — skips SettingsManager.updateSetting for Lite
- Update settings_manager.js toggleGlobe to block for Lite users
- Update syncToggleStates to force gated layers off on page load
- Add lock icon badges to _settings_panel.html.erb for Lite users
- Update lite.md with completed map-layers tasks
Backend:
- Add `scoped_points` helper to ApiController for 12-month Lite window
- Add `archived_points` helper returning only data older than 12 months
- Support `archived=true` param on Points index — returns only archived
  points for Lite users, empty array for Pro/self-hoster (no archived
  concept)
- PointsController index uses scoped_points by default, archived_points
  when param is set

Frontend:
- Add `fetchArchivedPoints` and `fetchAllArchivedPoints` to ApiClient
- After main map data loads, Lite users get a background fetch of
  archived points rendered as a separate MapLibre layer at 30% opacity
  (gray circles with stroke)
- Click on archived point shows upgrade prompt toast with link
- Cursor changes to pointer on hover over archived data
- Archived layer inserted below all active layers in z-order

Tests: 4 new specs for archived param behavior (Lite returns archived,
Lite excludes recent, Pro returns empty, self-hoster returns empty).
3592 examples, 0 failures.
Daily Sidekiq job checks all Lite users for data approaching archival:
- 11 months: in-app warning notification
- 11.5 months: email via UsersMailer#archival_approaching
- 12 months: in-app banner about archived data

Each threshold is tracked in user settings to prevent duplicate
warnings. Scheduled daily at 03:00 via sidekiq-cron on the
existing :archival queue.
3: feat: gate map layers for Lite plan with timed preview
…miting

- Add `require_write_api!` guard to PointsController for create, update,
  destroy, and bulk_destroy actions. Lite users receive 403 with clear
  JSON error and upgrade URL.
- Add `scoped_points` helper to ApiController that filters Lite users to
  a 12-month timestamp window. Pro and self-hoster users see all data.
- Add rack-attack gem with Redis backend for per-plan rate limiting:
  Lite 200 req/hr, Pro 1,000 req/hr, self-hosters exempt.
- Add X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
  headers on all API responses for rate-limited plans.
- Custom 429 response with Retry-After header and upgrade URL.
- Full test coverage: 14 new specs (points write gating, read scoping,
  rate limit headers, throttle behavior).
Freika and others added 25 commits March 2, 2026 22:34
Lite users see a lock badge + "Pro" label on gated layer toggles
(heatmap, fog-of-war, scratch map, globe view). Clicking a gated
toggle shows a 20-second live preview using the user's actual data,
then auto-hides with an upgrade prompt. Globe view (which requires
a page reload) shows the upgrade prompt immediately instead.

- Add layer_gate.js utility with gatedToggle() and isGatedPlan()
- Update routes_manager.js toggleHeatmap/toggleFog/toggleScratch
  to use gatedToggle — skips SettingsManager.updateSetting for Lite
- Update settings_manager.js toggleGlobe to block for Lite users
- Update syncToggleStates to force gated layers off on page load
- Add lock icon badges to _settings_panel.html.erb for Lite users
- Update lite.md with completed map-layers tasks
# Conflicts:
#	app/controllers/api/v1/points_controller.rb
#	app/controllers/api_controller.rb
#	app/javascript/maps_maplibre/utils/layer_gate.js
#	config/initializers/rack_attack.rb
#	spec/requests/api/v1/points_spec.rb
4: feat: add data retention archival filter and archived data rendering
5: feat: add Lite archival warning jobs
@coderabbitai
Copy link

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive Lite plan system with tiered features and data windowing. It adds a two-tier plan enum (Lite/Pro), implements 12-month data retention for Lite users, gates premium features (heatmap, fog of war, scratch map, globe), introduces plan-based API rate limiting via rack-attack, adds archival warnings for aging data, and scatters plan-awareness throughout controllers, views, and frontend code. A new database migration establishes the plan column, scheduled jobs manage data archival notifications, and extensive test coverage validates plan gating across API and UI layers.

Changes

Cohort / File(s) Summary
Version & Changelog
.app_version, CHANGELOG.md
Version bump to 1.3.2 with comprehensive changelog entry documenting Lite plan rollout, feature gating, API rate limits, and data windowing changes.
Documentation
CLAUDE.md
Added Migrations subsection, Public Sharing notes, and new Plan System (Lite vs Pro) section describing two-tier model and plan gating; includes minor duplications and structural updates.
Dependencies
Gemfile
Added rack-attack gem for rate limiting middleware.
Database Schema & Migrations
db/migrate/20260301*.rb, db/schema.rb
New migration adds integer plan column to users table (default: 1/pro), creates index; SetPlanForExistingUsers migration assigns plans based on user status; schema updated with plan index, VideoExports callback_nonce column, Notes table dropped, track_segments index added.
Core Model & Plan Scoping
app/models/user.rb, app/models/concerns/plan_scopable.rb
User model now includes PlanScopable concern and plan enum (lite/pro, default: pro); activate sets plan to pro. PlanScopable provides plan_restricted?, data_window_start, and scoped_* methods (scoped_points, scoped_tracks, scoped_visits, scoped_stats) for 12-month windowing.
API Base Controller & Authentication
app/controllers/api_controller.rb, app/controllers/api/v1/plan_controller.rb
ApiController gains require_pro_api!, require_write_api!, scoped_points, apply_plan_scope, upgrade_url_for, set_rate_limit_headers helpers. New PlanController#show returns current_user plan and feature flags (full_features for Pro/self-hosted, lite_features with data_window: '12_months' for Lite).
API Data Endpoint Controllers
app/controllers/api/v1/{insights_controller.rb, points_controller.rb, visits_controller.rb, subscriptions_controller.rb, tracks/points_controller.rb, settings_controller.rb}
Widespread scoped_* association replacement (stats→scoped_stats, visits→scoped_visits, points→scoped_points); insights adds plan_metadata to response payloads; points adds require_write_api! gating and X-Total-Points-In-Range/X-Scoped-Points headers for Lite users; subscriptions validates plan against User.plans enum and busts rate-limit cache; settings adds documentation comment on Lite plan handling.
Web Controllers - Data Access & Locking
app/controllers/insights_controller.rb, app/controllers/stats_controller.rb, app/controllers/visits_controller.rb, app/controllers/map/leaflet_controller.rb, app/queries/tracks/index_query.rb, app/services/timeline/day_assembler.rb
Replaces direct model associations with scoped variants; insights adds year_locked? and @locked_years/set_available_years helpers to gate year access; stats adds locked_years method and @locked_years/@sharing_allowed flags; all introduce plan-aware filtering in data retrieval.
Web Controllers - Feature Gating
app/controllers/application_controller.rb, app/controllers/settings/integrations_controller.rb, app/controllers/shared/{digests_controller.rb, stats_controller.rb}, app/controllers/imports_controller.rb
ApplicationController adds require_pro! guard (skips self-hosted, redirects Lite users, allows Pro); integrations, shared stats add require_pro! before_actions; digests and shared digests set @full_digest based on self-hosted/pro status; imports adds authenticate_active_user! filter.
Timestamp Parsing & Utilities
app/controllers/concerns/safe_timestamp_parser.rb
Enhanced to interpret numeric date strings as Unix timestamps with 1970-2100 clamping bounds.
Services - Settings & Plan Filtering
app/services/users/safe_settings.rb, app/services/users/transportation_thresholds_updater.rb
SafeSettings now accepts plan parameter, introduces GATED_MAP_LAYERS constant, filters enabled_map_layers and forces globe_projection: false for Lite plans. TransportationThresholdsUpdater adds sanitize_gated_layers to strip gated settings for Lite users on save.
Services - Data Query & Filtering
app/services/location_search/spatial_matcher.rb, app/services/visits/{find_in_time.rb, find_within_bounding_box.rb}
Spatial matcher adds build_plan_filter for timestamp constraints on restricted users; visit queries switch to scoped_visits association.
Serializers & Helpers
app/serializers/api/user_serializer.rb, app/helpers/application_helper.rb
User serializer adds plan field to subscription object. ApplicationHelper adds show_plan_data_window_alert?, OAUTH_PROVIDERS constant, oauth_button_config, upgrade_url, pro_badge_tag for UI components.
Background Jobs & Mailers
app/jobs/lite/archival_warning_job.rb, app/mailers/users_mailer.rb, app/views/users_mailer/archival_approaching.{html,text}.erb
New Lite::ArchivalWarningJob (daily at 3am) monitors data age thresholds and triggers in-app notifications/emails for approaching/reached archival. UsersMailer#archival_approaching sends upgrade-prompting emails. Templates provide user-facing notifications.
Frontend - Map JavaScript Core
app/javascript/controllers/maps/maplibre_controller.js, app/javascript/controllers/maps/maplibre/data_loader.js, app/javascript/maps_maplibre/services/api_client.js
MapLibre controller adds userPlan/upgradeUrl values, calls cancelAllPreviews on disconnect. DataLoader extracts points from result object, handles totalPointsInRange. ApiClient augments fetchPoints/fetchAllPoints return shape with totalPointsInRange and scopedPoints from response headers.
Frontend - Map Layer Gating & Managers
app/javascript/controllers/maps/maplibre/{map_data_manager.js, routes_manager.js, settings_manager.js}, app/javascript/maps_maplibre/utils/layer_gate.js
Introduces layer gating system: isGatedPlan detects Lite, gatedToggle intercepts layer toggles with timed previews and toasts, cancelAllPreviews cleanup. Managers import and use gating to prevent Lite users from enabling heatmap/fog/scratch/globe, show upgrade banners on gating. Settings manager forces gated toggles off on load for Lite.
Frontend - UI Components
app/javascript/maps_maplibre/components/upgrade_banner.js, app/javascript/maps_maplibre/components/toast.js
New UpgradeBanner class with static show/dismiss for singleton upgrade prompts with UTM tagging. Toast.dismiss guards against detached elements.
Frontend - Styling
app/assets/stylesheets/maps_maplibre.css
Adds 68 lines of CSS for upgrade banner UI (layout, typography, colors, dark mode overrides).
Views - Shared Components
app/views/shared/{_plan_data_window_alert.html.erb, _sharing_modal.html.erb, map/_upgrade_banner.html.erb}
New plan_data_window_alert partial displays Lite data window notice with dismissible close button and upgrade link. Sharing modal branches on sharing_allowed flag: renders upgrade prompt or full modal. Upgrade banner partial renders data window alert for maps.
Views - Insights
app/views/insights/{_header.html.erb, _pro_locked_card.html.erb, index.html.erb, details.html.erb}
Header shows lock icon for locked years. New pro_locked_card partial renders blurred card with lock overlay and upgrade CTA. Index conditionally renders pro_locked_card grid when @year_locked, else shows original stats. Details gates sections behind show_plan_data_window_alert?.
Views - Stats
app/views/stats/{_locked_year_card.html.erb, index.html.erb, show.html.erb, month.html.erb, _month.html.erb}
New locked_year_card partial shows year with blurred chart and lock overlay. Index/show/month pages render plan_data_window_alert; index adds loop for @locked_years. Month passes sharing_allowed to modal partial.
Views - Maps & Auth
app/views/map/{leaflet/index.html.erb, maplibre/index.html.erb, maplibre/_settings_panel.html.erb, timeline_feeds/_feed.html.erb}, app/views/devise/{sessions/new.html.erb, shared/_links.html.erb}
Map views inject upgrade banner and set userPlan/upgradeUrl data attributes. Settings panel adds pro_badge_tag next to gated layer toggles. Timeline feeds renders plan_data_window_alert in empty state. Devise pages use configurable oauth_button_config for branded buttons.
Views - Settings & Digests
app/views/settings/integrations/index.html.erb, app/views/users/digests/{show.html.erb, public_year.html.erb}, app/views/visits/index.html.erb
Integrations page gates Immich/Photoprism behind upgrade card for Lite users. Digest views gate First Time Visits, Monthly Distance, Top Countries, All-Time Stats behind @full_digest flag; adds lock icons and upgrade CTAs. Visits index renders plan_data_window_alert in hero and no-visits blocks.
Infrastructure - Config & Routes
config/initializers/{03_dawarich_settings.rb, rack_attack.rb, rails_icons.rb}, config/routes.rb, config/schedule.yml
DawarichSettings adds LITE_DATA_WINDOW constant (12.months). New rack_attack initializer configures per-plan API rate limits (lite: 200, pro: 1000) via Redis, throttles /api/* paths, returns 429 with upgrade_url. Routes add singular :plan resource (show only). Schedule adds lite_archival_warning_job (daily 3am, archival queue). RailsIcons configures brand icons default size.
E2E Tests & Setup
e2e/{lite/plan-gates.spec.js, setup/auth-lite.setup.js}, playwright.config.js, e2e/v2/helpers/constants.js
New plan-gates.spec.js tests map layer gating, data window banners, settings gating, API write restrictions. Auth-lite.setup.js authenticates a Lite test user. Playwright config adds lite-setup/lite projects and testIgnore for lite in chromium. Constants adds LITE_USER to API_KEYS and TEST_USERS.
Demo Seed Data
lib/tasks/demo.rake
Extends demo task to create a Lite user with recent (within 6mo) and old (13-14mo) test points, visits, and areas; adds BERLIN_BASE constant and helper methods create_lite_recent/old_points.
Unit & Integration Tests
spec/{controllers,models,requests,services,serializers,factories,support}/*
Comprehensive test suite: PlanScopable concern tests, plan enum tests on User, controller spec for require_pro_api!, application_controller require_pro! tests, factory traits (lite_plan, pro_plan), API endpoint tests validating plan scoping/rate limiting/write restrictions, mailer tests for archival_approaching, safe_settings/transportation_updater tests for layer filtering, user serializer plan field tests, E2E setup helpers, request specs for rate limiting and plan-based access, request tests validating 12-month window scoping across points/visits/tracks APIs.

Sequence Diagram(s)

sequenceDiagram
    participant Lite as Lite User<br/>(Cloud)
    participant UI as Web UI /<br/>Frontend
    participant Controller as Web/API<br/>Controller
    participant Model as User Model<br/>(plan_scopable)
    participant DB as Database<br/>(filtered)
    
    Note over Lite,DB: Plan-Based Data Scoping Flow
    Lite->>UI: Request stats/points
    UI->>Controller: GET /stats or /api/v1/points
    Controller->>Model: current_user.scoped_points<br/>current_user.scoped_stats
    Model->>Model: Check plan_restricted?<br/>(lite + cloud = true)
    Model->>Model: Calculate data_window_start<br/>(12 months ago)
    Model->>DB: SELECT * WHERE timestamp >= cutoff
    DB-->>Model: Filtered records (12mo window)
    Model-->>Controller: Scoped relation
    Controller->>Controller: Build response / render view
    Controller-->>UI: Response + alert/lock UI
    UI-->>Lite: Render with<br/>upgrade banner/<br/>locked cards
Loading
sequenceDiagram
    participant User as Lite User
    participant Map as MapLibre Map
    participant Manager as SettingsManager<br/>(Gating)
    participant Toggle as Layer Toggle
    participant Gate as LayerGate<br/>gatedToggle()
    participant Banner as UpgradeBanner
    
    Note over User,Banner: Layer Gating & Preview Flow
    User->>Map: Load map with Lite plan
    Map->>Manager: syncToggleStates()
    Manager->>Manager: Check isGatedPlan(plan)
    Manager->>Toggle: Force gated toggles OFF
    Toggle-->>Manager: Confirmed
    User->>Toggle: Click Heatmap toggle (ON)
    Toggle->>Gate: gatedToggle({heatmap...})
    Gate->>Gate: Validate isGatedPlan = true
    Gate->>Toggle: Show preview countdown toast
    Gate->>Map: showFn() → display heatmap
    Note over Gate: Wait PREVIEW_SECONDS
    Gate->>Map: hideFn() → hide heatmap
    Gate->>Toggle: Force toggle OFF
    Gate->>Banner: UpgradeBanner.show({message, upgradeUrl})
    Banner-->>User: Display upgrade prompt
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • 1.1.0 #2226: Modifies overlapping MapLibre frontend modules (data_loader.js, map_data_manager.js, api_client.js) with similar data structure changes and API response shape updates.
  • 0.36.3 #2013: Changes fetchPoints/fetchAllPoints return signatures in api_client.js, directly overlapping with this PR's API shape augmentations (totalPointsInRange, scopedPoints).
  • 0.37.3 #2146: Modifies map data loading and points fetching logic (data_loader.js, api_client.js) with related changes to data pipelines and response handling.

Poem

🐰 A tale of plans, both Lite and Pro,
Where data windows start to flow.
Twelve moons of maps, gated layers bright,
Upgrade banners shine so light.
Fences rise, but Pro breaks free—
Welcome to tiered serenity! 🎯

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
app/controllers/map/leaflet_controller.rb (1)

43-72: ⚠️ Potential issue | 🟠 Major

Plan scoping bypassed in distance calculation.

The calculate_distance method queries the points table directly using user_id, bypassing the plan-scoped filtering applied in points_from_user (line 113). For Lite users, this will calculate distances including points outside their 12-month data window, creating inconsistency between the displayed points and the reported distance.

Consider either:

  1. Filtering the SQL query to respect the plan window, or
  2. Using @points (already scoped) for the calculation
Potential approach using already-scoped points
  def calculate_distance
-   return 0 if `@points.count`(:id) < 2
+   return 0 if `@points.size` < 2

-   # Use PostGIS window function for efficient distance calculation
-   # This is O(1) database operation vs O(n) Ruby iteration
-   import_filter = params[:import_id].present? ? 'AND import_id = :import_id' : ''
-
-   sql = <<~SQL.squish
-     SELECT COALESCE(SUM(distance_m) / 1000.0, 0) as total_km FROM (
-       SELECT ST_Distance(
-         lonlat::geography,
-         LAG(lonlat::geography) OVER (ORDER BY timestamp)
-       ) as distance_m
-       FROM points
-       WHERE user_id = :user_id
-         AND timestamp >= :start_at
-         AND timestamp <= :end_at
-         #{import_filter}
-     ) distances
-   SQL
-
-   query_params = { user_id: current_user.id, start_at: start_at, end_at: end_at }
-   query_params[:import_id] = params[:import_id] if params[:import_id].present?
-
-   result = Point.connection.select_value(
-     ActiveRecord::Base.sanitize_sql_array([sql, query_params])
-   )
-
-   result&.to_f&.round || 0
+   # Calculate distance using already-scoped and filtered points
+   point_ids = `@points.pluck`(:id)
+   return 0 if point_ids.size < 2
+
+   sql = <<~SQL.squish
+     SELECT COALESCE(SUM(distance_m) / 1000.0, 0) as total_km FROM (
+       SELECT ST_Distance(
+         lonlat::geography,
+         LAG(lonlat::geography) OVER (ORDER BY timestamp)
+       ) as distance_m
+       FROM points
+       WHERE id = ANY(:point_ids)
+     ) distances
+   SQL
+
+   result = Point.connection.select_value(
+     ActiveRecord::Base.sanitize_sql_array([sql, { point_ids: point_ids }])
+   )
+
+   result&.to_f&.round || 0
  end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/map/leaflet_controller.rb` around lines 43 - 72, The
calculate_distance method bypasses plan-scoped filtering by querying points
directly (using user_id) instead of the already-scoped `@points` created by
points_from_user, causing Lite users to see distances outside their allowed
window; fix by reusing the scoped relation (`@points` or points_from_user) for the
distance calculation—either (A) build the same ST_Distance SQL as a fragment and
execute it against `@points` (e.g., via `@points.select/connection` with sanitized
SQL and the same window/order) so the import_id and plan window are respected,
or (B) compute the distance in Ruby from `@points` enum if performance allows;
ensure the final query still uses LAG(... ORDER BY timestamp) and returns
total_km as before, and remove the direct points WHERE user_id = :user_id
clause.
app/javascript/controllers/maps/maplibre/data_loader.js (1)

146-163: ⚠️ Potential issue | 🟠 Major

Don't zero out totalPointsInRange when point layers are disabled.

The new fallback returns { points: [], totalPointsInRange: 0 }, and that value is copied into data.totalPointsInRange. If a Lite user hides points/routes/heatmap/fog/scratch, the map will now look like the selected range has no truncated data at all, so the upgrade/banner logic never gets a chance to fire. This count needs to be fetched independently of layer visibility.

Also applies to: 228-235, 289-289

app/views/visits/index.html.erb (1)

45-50: ⚠️ Potential issue | 🟠 Major

Render the plan-window alert for non-empty visit lists too.

At Line 49, the warning only appears when @visits.empty?, so Lite users with actual visits never see the archival notice on this page. Move it above the conditional so both states get the same warning.

Proposed fix
-  <% if `@visits.empty`? %>
+  <%= render 'shared/plan_data_window_alert', utm_content: 'visits' %>
+
+  <% if `@visits.empty`? %>
     <div class="hero min-h-80 bg-base-200">
       <div class="hero-content text-center">
         <div class="max-w-md">
-          <%= render 'shared/plan_data_window_alert', utm_content: 'visits' %>
           <h1 class="text-5xl font-bold">Hello there!</h1>
           <p class="py-6">
             Here you'll find your <%= params[:status] %> visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page!
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/visits/index.html.erb` around lines 45 - 50, The plan-data alert
partial is currently rendered inside the `@visits.empty`? branch so Lite users
with visits never see it; move the render 'shared/plan_data_window_alert',
utm_content: 'visits' out of the conditional and place it above the if
`@visits.empty`? block so it is always rendered regardless of whether `@visits` is
empty or not, keeping the rest of the conditional (the hero/content headings and
visit list) unchanged.
app/controllers/api/v1/subscriptions_controller.rb (1)

6-32: ⚠️ Potential issue | 🟡 Minor

Consider rescuing ActiveRecord::RecordNotFound for user lookup.

If the JWT token contains an invalid user_id, User.find will raise ActiveRecord::RecordNotFound which isn't currently rescued, potentially returning a 500 error instead of a more appropriate response.

🛡️ Proposed fix
   def callback
     decoded_token = Subscription::DecodeJwtToken.new(params[:token]).call

     user = User.find(decoded_token[:user_id])
     # ... rest of method
   rescue JWT::DecodeError => e
     ExceptionReporter.call(e)
     render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized
+  rescue ActiveRecord::RecordNotFound => e
+    ExceptionReporter.call(e)
+    render json: { message: 'User not found.' }, status: :not_found
   rescue ArgumentError => e
     ExceptionReporter.call(e)
     render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_content
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/api/v1/subscriptions_controller.rb` around lines 6 - 32, The
callback action uses User.find and can raise ActiveRecord::RecordNotFound for an
invalid decoded_token[:user_id]; add a rescue ActiveRecord::RecordNotFound
branch (similar to the existing JWT::DecodeError/ArgumentError handlers) that
calls ExceptionReporter.call(e) and renders a clear JSON error (e.g., "User not
found") with an appropriate status like :not_found so the controller returns a
404 instead of a 500 when the user lookup fails.
app/controllers/insights_controller.rb (1)

119-119: ⚠️ Potential issue | 🟡 Minor

digests.yearly is not scoped - same issue as API controller.

This mirrors the concern in Api::V1::InsightsController. The digest data may include aggregations from outside the plan's 12-month window. Consider applying consistent plan scoping to digests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/insights_controller.rb` at line 119, The call
current_user.digests.yearly.find_by(year: `@selected_year`) is not scoped to the
current plan and can return digests outside the plan's 12-month window; update
this lookup to apply the same plan scoping used in the API controller (e.g. use
the current plan's digests scope or filter current_user.digests by
current_plan/id) so the digest lookup for `@selected_year` only searches digests
belonging to the current plan.
app/controllers/api/v1/insights_controller.rb (1)

63-64: ⚠️ Potential issue | 🟠 Major

digests is not scoped like other data sources; inconsistency affects both yearly and monthly digests.

While scoped_stats and scoped_visits are used throughout to enforce plan-based data windowing, digests.yearly on line 63 and digests.monthly on line 76 remain unscoped. For Lite users, this allows access to aggregated data from outside the 12-month plan window, leaking historical travel patterns and statistics.

Consider either:

  1. Adding a scoped_digests method to PlanScopable that filters digests by the plan window, or
  2. Filtering the digest's travel patterns in both load_travel_patterns and calculate_yearly_day_of_week to exclude data outside the plan window
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/api/v1/insights_controller.rb` around lines 63 - 64, The
digests lookup (current_api_user.digests.yearly / monthly) is not being filtered
by the user's plan window like scoped_stats/scoped_visits; either add a
scoped_digests method to PlanScopable that mirrors scoped_stats/scoped_visits
and replace current_api_user.digests.yearly/monthly with
current_api_user.scoped_digests.yearly/monthly, or (if you prefer a smaller
change) filter the digest contents after loading by trimming travel_patterns and
any aggregated fields in load_travel_patterns and calculate_yearly_day_of_week
to only include records within the plan window (use the same plan window logic
used by scoped_stats/scoped_visits to determine start/end dates). Ensure you
update references to yearly_digest and monthly_digest where they are used so no
unscoped data remains.
🟡 Minor comments (11)
app/controllers/concerns/safe_timestamp_parser.rb-11-12 (1)

11-12: ⚠️ Potential issue | 🟡 Minor

Clamp Unix timestamps against UTC bounds.

Lines 11-12 make the allowed range depend on Time.zone, so the numeric path can reject 0 in west-of-UTC zones or allow negative epochs in east-of-UTC zones. Since Line 15 treats these values as Unix timestamps, the bounds should be fixed UTC epoch values instead.

Suggested fix
-    min_timestamp = Time.zone.parse('1970-01-01').to_i
-    max_timestamp = Time.zone.parse('2100-01-01').to_i
+    min_timestamp = Time.utc(1970, 1, 1).to_i
+    max_timestamp = Time.utc(2100, 1, 1).to_i
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/concerns/safe_timestamp_parser.rb` around lines 11 - 12, The
min_timestamp and max_timestamp are currently computed with Time.zone.parse
which makes numeric parsing vary by local zone; change them to fixed UTC epoch
bounds (e.g. compute min_timestamp and max_timestamp using Time.utc (or
Time.new(...).utc) for '1970-01-01' and '2100-01-01') so the numeric path in
SafeTimestampParser always clamps against absolute Unix UTC values; update the
assignments to min_timestamp and max_timestamp accordingly and keep the rest of
the timestamp parsing logic unchanged.
app/models/points/raw_data_archive.rb-63-64 (1)

63-64: ⚠️ Potential issue | 🟡 Minor

Reject malformed format_version values instead of coercing them to legacy archives.

The metadata['format_version'].to_i on line 64 silently treats non-numeric values like 'abc' as 0, bypassing the count-field validation. Similarly, false.blank? also returns true, allowing invalid metadata to skip validation. The current tests cover format_version 1 and 2, but don't test these edge cases. If format_version is meant to be strictly a version number, parse it with Integer() and add an error for invalid values.

Suggested fix
-      # Count fields were introduced in format_version 2; don't enforce on older archives
-      return if metadata['format_version'].blank? || metadata['format_version'].to_i < 2
+      # Count fields were introduced in format_version 2; don't enforce on older archives
+      format_version = metadata['format_version']
+      return if format_version.blank?
+
+      version = Integer(format_version)
+      return if version < 2
+
+    rescue ArgumentError, TypeError
+      errors.add(:metadata, 'format_version must be a valid integer')
+      return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/points/raw_data_archive.rb` around lines 63 - 64, The code
currently uses metadata['format_version'].to_i which coerces non-numeric or
falsey values to 0 and skips count-field validation; instead parse and validate
the version strictly: check metadata['format_version'] for presence, attempt
Integer(metadata['format_version']) (rescue ArgumentError/TypeError) and add a
model validation error (e.g., errors.add(:metadata, "...invalid
format_version...")) for missing or malformed values; only when Integer(...)
succeeds use the numeric value to decide whether to return early for versions <
2 and proceed with count-field validation otherwise.
app/controllers/application_controller.rb-76-78 (1)

76-78: ⚠️ Potential issue | 🟡 Minor

Don’t redirect Turbo Stream requests from require_pro!.

Protected update actions now using this helper fall back to a redirect instead of a Turbo Stream response, so they bypass the inline flash/update flow and degrade the Turbo UX. Handle the turbo_stream branch with a streamed flash response instead.

As per coding guidelines, app/controllers/**/*_controller.rb: Use Turbo Stream responses for CRUD actions (create, update, destroy) instead of redirects or JSON responses. Use the FlashStreamable concern to send flash messages via Turbo Streams with the stream_flash method.

Also applies to: 92-96

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/application_controller.rb` around lines 76 - 78, The
turbo_stream branch in the require_pro! handler currently issues a redirect
which breaks Turbo UX; replace the redirect_to call inside format.turbo_stream
with a streamed flash response using the FlashStreamable concern: call
stream_flash (or the controller's stream_flash method) to emit the alert and
appropriate status, and ensure the format.turbo_stream branch renders nothing
else so the client receives the Turbo Stream flash update; update the same
pattern for the other affected block at lines ~92-96 to use stream_flash instead
of redirect_to.
spec/controllers/application_controller_spec.rb-32-41 (1)

32-41: ⚠️ Potential issue | 🟡 Minor

This example never exercises the self-hosted bypass.

DawarichSettings.self_hosted? is never stubbed to true, and the user is already Pro, so this spec currently passes through the same path as the previous context. Use a Lite user and enable self-hosted mode here.

Suggested test fix
     context 'when on a self-hosted instance' do
-      let(:user) { create(:user) } # default plan is pro
+      let(:user) { create(:user, plan: :lite) }
 
-      before { sign_in user }
+      before do
+        allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
+        sign_in user
+      end
 
       it 'allows access regardless of plan' do
         get :index
 
         expect(response).to have_http_status(:ok)
       end
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/controllers/application_controller_spec.rb` around lines 32 - 41, The
spec in context 'when on a self-hosted instance' never toggles the self-hosted
flag and uses a Pro user, so it doesn't exercise the bypass; change the user to
a Lite user (e.g. replace let(:user) { create(:user) } with a Lite variant like
create(:user, :lite) or create(:user, plan: :lite)) and stub
DawarichSettings.self_hosted? to true in the before block (use
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)) so the test
actually verifies the self-hosted bypass in ApplicationController.
app/views/settings/integrations/index.html.erb-7-23 (1)

7-23: ⚠️ Potential issue | 🟡 Minor

Move Connected Accounts outside the Pro gate.

The new top-level if @pro_required`` branch hides the OAuth provider summary for Lite users, even though that section is unrelated to Immich/Photoprism access. This turns a photo-integration gate into a general settings-page regression.

Also applies to: 116-128

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/settings/integrations/index.html.erb` around lines 7 - 23, The
"Connected Accounts" / OAuth provider summary is mistakenly placed inside the
`@pro_required` branch, hiding it for Lite users; move the markup that renders the
"Connected Accounts" section (the OAuth provider summary and its heading/button
block) out of the if `@pro_required` ... else ... end conditional so it renders
for all users, leaving only the Immich/Photoprism-specific card inside the
pro-only branch; update the surrounding ERB so `@pro_required` only wraps the
Immich/Photoprism card and ensure HTML/ERB tags (the opening <% if `@pro_required`
%>, the else branch and the corresponding end) are adjusted accordingly to keep
the Connected Accounts markup outside the conditional.
spec/models/user_spec.rb-48-50 (1)

48-50: ⚠️ Potential issue | 🟡 Minor

This example still creates the user before the self-hosted stub is applied.

Because the context uses let!, the user is materialized before the later before hook runs. So this assertion can pass or fail based on the suite default instead of the self-hosted branch you're trying to cover. Make user lazy here (or stub earlier) so creation happens after DawarichSettings.self_hosted? is forced to true.

Suggested test setup fix
-        let!(:user) { create(:user, :inactive) }
-
         before do
           allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
         end
+
+        let(:user) { create(:user, :inactive) }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/models/user_spec.rb` around lines 48 - 50, The test is creating user too
early because the spec uses let! which materializes the user before the before
hook that stubs DawarichSettings.self_hosted?; change the spec to use a lazy let
(replace let! with let for the user) or move the DawarichSettings.self_hosted?
stub into an earlier setup (e.g., a top-level before block or use
allow(DawarichSettings).to receive(:self_hosted?).and_return(true) before user
is referenced) so that User creation (the user factory used in the example)
happens after self_hosted? is forced to true and the pro? assertion reliably
exercises the self-hosted branch.
spec/serializers/api/user_serializer_spec.rb-118-121 (1)

118-121: ⚠️ Potential issue | 🟡 Minor

Assert the serialized plan value, not just its type.

At Lines 118-121, be_a(String) still passes if the serializer returns the wrong enum name or a stale value. Compare it directly with test_user.plan, like the other subscription fields.

Proposed test fix
             expect(subscription).to include(:status, :active_until, :plan)
             expect(subscription[:status]).to be_a(String)
             expect(subscription[:active_until]).to be_a(ActiveSupport::TimeWithZone)
-            expect(subscription[:plan]).to be_a(String)
+            expect(subscription[:plan]).to eq(test_user.plan)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/serializers/api/user_serializer_spec.rb` around lines 118 - 121, The
test currently only asserts the serialized subscription[:plan] is a String which
can hide wrong enum values; replace that loose type assertion by asserting the
actual value matches the object's plan (i.e. change the expectation on
subscription[:plan] to compare to test_user.plan so the serializer returns the
correct enum name), keeping the other expectations for :status and :active_until
unchanged.
e2e/setup/auth-lite.setup.js-12-15 (1)

12-15: ⚠️ Potential issue | 🟡 Minor

Switch these sign-in locators to data-testids.

Lines 12-15 rely on field names and submit text, which makes this setup brittle to markup or copy changes. Please target stable test ids instead.

Proposed locator update
-  await page.fill('input[name="user[email]"]', "lite@dawarich.app")
-  await page.fill('input[name="user[password]"]', "password")
+  await page.getByTestId("sign-in-email").fill("lite@dawarich.app")
+  await page.getByTestId("sign-in-password").fill("password")
 
-  await page.click('input[type="submit"][value="Log in"]')
+  await page.getByTestId("sign-in-submit").click()

As per coding guidelines, e2e/**/*.{js,ts}: Use data-testid selectors in browser test files for DOM queries.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/setup/auth-lite.setup.js` around lines 12 - 15, Replace the brittle
name/text-based selectors used in the sign-in flow by switching the page.fill
and page.click calls to target stable data-testid attributes; update the two
page.fill calls that currently use 'input[name="user[email]"]' and
'input[name="user[password]"]' to use their corresponding data-testid values
(e.g., the email and password input testids), and change the page.click that
targets 'input[type="submit"][value="Log in"]' to click the submit element using
its data-testid. Ensure the testids you use match the app's markup (add them if
missing) and keep using the existing page.fill and page.click calls (only change
the selector strings).
spec/requests/users/digests_spec.rb-90-91 (1)

90-91: ⚠️ Potential issue | 🟡 Minor

Pin cloud mode in the Pro-plan examples.

These examples currently inherit whatever DawarichSettings.self_hosted? defaults to in test, so they can still pass via the self-hosted bypass instead of the Pro-only branch. Stubbing it to false here keeps the spec targeted.

💡 Suggested change
       context 'when user is on Pro plan' do
-        before { user.update_column(:plan, User.plans[:pro]) }
+        before do
+          allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
+          user.update_column(:plan, User.plans[:pro])
+        end
 
         it 'shows full digest with monthly chart and detailed stats' do
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/requests/users/digests_spec.rb` around lines 90 - 91, The Pro-plan
examples can pass via the self-hosted bypass because
DawarichSettings.self_hosted? is not pinned; in the context 'when user is on Pro
plan' (where user.update_column(:plan, User.plans[:pro]) is used) stub
DawarichSettings.self_hosted? to return false for the duration of the examples
so the test exercises the Pro-only branch (use a before block to
allow(DawarichSettings).to receive(:self_hosted?).and_return(false) and ensure
it is reset after if required).
app/javascript/controllers/maps/maplibre/map_data_manager.js-376-383 (1)

376-383: ⚠️ Potential issue | 🟡 Minor

Dismiss the data-window banner when it no longer applies.

This helper only shows the upsell. If a Lite user narrows the query so totalInRange <= loadedCount, the previous banner will stay visible because there is no hide/reset path here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/controllers/maps/maplibre/map_data_manager.js` around lines
376 - 383, The _showDataWindowBanner helper only shows the banner but never
hides it; update _showDataWindowBanner(totalInRange, loadedCount) to call
UpgradeBanner.hide() (or the banner's dismiss method if named differently) when
totalInRange <= loadedCount so the upsell is removed as soon as it no longer
applies; keep the existing UpgradeBanner.show call (using
this.controller.upgradeUrlValue and utmContent) for the > case and add the
complementary hide call in the else branch to reset the banner state.
app/views/users/digests/show.html.erb-126-163 (1)

126-163: ⚠️ Potential issue | 🟡 Minor

Don’t gate away the No tracking data fallback.

Line 126 only checks @digest.top_countries_by_time.any?, so the placeholder on Lines 148-159 never renders for a fully untracked year — exactly when the explanation is most useful. Widen the outer condition to include positive untracked_days.

Suggested fix
-    <% if `@digest.top_countries_by_time.any`? %>
+    <% if `@digest.top_countries_by_time.any`? || `@digest.untracked_days.to_i.positive`? %>
       <div class="card bg-base-200 shadow-xl mb-8">
         <div class="card-body text-center items-center">
@@
-            <% if `@digest.untracked_days` > 0 %>
+            <% if `@digest.untracked_days.to_i.positive`? %>
               <div class="flex justify-between items-center p-3 bg-base-100 rounded-lg border-2 border-dashed border-gray-200">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/users/digests/show.html.erb` around lines 126 - 163, The outer
conditional currently only renders the country card when
`@digest.top_countries_by_time.any`?, which hides the "No tracking data" fallback
when a year is fully untracked; change the top conditional that wraps the card
to check either `@digest.top_countries_by_time.any`? OR
`@digest.untracked_days.to_i` > 0 so the card always renders when there are
untracked days, leaving the inner logic (the .take(5).each_with_index loop and
the `@digest.untracked_days` conditional) unchanged.
🧹 Nitpick comments (16)
spec/models/points/raw_data_archive_spec.rb (1)

67-85: Add a one-missing-count example to protect the validator branch.

These cases cover “both missing” and “both present”, but not the actual expected_count.blank? || actual_count.blank? branch. A regression to && would still pass this block, so it’s worth adding a case where exactly one count is missing.

Suggested spec addition
     it 'rejects format_version 2 archives missing count fields' do
       archive = build(:points_raw_data_archive, user: user, metadata: {
                         'format_version' => 2,
                          'compression' => 'gzip',
                          'encryption' => 'aes-256-gcm'
                       })
       expect(archive).not_to be_valid
       expect(archive.errors[:metadata]).to include('must contain expected_count and actual_count')
     end
+
+    it 'rejects format_version 2 archives when only one count field is present' do
+      archive = build(:points_raw_data_archive, user: user, metadata: {
+                        'format_version' => 2,
+                        'compression' => 'gzip',
+                        'encryption' => 'aes-256-gcm',
+                        'expected_count' => 100
+                      })
+      expect(archive).not_to be_valid
+      expect(archive.errors[:metadata]).to include('must contain expected_count and actual_count')
+    end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/models/points/raw_data_archive_spec.rb` around lines 67 - 85, Add a spec
that exercises the validator branch where exactly one count is missing: in
spec/models/points/raw_data_archive_spec.rb add an example (e.g. it 'rejects
format_version 2 archives with one missing count field') that builds a
points_raw_data_archive via build(:points_raw_data_archive, user: user,
metadata: { 'format_version' => 2, 'compression' => 'gzip', 'encryption' =>
'aes-256-gcm', 'expected_count' => 100 }) (omit 'actual_count') and asserts
archive is invalid and archive.errors[:metadata] includes 'must contain
expected_count and actual_count'; you can also add the mirrored case (only
'actual_count' present) to fully protect the expected_count.blank? ||
actual_count.blank? branch.
lib/tasks/demo.rake (1)

125-131: Keep Lite visits and areas sourced from recent points.

These calls read from the full Lite dataset, not the recent slice. In particular, create_areas orders by timestamp, so with 20 recent + 10 old points and count = 2, one of the two Lite areas will be built from the archived portion. If this fixture is meant to demo the 12-month Lite window, pass a recent-only scope or dedicated Lite helpers here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/tasks/demo.rake` around lines 125 - 131, The Lite demo is using the full
dataset when calling create_visits and create_areas, so areas can be built from
archived points; update the calls that create Lite fixtures (create_visits and
create_areas invoked for lite_user) to use the recent-only slice or the
Lite-specific helper scope (e.g., pass a recent_points scope or use
create_areas_for_lite / create_visits_for_lite) so both visits and areas are
sourced only from the recent 12-month Lite window; ensure the create_areas call
no longer relies on the global ordering by timestamp but instead receives the
filtered recent dataset for lite_user.
app/controllers/settings/integrations_controller.rb (1)

19-22: Consider Turbo Stream response for update action.

The coding guidelines specify using Turbo Stream responses for CRUD actions instead of redirects. Consider using FlashStreamable concern with stream_flash for the flash messages.

Example using Turbo Stream
+ include FlashStreamable
+
  def update
    result = Settings::Update.new(
      current_user,
      settings_params,
      refresh_photos_cache: params[:refresh_photos_cache].present?
    ).call

-   flash[:notice] = result[:notices].join('. ') if result[:notices].any?
-   flash[:alert] = result[:alerts].join('. ') if result[:alerts].any?
-
-   redirect_to settings_integrations_path
+   respond_to do |format|
+     format.turbo_stream do
+       stream_flash(notice: result[:notices].join('. ')) if result[:notices].any?
+       stream_flash(alert: result[:alerts].join('. ')) if result[:alerts].any?
+     end
+     format.html { redirect_to settings_integrations_path }
+   end
  end

As per coding guidelines: "Use Turbo Stream responses for CRUD actions (create, update, destroy) instead of redirects or JSON responses" and "Use the FlashStreamable concern to send flash messages via Turbo Streams with the stream_flash method".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/settings/integrations_controller.rb` around lines 19 - 22,
The update action currently sets flash[:notice]/[:alert] from result and then
calls redirect_to settings_integrations_path; change this to emit a Turbo Stream
response using the FlashStreamable concern: include FlashStreamable in
IntegrationsController (if not already), set the flash entries as you do from
result (flash[:notice] = ...; flash[:alert] = ...), then call stream_flash to
stream those flash messages and render a turbo_stream response that updates the
settings integrations UI (e.g., render a turbo_stream that replaces the
integrations list or a specific partial) instead of redirecting; replace the
existing redirect_to settings_integrations_path with a render turbo_stream flow
that uses stream_flash and updates the relevant DOM.
spec/controllers/application_controller_spec.rb (1)

18-74: Add JSON and Turbo Stream coverage for require_pro!.

The helper has separate html, json, and turbo_stream branches, but these specs only send HTML requests. A regression in either non-HTML response path would currently go unnoticed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/controllers/application_controller_spec.rb` around lines 18 - 74, Add
specs that exercise the JSON and turbo_stream branches of require_pro! in
addition to the existing HTML tests: for the pro plan and self-hosted contexts
send requests with format: :json and format: :turbo_stream (or appropriate
ACCEPT headers) and assert 200/ok responses and body as expected; for the lite
plan and unsigned contexts send requests with format: :json and :turbo_stream
and assert the correct redirect/status and alert payloads (JSON error body or
turbo_stream redirect/flash behavior) so the require_pro! branches for json and
turbo_stream are covered alongside the existing HTML examples.
app/views/insights/details.html.erb (1)

10-58: Consider extracting the repeated gating pattern into a helper.

The if show_plan_data_window_alert? ... else cache ... end pattern is repeated 6 times. This could be refactored into a helper method for better maintainability.

♻️ Example helper extraction
# app/helpers/insights_helper.rb
def render_insight_section(title:, utm_content:, cache_key:, &block)
  if show_plan_data_window_alert?
    render 'pro_locked_card', title: title, utm_content: utm_content
  else
    cache(cache_key, expires_in: 24.hours, &block)
  end
end

Then in the view:

<%= render_insight_section(
      title: 'Year Comparison',
      utm_content: 'year_comparison',
      cache_key: insights_cache_key + ["year_comparison"]) do %>
  <%= render 'year_comparison' %>
<% end %>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/insights/details.html.erb` around lines 10 - 58, Extract the
repeated gating pattern around show_plan_data_window_alert? into a helper (e.g.,
render_insight_section) that accepts title, utm_content, cache_key and a block;
replace each repeated if/else cache ... render 'pro_locked_card' logic in the
view with calls to this helper using insights_cache_key + ["..."] (and include
`@selected_month` where appropriate) so the view only calls
render_insight_section(title:, utm_content:, cache_key:) { render 'partial_name'
} while the helper renders the pro_locked_card when show_plan_data_window_alert?
is true or otherwise wraps the provided block in cache(..., expires_in:
24.hours).
app/views/stats/_locked_year_card.html.erb (1)

12-13: Avoid rand in the view for placeholder chart data.

This makes the locked card change on every render, which is noisy for users and undermines deterministic caching/tests. A fixed sample series is safer here.

♻️ Suggested change
         <%= column_chart(
-          (1..12).map { |m| [Date::MONTHNAMES[m], rand(5_000..80_000)] },
+          [
+            ['January', 18_000], ['February', 26_000], ['March', 34_000],
+            ['April', 29_000], ['May', 41_000], ['June', 52_000],
+            ['July', 48_000], ['August', 57_000], ['September', 44_000],
+            ['October', 36_000], ['November', 31_000], ['December', 24_000]
+          ],
           height: '200px',
           suffix: " #{current_user.safe_settings.distance_unit}",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/stats/_locked_year_card.html.erb` around lines 12 - 13, The view
uses a non-deterministic data series in the column_chart call by mapping
(1..12).map { |m| [Date::MONTHNAMES[m], rand(5_000..80_000)] }, causing the
chart to change on every render; replace that rand-based series with a fixed
sample array of 12 month/value pairs (keep Date::MONTHNAMES for labels or a
literal array of month names) so the partial (_locked_year_card.html.erb)
renders deterministic, cacheable, and testable data instead of random values.
e2e/lite/plan-gates.spec.js (2)

144-148: Consider using more stable selectors for settings gating test.

The current selectors use text= and input[placeholder*="Immich"] which may be fragile if text changes. Per coding guidelines, prefer data-testid selectors for DOM queries in E2E tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/lite/plan-gates.spec.js` around lines 144 - 148, Replace fragile text and
placeholder-based locators with stable data-testid selectors: swap
page.locator("text=Upgrade to Pro") for a data-testid-based locator (e.g.
page.locator('[data-testid="upgrade-to-pro"]')) and swap
page.locator('input[placeholder*="Immich"]').first() for a data-testid like
'[data-testid="immich-url-input"]'; update the app templates to add those
data-testid attributes if they do not exist and adjust the assertions in the
test accordingly (keep the same visibility checks using the new locators).

125-131: Minor: 12-month calculation approximation may drift from server logic.

The calculation 12 * 30 * 24 * 60 * 60 assumes 30-day months (360 days), while actual 12 months is ~365 days. If the server uses 12.months.ago (Rails), this could cause edge-case test failures.

Consider aligning with the server's approach or adding a small buffer:

-    const twelveMonthsAgo = Date.now() / 1000 - 12 * 30 * 24 * 60 * 60
+    // Use 365 days to match Rails' 12.months.ago more closely
+    const twelveMonthsAgo = Date.now() / 1000 - 365 * 24 * 60 * 60
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/lite/plan-gates.spec.js` around lines 125 - 131, The 12-month window
calculation using 12 * 30 * 24 * 60 * 60 (variable twelveMonthsAgo)
underestimates a true year and can drift from server-side 12.months.ago; update
the twelveMonthsAgo computation to use a 365-day year (365 * 24 * 60 * 60) or
add a small safety buffer (e.g., +7 days) so the
expect(point.timestamp).toBeGreaterThanOrEqual(Math.floor(twelveMonthsAgo))
check aligns with server logic and avoids edge-case failures in the points loop.
spec/requests/api/v1/points_spec.rb (1)

468-490: Self-hosted context may need explicit stub for consistency.

The self-hosted context doesn't explicitly stub DawarichSettings.self_hosted?. While it may default to true in the test environment, other contexts explicitly stub it. For consistency and to avoid test brittleness if defaults change, consider adding an explicit stub.

Suggested addition
     context 'when on a self-hosted instance' do
       let!(:self_hosted_user) { create(:user) } # default plan is pro

+      before do
+        allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
+      end
+
       let!(:recent_point) do
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/requests/api/v1/points_spec.rb` around lines 468 - 490, The self-hosted
context relies on a default but doesn't explicitly stub
DawarichSettings.self_hosted? which can cause brittle tests; inside the "when on
a self-hosted instance" context add an explicit stub (e.g., a before block that
uses allow(DawarichSettings).to receive(:self_hosted?).and_return(true)) so the
behavior is deterministic for the tests that create self_hosted_user,
recent_point, and old_point.
app/views/insights/index.html.erb (1)

24-37: Consider accessibility for locked content cards.

The locked cards provide visual indication that features are Pro-only, but ensure the pro_locked_card partial includes appropriate ARIA attributes or screen reader text to communicate the locked state to assistive technology users.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/insights/index.html.erb` around lines 24 - 37, The pro_locked_card
partial currently only provides visual cues; update the pro_locked_card partial
to expose the locked state to assistive tech by adding a screen-reader-only text
(e.g., "Pro feature — locked") and/or ARIA attributes such as
aria-disabled="true" and aria-label or aria-describedby that conveys the lock,
ensure any interactive element inside pro_locked_card uses tabindex="-1" when
locked (or role="button" with aria-disabled) so it's not focusable/announced as
actionable, and verify the turbo_frame_tag "insights_details" content still
reads correctly to screen readers with these additions.
config/initializers/rack_attack.rb (1)

32-35: Consider cache invalidation after plan upgrade.

The 2-minute cache for plan lookup (expires_in: 2.minutes) means users who upgrade from Lite to Pro won't see their new rate limits for up to 2 minutes. This is generally acceptable, but consider if immediate reflection is needed.

If so, you could invalidate the cache key when a user's plan changes:

# In User model or plan upgrade service
Rails.cache.delete("rack_attack/plan/#{api_key}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/initializers/rack_attack.rb` around lines 32 - 35, The current
two-minute cache of User plan (created via Rails.cache.fetch in rack_attack.rb
using user_plan and api_key) can delay new rate limits after a plan upgrade; fix
this by invalidating that cache entry when a user's plan changes—add a cache
delete call for the "rack_attack/plan/#{api_key}" key in the User model (e.g.,
an after_commit callback detecting plan change) or in the plan upgrade service
so the next Rack::Attack lookup reads the updated plan immediately.
spec/models/concerns/plan_scopable_spec.rb (1)

39-42: Consider using freeze_time for time-sensitive assertions.

The be_within(1.second) matcher works but could be flaky if tests run slowly. Consider wrapping time-sensitive tests with freeze_time or travel_to for deterministic behavior.

♻️ Suggested improvement
 describe '#data_window_start' do
+  around do |example|
+    freeze_time { example.run }
+  end
+
   it 'returns approximately 12 months ago' do
-    expect(user.data_window_start).to be_within(1.second).of(12.months.ago)
+    expect(user.data_window_start).to eq(12.months.ago)
   end
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/models/concerns/plan_scopable_spec.rb` around lines 39 - 42, Wrap the
time-sensitive example that asserts user.data_window_start in a deterministic
time block (e.g., use freeze_time or travel_to from
ActiveSupport::Testing::TimeHelpers) so it doesn't flake; locate the example in
spec/models/concerns/plan_scopable_spec.rb that calls user.data_window_start and
either wrap the expectation in freeze_time { ... } or travel_to a fixed time
(e.g., travel_to 12.months.ago and assert equality) to make the assertion
stable.
app/javascript/maps_maplibre/utils/layer_gate.js (1)

70-75: Countdown timer updates every 5 seconds but decrements by 5.

The countdown shows "20s → 15s → 10s → 5s" which works, but the first update happens after 5 seconds, so users initially see "20 seconds" for the first 5 seconds. Consider showing "15s remaining" immediately or adjusting the messaging.

💡 Alternative: start countdown immediately
   let remaining = PREVIEW_SECONDS
+  const updateToast = () => {
+    if (remaining > 0 && toast?.parentNode) {
+      toast.textContent = `Previewing ${layerName} — ${remaining}s remaining.`
+    }
+  }
   const countdownInterval = setInterval(() => {
     remaining -= 5
-    if (remaining > 0 && toast?.parentNode) {
-      toast.textContent = `Previewing ${layerName} — ${remaining}s remaining.`
-    }
+    updateToast()
   }, 5000)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/maps_maplibre/utils/layer_gate.js` around lines 70 - 75, The
countdown currently waits 5s before the first decrement so users see the full
starting value; update the UI immediately by adjusting the logic around
countdownInterval: either decrement remaining once before starting the
setInterval or set toast.textContent using remaining-5 right away (referencing
remaining, countdownInterval, toast, and layerName), then keep the existing 5s
interval for subsequent updates; ensure you also handle the case where remaining
<= 0 and clearInterval(countdownInterval) as needed.
app/controllers/api_controller.rb (1)

41-63: Consider extracting shared logic from require_pro_api! and require_write_api!.

These two methods are nearly identical, differing only in error codes and messages. Consider extracting a shared helper:

♻️ DRY refactor suggestion
def require_pro_api!
  require_pro_plan!('pro_plan_required', 'This feature requires a Pro plan.')
end

def require_write_api!
  require_pro_plan!('write_api_restricted', 'Write API access requires a Pro plan. Your data was not modified.')
end

private

def require_pro_plan!(error_code, message)
  return unless current_api_user
  return if DawarichSettings.self_hosted?
  return if current_api_user.pro?

  render json: {
    error: error_code,
    message: message,
    upgrade_url: upgrade_url_for(current_api_user)
  }, status: :forbidden
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/api_controller.rb` around lines 41 - 63, The two nearly
identical methods require_pro_api! and require_write_api! should be DRYed by
extracting the shared logic into a private helper (e.g., require_pro_plan!) that
accepts the error code and message; update require_pro_api! and
require_write_api! to call require_pro_plan! with their specific error strings,
and move the existing checks (current_api_user guard,
DawarichSettings.self_hosted? check, current_api_user.pro? check, and render
json with upgrade_url_for(current_api_user) and status :forbidden) into that
helper.
app/javascript/maps_maplibre/components/upgrade_banner.js (1)

5-81: Consider refactoring to a plain module with exported functions.

Static analysis flags this class as containing only static members. A plain module with exported functions would be more idiomatic JavaScript:

♻️ Alternative module-based approach
let activeBanner = null

export function showUpgradeBanner({ message, upgradeUrl, utmContent }) {
  // ... implementation
}

export function dismissUpgradeBanner() {
  // ... implementation
}

That said, the current class-based approach provides a clean namespace and the singleton pattern is explicit. This is a minor stylistic preference.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/maps_maplibre/components/upgrade_banner.js` around lines 5 -
81, The UpgradeBanner class only contains static members; refactor it into a
plain module by replacing the class with a module-level variable activeBanner
and two exported functions showUpgradeBanner and dismissUpgradeBanner that
implement the same logic currently in UpgradeBanner.show and
UpgradeBanner.dismiss; move static activeBanner -> let activeBanner = null,
implement showUpgradeBanner({ message, upgradeUrl, utmContent }) to create and
append the banner (same DOM logic, use encodeURIComponent for utmContent) and
set activeBanner, and implement dismissUpgradeBanner() to remove activeBanner,
clear it, and set sessionStorage, then update any callers to use the new
exported function names instead of UpgradeBanner.show/UpgradeBanner.dismiss.
app/controllers/insights_controller.rb (1)

30-31: Guard conditions may be redundant.

If year_locked? returns true, then current_user.plan_restricted? must also be true (since year_locked? checks plan_restricted? internally on line 52). The || current_user.plan_restricted? condition appears to cover the case where the year itself isn't locked but the user is still restricted.

However, the intent could be clearer. If you want to block all restricted users from the details page regardless of year, consider adding a comment or restructuring:

💡 Clarify intent with a comment
 load_year_stats
-return if `@year_locked` || current_user.plan_restricted?
+# Block access if viewing a locked year OR if restricted user selects unlocked year
+# (Lite users can view scoped years but not full details)
+return if `@year_locked` || current_user.plan_restricted?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/insights_controller.rb` around lines 30 - 31, The guard
`return if `@year_locked` || current_user.plan_restricted?` is redundant because
the `year_locked?` check (see year_locked? method) already inspects
`current_user.plan_restricted?`; either drop the explicit
`current_user.plan_restricted?` from this guard and leave `return if
`@year_locked`` (or `return if year_locked?` if you prefer the method call), or
keep both but add a clarifying comment above the line explaining why both are
needed; update the line referencing the `@year_locked`/`year_locked?` symbols
accordingly to remove ambiguity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d55fa68c-e454-4d1a-a091-0ef5d77965dc

📥 Commits

Reviewing files that changed from the base of the PR and between 29f00df and b99fe83.

⛔ Files ignored due to path filters (4)
  • Gemfile.lock is excluded by !**/*.lock
  • app/assets/svg/icons/brands/github.svg is excluded by !**/*.svg
  • app/assets/svg/icons/brands/google.svg is excluded by !**/*.svg
  • app/assets/svg/icons/lucide/outline/lock.svg is excluded by !**/*.svg
📒 Files selected for processing (106)
  • .app_version
  • CHANGELOG.md
  • CLAUDE.md
  • Gemfile
  • app/assets/builds/tailwind.css
  • app/assets/stylesheets/maps_maplibre.css
  • app/controllers/api/v1/insights_controller.rb
  • app/controllers/api/v1/plan_controller.rb
  • app/controllers/api/v1/points_controller.rb
  • app/controllers/api/v1/settings_controller.rb
  • app/controllers/api/v1/subscriptions_controller.rb
  • app/controllers/api/v1/tracks/points_controller.rb
  • app/controllers/api/v1/visits_controller.rb
  • app/controllers/api_controller.rb
  • app/controllers/application_controller.rb
  • app/controllers/concerns/safe_timestamp_parser.rb
  • app/controllers/imports_controller.rb
  • app/controllers/insights_controller.rb
  • app/controllers/map/leaflet_controller.rb
  • app/controllers/settings/integrations_controller.rb
  • app/controllers/shared/digests_controller.rb
  • app/controllers/shared/stats_controller.rb
  • app/controllers/stats_controller.rb
  • app/controllers/users/digests_controller.rb
  • app/controllers/visits_controller.rb
  • app/helpers/application_helper.rb
  • app/javascript/controllers/maps/maplibre/data_loader.js
  • app/javascript/controllers/maps/maplibre/map_data_manager.js
  • app/javascript/controllers/maps/maplibre/routes_manager.js
  • app/javascript/controllers/maps/maplibre/settings_manager.js
  • app/javascript/controllers/maps/maplibre_controller.js
  • app/javascript/maps_maplibre/components/toast.js
  • app/javascript/maps_maplibre/components/upgrade_banner.js
  • app/javascript/maps_maplibre/services/api_client.js
  • app/javascript/maps_maplibre/utils/layer_gate.js
  • app/jobs/lite/archival_warning_job.rb
  • app/mailers/users_mailer.rb
  • app/models/concerns/plan_scopable.rb
  • app/models/points/raw_data_archive.rb
  • app/models/user.rb
  • app/queries/tracks/index_query.rb
  • app/serializers/api/user_serializer.rb
  • app/services/location_search/spatial_matcher.rb
  • app/services/timeline/day_assembler.rb
  • app/services/users/safe_settings.rb
  • app/services/users/transportation_thresholds_updater.rb
  • app/services/visits/find_in_time.rb
  • app/services/visits/find_within_bounding_box.rb
  • app/views/devise/sessions/new.html.erb
  • app/views/devise/shared/_links.html.erb
  • app/views/insights/_header.html.erb
  • app/views/insights/_pro_locked_card.html.erb
  • app/views/insights/details.html.erb
  • app/views/insights/index.html.erb
  • app/views/map/leaflet/index.html.erb
  • app/views/map/maplibre/_settings_panel.html.erb
  • app/views/map/maplibre/index.html.erb
  • app/views/map/timeline_feeds/_feed.html.erb
  • app/views/settings/integrations/index.html.erb
  • app/views/shared/_plan_data_window_alert.html.erb
  • app/views/shared/_sharing_modal.html.erb
  • app/views/shared/map/_upgrade_banner.html.erb
  • app/views/stats/_locked_year_card.html.erb
  • app/views/stats/_month.html.erb
  • app/views/stats/index.html.erb
  • app/views/stats/month.html.erb
  • app/views/stats/show.html.erb
  • app/views/users/digests/public_year.html.erb
  • app/views/users/digests/show.html.erb
  • app/views/users_mailer/archival_approaching.html.erb
  • app/views/users_mailer/archival_approaching.text.erb
  • app/views/visits/index.html.erb
  • config/initializers/03_dawarich_settings.rb
  • config/initializers/rack_attack.rb
  • config/initializers/rails_icons.rb
  • config/routes.rb
  • config/schedule.yml
  • db/migrate/20260301201446_add_plan_to_users.rb
  • db/migrate/20260301202147_set_plan_for_existing_users.rb
  • db/schema.rb
  • e2e/lite/plan-gates.spec.js
  • e2e/setup/auth-lite.setup.js
  • e2e/v2/helpers/constants.js
  • lib/tasks/demo.rake
  • playwright.config.js
  • spec/controllers/api_controller_spec.rb
  • spec/controllers/application_controller_spec.rb
  • spec/factories/users.rb
  • spec/helpers/application_helper_spec.rb
  • spec/jobs/lite/archival_warning_job_spec.rb
  • spec/models/concerns/plan_scopable_spec.rb
  • spec/models/points/raw_data_archive_spec.rb
  • spec/models/user_spec.rb
  • spec/requests/api/v1/plan_spec.rb
  • spec/requests/api/v1/points_spec.rb
  • spec/requests/api/v1/rate_limiting_spec.rb
  • spec/requests/api/v1/subscriptions_spec.rb
  • spec/requests/api/v1/tracks/points_spec.rb
  • spec/requests/imports_spec.rb
  • spec/requests/settings/integrations_spec.rb
  • spec/requests/shared/stats_spec.rb
  • spec/requests/users/digests_spec.rb
  • spec/serializers/api/user_serializer_spec.rb
  • spec/services/users/safe_settings_spec.rb
  • spec/services/users/transportation_thresholds_updater_spec.rb
  • spec/support/devise.rb

Comment on lines 24 to +25
def show
visit = current_api_user.visits.find(params[:id])
visit = current_api_user.scoped_visits.find(params[:id])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply the same scope to the write endpoints.

show is now plan-scoped, but Lines 43, 57, and 95 still use current_api_user.visits. That leaves visits outside the active plan window writable, mergeable, and deletable by ID even though they are no longer readable via show.

Suggested fix
 def update
-  visit = current_api_user.visits.find(params[:id])
+  visit = current_api_user.scoped_visits.find(params[:id])
   visit = update_visit(visit)

   render json: Api::VisitSerializer.new(visit).call
 end
- visits = current_api_user.visits.where(id: visit_ids).order(started_at: :asc)
+ visits = current_api_user.scoped_visits.where(id: visit_ids).order(started_at: :asc)
 def destroy
-  visit = current_api_user.visits.find(params[:id])
+  visit = current_api_user.scoped_visits.find(params[:id])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/api/v1/visits_controller.rb` around lines 24 - 25, The write
endpoints are still using current_api_user.visits.find which allows modifying
visits outside the active plan window; update those calls to use the same scoped
lookup as show by replacing current_api_user.visits.find(...) with
current_api_user.scoped_visits.find(...) in the actions that perform writes (the
update, merge, and destroy endpoints referenced around lines 43, 57, and 95).
Ensure any helper methods or before_actions that load a visit also use
scoped_visits so all read and write operations consistently apply the plan
scope.

Comment on lines +14 to +15
# Treat purely numeric strings as Unix timestamps
return date_string.to_i.clamp(min_timestamp, max_timestamp) if date_string.match?(/\A\d+\z/)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep numeric inputs from bypassing the “safe” fallback.

Line 15 assumes date_string responds to match?. If a controller passes an actual Integer from JSON params, this raises NoMethodError, which is not rescued here and turns a valid numeric timestamp into a 500. Branch on Numeric first, or normalize before regex matching.

Suggested fix
-    return date_string.to_i.clamp(min_timestamp, max_timestamp) if date_string.match?(/\A\d+\z/)
+    if date_string.is_a?(Numeric) || date_string.to_s.match?(/\A\d+\z/)
+      return date_string.to_i.clamp(min_timestamp, max_timestamp)
+    end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/concerns/safe_timestamp_parser.rb` around lines 14 - 15, The
current check assumes date_string responds to match? and fails for Integer
inputs; update the logic in SafeTimestampParser so numeric params are handled
first (e.g., if date_string.is_a?(Numeric) then use
date_string.to_i.clamp(min_timestamp, max_timestamp)), or normalize the input to
a String before running the regex (e.g., convert date_string.to_s and then call
match?(/\A\d+\z/)); adjust the branch that currently uses
date_string.match?(/\A\d+\z/) to avoid NoMethodError and ensure numeric
timestamps hit the safe clamp path.

Comment on lines 11 to +17
visits = current_user
.visits
.scoped_visits
.where(status:)
.includes(%i[suggested_places area points place])
.order(started_at: order_by)

@suggested_visits_count = current_user.visits.suggested.count
@suggested_visits_count = current_user.scoped_visits.suggested.count
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope set_visit as well.

Line 50 still loads @visit via current_user.visits.find(params[:id]). That lets a user update a visit outside the active plan window if they already have the ID, so the new scoping here only protects the list view.

Suggested fix
 def set_visit
-  `@visit` = current_user.visits.find(params[:id])
+  `@visit` = current_user.scoped_visits.find(params[:id])
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/visits_controller.rb` around lines 11 - 17, The set_visit
finder still uses current_user.visits.find(params[:id]) and allows access
outside the scoped window; update the set_visit method (the before_action that
assigns `@visit`) to use current_user.scoped_visits.find(params[:id]) instead so
it enforces the same scoped_visits restrictions as the index/listing and
prevents loading visits outside the active plan window.

Comment on lines +69 to +88
// Gated layer toggles that Lite users cannot persist
const gatedToggles = new Set([
"heatmapToggle",
"fogToggle",
"scratchToggle",
])

Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
const target = `${targetName}Target`
const hasTarget = `has${targetName.charAt(0).toUpperCase()}${targetName.slice(1)}Target`
if (controller[hasTarget]) {
controller[target].checked = this.settings[settingKey]
// Force gated layers off for Lite users on page load
if (
gatedToggles.has(targetName) &&
isGatedPlan(controller.userPlanValue)
) {
controller[target].checked = false
} else {
controller[target].checked = this.settings[settingKey]
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== Gated setting reads/writes ==\n'
rg -n -C2 '\b(heatmapEnabled|fogEnabled|scratchEnabled|globeProjection)\b' app/javascript

printf '\n== Globe projection initialization ==\n'
rg -n -C2 '\bsetProjection\s*\(|\bglobeProjection\b' app/javascript/controllers/maps app/javascript/maps_maplibre

printf '\n== Existing gated-plan normalization ==\n'
rg -n -C2 '\bisGatedPlan\b|userPlanValue' app/javascript

Repository: Freika/dawarich

Length of output: 20490


The Lite gate only flips checkboxes; it does not clear the stored gated flags.

syncToggleStates() leaves this.settings.heatmapEnabled, fogEnabled, scratchEnabled, and globeProjection untouched. Since these settings are read directly during map initialization (line 346), layer creation, and data loading, a Lite user with previously enabled gated features will still have them active on the map despite the checkbox being forced off. Globe View is especially risky because the projection is applied during MapInitializer.initialize() before checkbox sync runs.

To fix this, normalize these settings to false for Lite users alongside the checkbox updates, or guard their use throughout initialization and rendering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/controllers/maps/maplibre/settings_manager.js` around lines 69
- 88, syncToggleStates() currently forces checkbox state for gated toggles but
does not update the underlying settings, so Lite users can still have gated
features active; update syncToggleStates() (the block that iterates toggleMap
and uses gatedToggles) to also set this.settings[settingKey] = false when
gatedToggles.has(targetName) && isGatedPlan(controller.userPlanValue), and
specifically ensure globeProjection (and the keys heatmapEnabled, fogEnabled,
scratchEnabled) are normalized to false for Lite users so
MapInitializer.initialize() and layer creation/read paths read the disabled
value rather than relying solely on the checkbox.

Comment on lines +49 to +52
def sanitize_gated_layers
if @user.settings.key?('enabled_map_layers')
@user.settings['enabled_map_layers'] -= Users::SafeSettings::GATED_MAP_LAYERS
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard enabled_map_layers before subtracting gated layers.

key? does not guarantee an array value. If this setting is present but nil, the -= will raise and turn a Lite settings update into a 500.

🛠️ Suggested fix
     def sanitize_gated_layers
       if `@user.settings.key`?('enabled_map_layers')
-        `@user.settings`['enabled_map_layers'] -= Users::SafeSettings::GATED_MAP_LAYERS
+        `@user.settings`['enabled_map_layers'] =
+          Array.wrap(`@user.settings`['enabled_map_layers']) - Users::SafeSettings::GATED_MAP_LAYERS
       end
       `@user.settings`['globe_projection'] = false if `@settings_params.key`?('globe_projection')
     end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def sanitize_gated_layers
if @user.settings.key?('enabled_map_layers')
@user.settings['enabled_map_layers'] -= Users::SafeSettings::GATED_MAP_LAYERS
end
def sanitize_gated_layers
if `@user.settings.key`?('enabled_map_layers')
`@user.settings`['enabled_map_layers'] =
Array.wrap(`@user.settings`['enabled_map_layers']) - Users::SafeSettings::GATED_MAP_LAYERS
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/users/transportation_thresholds_updater.rb` around lines 49 -
52, sanitize_gated_layers currently assumes `@user.settings`['enabled_map_layers']
is an Array and does a -= which will blow up if it's nil or another type; change
it to guard the value first by fetching the setting into a local (e.g. enabled =
`@user.settings`['enabled_map_layers']) and only perform the subtraction if
enabled.is_a?(Array) (or coerce via Array(enabled) if you intend to treat nil as
empty array), otherwise leave the setting untouched; reference the
sanitize_gated_layers method, `@user.settings`['enabled_map_layers'], and
Users::SafeSettings::GATED_MAP_LAYERS when making the change.

@@ -0,0 +1,36 @@
<div class="card w-full bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-between text-<%= header_colors[year % header_colors.size] %>">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e

echo "== Tailwind config =="
fd 'tailwind\.config\.(js|cjs|ts)$' . -x sh -c 'echo "-- $1 --"; sed -n "1,240p" "$1"' sh {}

echo
echo "== Dynamic class usage =="
rg -n 'text-<%=|header_colors' app

Repository: Freika/dawarich

Length of output: 1519


🏁 Script executed:

#!/bin/bash
set -e

echo "== header_colors definition =="
rg -A 5 'def header_colors' app/helpers/application_helper.rb

echo
echo "== Full tailwind config =="
cat config/tailwind.config.js

Repository: Freika/dawarich

Length of output: 752


Add a safelist for the dynamic header color utilities in Tailwind config.

The text-* classes generated by <%= header_colors[...] %> are never scanned as complete literals (the helper only contains the bare color names info, success, warning, error, accent, secondary, primary). Tailwind will purge them in production. Add a safelist entry to config/tailwind.config.js:

safelist: [
  { pattern: /^text-(info|success|warning|error|accent|secondary|primary)$/ }
]

This pattern also affects other files using the same dynamic approach (app/views/stats/index.html.erb, app/views/stats/_year.html.erb, app/views/insights/_year_comparison.html.erb).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/stats/_locked_year_card.html.erb` at line 3, The dynamic class
generation using header_colors (the text-<%= header_colors[...] %> expressions)
produces partial class names (info, success, etc.) that Tailwind can't detect,
so add a safelist entry to your Tailwind config to preserve those utilities at
build time; update your Tailwind config's safelist to include the regex pattern
{ pattern: /^text-(info|success|warning|error|accent|secondary|primary)$/ }
(this will cover the dynamic uses of header_colors and the same pattern in other
views that build text-* classes).

Comment on lines +126 to +159
<!-- Countries & Cities (Pro / self-hosted only) -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'map-pin' %> Where They Spent the Most Time
<%= icon 'earth' %> Countries & Cities
</h2>
<ul class="space-y-2 w-full">
<% @digest.top_countries_by_time.take(3).each do |country| %>
<li class="flex justify-between items-center p-3 bg-base-200 rounded-lg">
<span class="font-semibold">
<span class="mr-1"><%= country_flag(country['name']) %></span>
<%= country['name'] %>
</span>
<span class="text-gray-600"><%= format_time_spent(country['minutes']) %></span>
</li>
<% end %>
</ul>
</div>
</div>
<% end %>

<!-- Countries & Cities -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'earth' %> Countries & Cities
</h2>
<div class="space-y-4 w-full">
<% @digest.toponyms&.each_with_index do |country, index| %>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="font-semibold">
<span class="mr-1"><%= country_flag(country['country']) %></span>
<%= country['country'] %>
</span>
<span class="text-sm"><%= country['cities']&.length || 0 %> cities</span>
<div class="space-y-4 w-full">
<% @digest.toponyms&.each_with_index do |country, index| %>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="font-semibold">
<span class="mr-1"><%= country_flag(country['country']) %></span>
<%= country['country'] %>
</span>
<span class="text-sm"><%= country['cities']&.length || 0 %> cities</span>
</div>
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 15) %>" max="100"></progress>
</div>
<progress class="progress progress-primary w-full" value="<%= 100 - (index * 15) %>" max="100"></progress>
</div>
<% end %>
</div>
<% end %>
</div>

<div class="divider"></div>
<div class="divider"></div>

<div class="flex flex-wrap gap-2 justify-center w-full">
<span class="text-sm font-medium">Cities visited:</span>
<% @digest.toponyms&.each do |country| %>
<% country['cities']&.take(5)&.each do |city| %>
<div class="badge badge-outline"><%= city['city'] %></div>
<% end %>
<% if country['cities']&.length.to_i > 5 %>
<div class="badge badge-ghost">+<%= country['cities'].length - 5 %> more</div>
<div class="flex flex-wrap gap-2 justify-center w-full">
<span class="text-sm font-medium">Cities visited:</span>
<% @digest.toponyms&.each do |country| %>
<% country['cities']&.take(5)&.each do |city| %>
<div class="badge badge-outline"><%= city['city'] %></div>
<% end %>
<% if country['cities']&.length.to_i > 5 %>
<div class="badge badge-ghost">+<%= country['cities'].length - 5 %> more</div>
<% end %>
<% end %>
<% end %>
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use real city coverage values in the public Countries & Cities card.

Line 142’s 100 - (index * 15) is just rank-based decoration, so the bar stops matching the city counts immediately and becomes negative from the eighth country onward. Also, because this section only uses safe navigation, Lines 147-159 still render the divider and Cities visited: label when @digest.toponyms is blank. Please switch this view to the same data-driven progress/empty-state approach already used in app/views/users/digests/show.html.erb.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/users/digests/public_year.html.erb` around lines 126 - 159, The
progress bars and empty-state handling need to be data-driven: compute the max
city count from `@digest.toponyms` (e.g., max_count = `@digest.toponyms.map`{|c|
c['cities']&.length.to_i}.max.to_i) and for each country calculate percentage =
((country['cities']&.length.to_i * 100) / [max_count,1].max).clamp(0,100) and
use that value for the progress value attribute instead of 100 - (index * 15);
also wrap the whole "Countries & Cities" content (the progress loop, the divider
and the "Cities visited:" block that uses country['cities']&.take(5) and the "+n
more" badge) in a conditional that checks `@digest.toponyms.present`? and render
the same empty-state markup used in show.html.erb when absent.

Comment on lines +5 to +12
if DawarichSettings.self_hosted?
# Self-hosted: all users get pro plan (already the default 1)
# Explicit update for clarity in case any user has a non-default value
User.update_all(plan: :pro)
else
# Cloud: active/trial users get pro plan (the current plan, renamed)
User.where(status: %i[active trial]).update_all(plan: :pro)
User.where(status: :inactive).update_all(plan: :lite)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Avoid coupling this migration to the runtime User model.

Using User directly here means future enum/default-scope/model changes can break a fresh setup on this historical migration. Prefer a migration-local model or SQL against persisted values instead of application enums.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/migrate/20260301202147_set_plan_for_existing_users.rb` around lines 5 -
12, The migration currently references the runtime User model (User.update_all
and User.where(...).update_all), which couples the migration to future
model/enum changes; change it to use a migration-local model (e.g., define class
MigrationUser < ActiveRecord::Base; self.table_name = "users"; end) or issue raw
SQL updates instead of using User enums: use DawarichSettings.self_hosted? to
branch, then run MigrationUser.update_all(plan: "pro") or execute("UPDATE users
SET plan='pro' WHERE status IN ('active','trial')") and execute("UPDATE users
SET plan='lite' WHERE status='inactive'") so the migration relies only on
persistent DB values.

Comment on lines +34 to +41
context 'when on a self-hosted instance' do
let(:user) { create(:user) } # default plan is pro

it 'allows access regardless of plan' do
get :index, params: { api_key: user.api_key }

expect(response).to have_http_status(:ok)
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This context is still exercising the Pro-user path.

There is no self_hosted? stub here, and the example itself says the default user plan is Pro. That means this test can pass even if the self-hosted bypass is broken, because it never forces the Lite/self-hosted combination that matters.

Suggested test setup
     context 'when on a self-hosted instance' do
-      let(:user) { create(:user) } # default plan is pro
+      let(:user) { create(:user, plan: :lite) }
+
+      before do
+        allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
+      end
 
       it 'allows access regardless of plan' do
         get :index, params: { api_key: user.api_key }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
context 'when on a self-hosted instance' do
let(:user) { create(:user) } # default plan is pro
it 'allows access regardless of plan' do
get :index, params: { api_key: user.api_key }
expect(response).to have_http_status(:ok)
end
context 'when on a self-hosted instance' do
let(:user) { create(:user, plan: :lite) }
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
it 'allows access regardless of plan' do
get :index, params: { api_key: user.api_key }
expect(response).to have_http_status(:ok)
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/controllers/api_controller_spec.rb` around lines 34 - 41, The test is
exercising a Pro user path and never stubs the self-hosted condition; update the
context 'when on a self-hosted instance' to stub the self-hosted flag (e.g.,
make Site.self_hosted? or equivalent return true) and create a Lite user instead
of the default Pro (use create(:user, plan: :lite) or the factory trait your
suite uses) so the example calling get :index, params: { api_key: user.api_key }
actually verifies the self-hosted bypass for Lite users.

Comment on lines +56 to +67
context 'when on a self-hosted instance' do
let!(:user) { create(:user) }

it 'returns full features regardless of plan' do
get api_v1_plan_url(api_key: user.api_key)

expect(response).to have_http_status(:ok)

json = JSON.parse(response.body)
expect(json['features']['heatmap']).to be(true)
expect(json['features']['data_window']).to be_nil
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This self-hosted spec never proves the self-hosted override.

DawarichSettings.self_hosted? is not stubbed here, and the user is not forced onto Lite. A normal cloud path can satisfy these assertions too, so this example can pass without ever exercising the self-hosted bypass.

Suggested test setup
     context 'when on a self-hosted instance' do
-      let!(:user) { create(:user) }
+      let!(:user) { create(:user, plan: :lite) }
+
+      before do
+        allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
+      end
 
       it 'returns full features regardless of plan' do
         get api_v1_plan_url(api_key: user.api_key)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
context 'when on a self-hosted instance' do
let!(:user) { create(:user) }
it 'returns full features regardless of plan' do
get api_v1_plan_url(api_key: user.api_key)
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['features']['heatmap']).to be(true)
expect(json['features']['data_window']).to be_nil
end
context 'when on a self-hosted instance' do
let!(:user) { create(:user, plan: :lite) }
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
it 'returns full features regardless of plan' do
get api_v1_plan_url(api_key: user.api_key)
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['features']['heatmap']).to be(true)
expect(json['features']['data_window']).to be_nil
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/requests/api/v1/plan_spec.rb` around lines 56 - 67, The spec is not
proving the self-hosted override because DawarichSettings.self_hosted? isn't
stubbed and the user isn't placed on Lite; update the test to explicitly stub
DawarichSettings.self_hosted? to return true (so the self-hosted code path runs)
and create or set the test user to the Lite plan (e.g. create(:user, :lite) or
set user.plan = Plan::LITE) before calling api_v1_plan_url so the assertions
verify the self-hosted override behavior.

@Freika Freika merged commit bc1e933 into master Mar 8, 2026
12 of 13 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Mar 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant