content_signals 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/COUNTERS.MD ADDED
@@ -0,0 +1,1522 @@
1
+ # Page Counters Specification
2
+
3
+ ## 🚀 Status: Planning → Gem Creation
4
+
5
+ **This specification will be extracted into an open-source Ruby gem: `content_signals`**
6
+
7
+ - **Gem Name:** `content_signals` ✅ Available on RubyGems & GitHub
8
+ - **Tagline:** "Listen to signals from your content"
9
+ - **Roadmap:** See [GEM_CREATION_ROADMAP.md](GEM_CREATION_ROADMAP.md)
10
+ - **Target:** v0.1.0 release in 2 days (16 hours)
11
+ - **Use Cases:** Stejar CMS (Pages), Eventya (Profiles, Events), any polymorphic model
12
+ - **License:** MIT
13
+
14
+ ---
15
+
16
+ ## Overview
17
+ This document outlines the specifications for implementing content analytics to track page views, demographics, device types, and engagement metrics. Designed to work as a Rails gem with polymorphic trackable models and optional multi-tenant support.
18
+
19
+ ## Counter Types
20
+
21
+ ### 1. Page Views Counter
22
+
23
+ **Purpose:** Track the total number of times a page has been viewed.
24
+
25
+ **Requirements:**
26
+ - Increment counter each time a page is loaded/visited
27
+ - Track unique vs. total views (optional: distinguish between unique visitors and total page loads)
28
+ - Consider tracking views per time period (daily, weekly, monthly)
29
+ - Exclude bot/crawler traffic from counts
30
+ - Exclude admin/editor views from public counts (optional)
31
+ - Track demographics: country, city, region (using IP geolocation)
32
+ - Store language/locale preferences
33
+ - Track device types (desktop, mobile, tablet, hybrid_app)
34
+ - Track browser and OS information
35
+ - **Support hybrid mobile apps**: Recognize and track WebView/Capacitor/Cordova apps
36
+ - **App-specific tracking**: Accept device_id from mobile apps for consistent visitor identification
37
+ - **Platform identification**: Distinguish between web browser and mobile app contexts
38
+
39
+ **Data Storage:**
40
+ - `page_views_count` (integer) - Total view count on pages table
41
+ - `PageView` model for detailed tracking with:
42
+ - `tenant_id` (bigint, optional) - For multi-tenant applications
43
+ - `trackable_type`, `trackable_id` (polymorphic) - The model being tracked (Page, Profile, Event, etc.)
44
+ - `user_id` (optional), `visitor_id`
45
+ - `ip_address`, `user_agent`
46
+ - `country_code` (string, 2 chars - e.g., 'US', 'RO')
47
+ - `country_name` (string - e.g., 'United States', 'Romania')
48
+ - `city` (string)
49
+ - `region` (string - state/province)
50
+ - `latitude`, `longitude` (decimal, optional)
51
+ - `locale` (string - e.g., 'en-US', 'ro-RO')
52
+ - `device_type` (string - desktop, mobile, tablet, hybrid_app)
53
+ - `browser` (string - Chrome, Firefox, Safari, etc.)
54
+ - `os` (string - Windows, macOS, iOS, Android, etc.)
55
+ - `app_platform` (string - capacitor, cordova, react_native, flutter, null for web)
56
+ - `app_version` (string - mobile app version if applicable)
57
+ - `device_id` (string - persistent device identifier from mobile app)
58
+ - `referrer`, `viewed_at`
59
+
60
+ **Display:**
61
+ - Show view count on page header/stats bar
62
+ - Display in page analytics dashboard with demographics breakdown
63
+ - Show top countries/cities in analytics
64
+ - Display interactive map with visitor locations
65
+ - Show device type distribution (pie chart)
66
+ - Show browser/OS statistics
67
+ - Format large numbers (e.g., 1.2K, 1.5M)
68
+ - Export demographics data as CSV/PDF
69
+
70
+ ---
71
+
72
+ ### 2. Followers per Page
73
+
74
+ **Purpose:** Allow users to follow/subscribe to specific pages for updates.
75
+
76
+ **Requirements:**
77
+ - Users can follow/unfollow any public page
78
+ - Track total follower count per page
79
+ - Notify followers when page content is updated (optional)
80
+ - Allow users to view list of pages they follow
81
+ - Allow page owners to view their followers
82
+
83
+ **Data Storage:**
84
+ - `followers_count` (integer) - Cached counter on pages table
85
+ - `PageFollower` model with fields:
86
+ - `page_id` (references pages)
87
+ - `user_id` (references users)
88
+ - `followed_at` (datetime)
89
+ - Unique index on `[page_id, user_id]`
90
+
91
+ **Actions:**
92
+ - `POST /pages/:id/follow` - Follow a page
93
+ - `DELETE /pages/:id/unfollow` - Unfollow a page
94
+ - `GET /pages/:id/followers` - View page followers (permission-based)
95
+ - `GET /users/:id/following_pages` - View pages user follows
96
+
97
+ **Display:**
98
+ - Show follower count on page header
99
+ - Display "Follow" / "Following" button for authenticated users
100
+ - Show follower list in page settings/analytics
101
+
102
+ ---
103
+
104
+ ### 3. Bookmarks Counter
105
+
106
+ **Purpose:** Track how many users have bookmarked/saved the page for later reference.
107
+
108
+ **Requirements:**
109
+ - Users can bookmark/unbookmark any page
110
+ - Track total bookmark count per page
111
+ - Allow users to view their bookmarked pages
112
+ - Bookmarks are private to each user
113
+
114
+ **Data Storage:**
115
+ - `bookmarks_count` (integer) - Cached counter on pages table
116
+ - `PageBookmark` model with fields:
117
+ - `page_id` (references pages)
118
+ - `user_id` (references users)
119
+ - `bookmarked_at` (datetime)
120
+ - `notes` (text, optional) - User's private notes about the bookmark
121
+ - Unique index on `[page_id, user_id]`
122
+
123
+ **Actions:**
124
+ - `POST /pages/:id/bookmark` - Bookmark a page
125
+ - `DELETE /pages/:id/unbookmark` - Remove bookmark
126
+ - `GET /users/:id/bookmarks` - View user's bookmarked pages
127
+
128
+ **Display:**
129
+ - Show bookmark count on page header/stats
130
+ - Display bookmark/unbookmark icon/button for authenticated users
131
+ - Show in user's bookmark collection
132
+
133
+ ---
134
+
135
+ ### 4. Shares Counter
136
+
137
+ **Purpose:** Track how many times a page has been shared across different platforms.
138
+
139
+ **Requirements:**
140
+ - Track shares across multiple platforms (social media, email, copy link)
141
+ - Increment counter when share action is triggered
142
+ - Optionally track which platform was used for sharing
143
+ - Generate shareable links with tracking parameters
144
+
145
+ **Data Storage:**
146
+ - `shares_count` (integer) - Total shares counter on pages table
147
+ - Optional: `PageShare` model for detailed tracking:
148
+ - `page_id` (references pages)
149
+ - `user_id` (references users, nullable for anonymous)
150
+ - `platform` (string) - twitter, facebook, linkedin, email, copy_link, etc.
151
+ - `shared_at` (datetime)
152
+ - `referrer` (string, optional)
153
+
154
+ **Share Platforms:**
155
+ - Twitter/X
156
+ - Facebook
157
+ - LinkedIn
158
+ - Email
159
+ - Copy link to clipboard
160
+ - WhatsApp
161
+ - Other platforms as needed
162
+
163
+ **Actions:**
164
+ - `POST /pages/:id/share` - Record a share event
165
+ - Params: `platform` (required)
166
+ - Generate share URLs with UTM parameters for tracking
167
+
168
+ **Display:**
169
+ - Show total share count on page header
170
+ - Display share buttons with platform icons
171
+ - Show share analytics in page dashboard
172
+ - Format large numbers (e.g., 1.2K shares)
173
+
174
+ ---
175
+
176
+ ## Implementation Considerations
177
+
178
+ ### Multi-Tenant Support
179
+
180
+ **Overview:**
181
+
182
+ The analytics system supports multi-tenant Rails applications where data must be isolated by tenant. This is essential for SaaS applications where multiple organizations share the same database.
183
+
184
+ **Configuration:**
185
+
186
+ ```ruby
187
+ # config/initializers/content_signals.rb
188
+ ContentSignals.configure do |config|
189
+ # Enable multitenancy
190
+ config.multitenancy = true
191
+
192
+ # Method to get current tenant ID (required if multitenancy enabled)
193
+ config.current_tenant_method = :current_tenant_id
194
+
195
+ # Tenant model (optional, for associations)
196
+ config.tenant_model = 'Account' # or 'Organization', 'Company', etc.
197
+ end
198
+
199
+ # In your ApplicationController or base controller
200
+ def current_tenant_id
201
+ Current.account_id # or however you store tenant context
202
+ end
203
+ ```
204
+
205
+ **How it works:**
206
+
207
+ 1. **Automatic tenant scoping** - All analytics queries are automatically scoped to current tenant:
208
+ ```ruby
209
+ # Automatically filtered by current tenant
210
+ @page.analytics.top_countries
211
+ @page.page_views_count
212
+ ```
213
+
214
+ 2. **Tenant isolation** - PageView records include `tenant_id`:
215
+ ```ruby
216
+ # Each view is associated with a tenant
217
+ PageView.where(tenant_id: current_tenant_id)
218
+ ```
219
+
220
+ 3. **Cross-tenant analytics** (admin only):
221
+ ```ruby
222
+ # Bypass tenant scoping (requires admin permissions)
223
+ ContentSignals.unscoped do
224
+ PageView.group(:tenant_id).count
225
+ end
226
+ ```
227
+
228
+ **Multi-tenant patterns supported:**
229
+
230
+ 1. **Shared database, shared schema** (row-level isolation):
231
+ ```ruby
232
+ # Uses tenant_id column on all tables
233
+ config.multitenancy = true
234
+ config.isolation_strategy = :row_level
235
+ ```
236
+
237
+ 2. **Shared database, separate schemas** (Apartment gem):
238
+ ```ruby
239
+ # Works with Apartment gem
240
+ config.multitenancy = true
241
+ config.isolation_strategy = :schema
242
+ # tenant_id still stored for cross-schema queries
243
+ ```
244
+
245
+ 3. **Separate databases per tenant**:
246
+ ```ruby
247
+ # Each tenant has their own database
248
+ config.multitenancy = false # No tenant_id needed
249
+ ```
250
+
251
+ **Example with ActsAsTenant gem:**
252
+
253
+ ```ruby
254
+ # Gemfile
255
+ gem 'acts_as_tenant'
256
+ gem 'content_signals'
257
+
258
+ # app/models/page_view.rb
259
+ class PageView < ApplicationRecord
260
+ acts_as_tenant(:account)
261
+ # content_signals handles the rest
262
+ end
263
+
264
+ # Automatic tenant scoping in controllers
265
+ class PagesController < ApplicationController
266
+ set_current_tenant_through_filter
267
+ before_action :set_current_tenant
268
+
269
+ def set_current_tenant
270
+ set_current_tenant(current_user.account)
271
+ end
272
+ end
273
+ ```
274
+
275
+ **Tenant-aware caching:**
276
+
277
+ ```ruby
278
+ # Redis keys include tenant_id
279
+ "page_view:tenant_123:page_456:visitor_abc:2026-01-06"
280
+
281
+ # Ensures no data leakage between tenants
282
+ ```
283
+
284
+ **Benefits:**
285
+ - ✅ Complete data isolation between tenants
286
+ - ✅ Works with existing multitenancy gems (ActsAsTenant, Apartment)
287
+ - ✅ Optional - can be disabled for single-tenant apps
288
+ - ✅ Prevents accidental cross-tenant data leaks
289
+ - ✅ Supports super-admin cross-tenant analytics
290
+
291
+ ---
292
+
293
+ ### Hybrid Mobile App Support
294
+
295
+ **Identifying Hybrid Apps:**
296
+
297
+ Hybrid apps load your Rails website inside a WebView. To properly track these:
298
+
299
+ 1. **Custom Headers** - Apps should send identifying headers:
300
+ ```javascript
301
+ // In Capacitor/Ionic app
302
+ import { CapacitorHttp } from '@capacitor/core';
303
+
304
+ CapacitorHttp.request({
305
+ url: 'https://yoursite.com/page',
306
+ headers: {
307
+ 'X-App-Platform': 'capacitor',
308
+ 'X-App-Version': '1.2.3',
309
+ 'X-Device-ID': 'unique-device-uuid'
310
+ }
311
+ });
312
+
313
+ // Or inject headers in WebView
314
+ webView.setWebViewClient(new WebViewClient() {
315
+ @Override
316
+ public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
317
+ // Add custom headers to all requests
318
+ }
319
+ });
320
+ ```
321
+
322
+ 2. **URL Parameters** - Alternative approach for initial load:
323
+ ```
324
+ https://yoursite.com/page?app_platform=capacitor&device_id=abc123&app_version=1.2.3
325
+ ```
326
+
327
+ 3. **JavaScript Detection** - Detect WebView in frontend:
328
+ ```javascript
329
+ // Check for Capacitor
330
+ const isCapacitor = window.Capacitor !== undefined;
331
+
332
+ // Check for Cordova
333
+ const isCordova = window.cordova !== undefined;
334
+
335
+ // Check for generic WebView
336
+ const isWebView = /wv|WebView|; wv\)/.test(navigator.userAgent);
337
+
338
+ // Send to analytics endpoint
339
+ if (isCapacitor || isCordova || isWebView) {
340
+ fetch('/api/analytics/identify', {
341
+ method: 'POST',
342
+ headers: { 'Content-Type': 'application/json' },
343
+ body: JSON.stringify({
344
+ app_platform: isCapacitor ? 'capacitor' : isCordova ? 'cordova' : 'webview',
345
+ device_id: localStorage.getItem('device_id') || generateDeviceId(),
346
+ app_version: window.APP_VERSION
347
+ })
348
+ });
349
+ }
350
+ ```
351
+
352
+ **Visitor Identification for Apps:**
353
+
354
+ ```ruby
355
+ # app/services/page_view_tracker.rb
356
+ def identify_visitor
357
+ # 1. Use authenticated user ID if available
358
+ return "user_#{@user.id}" if @user
359
+
360
+ # 2. Use device_id from mobile app (most reliable for apps)
361
+ device_id = @request.headers['X-Device-ID'] || @request.params[:device_id]
362
+ return "device_#{device_id}" if device_id.present?
363
+
364
+ # 3. Use cookie for web browsers
365
+ cookie_id = @request.cookie_jar.signed[:visitor_id]
366
+ return cookie_id if cookie_id.present?
367
+
368
+ # 4. Fallback to IP + User Agent hash
369
+ "anon_#{Digest::SHA256.hexdigest("#{@request.ip}:#{@request.user_agent}")[0..15]}"
370
+ end
371
+
372
+ def detect_app_context
373
+ {
374
+ app_platform: @request.headers['X-App-Platform'] || detect_platform_from_ua,
375
+ app_version: @request.headers['X-App-Version'],
376
+ device_id: @request.headers['X-Device-ID'] || @request.params[:device_id]
377
+ }
378
+ end
379
+
380
+ def detect_platform_from_ua
381
+ ua = @request.user_agent.to_s.downcase
382
+ return 'capacitor' if ua.include?('capacitor')
383
+ return 'cordova' if ua.include?('cordova')
384
+ return 'react_native' if ua.include?('react-native')
385
+ return 'flutter' if ua.include?('flutter')
386
+ return 'webview' if ua.match?(/wv|webview/)
387
+ nil
388
+ end
389
+ ```
390
+
391
+ **Device Type Detection:**
392
+
393
+ ```ruby
394
+ # app/services/device_detector_service.rb
395
+ def self.detect(user_agent, app_platform: nil)
396
+ browser = Browser.new(user_agent)
397
+
398
+ device_type = if app_platform.present?
399
+ 'hybrid_app' # It's from a mobile app
400
+ elsif browser.device.mobile?
401
+ 'mobile'
402
+ elsif browser.device.tablet?
403
+ 'tablet'
404
+ else
405
+ 'desktop'
406
+ end
407
+
408
+ {
409
+ device_type: device_type,
410
+ browser: browser.name,
411
+ browser_version: browser.version,
412
+ os: browser.platform.name,
413
+ os_version: browser.platform.version,
414
+ is_webview: browser.device.mobile? && user_agent.match?(/wv|WebView/i)
415
+ }
416
+ end
417
+ ```
418
+
419
+ **Updated Migration:**
420
+
421
+ ```ruby
422
+ # Add app-specific columns to page_views table
423
+ create_table :page_views do |t|
424
+ # ... existing columns ...
425
+
426
+ # Hybrid app support
427
+ t.string :app_platform, limit: 20 # capacitor, cordova, react_native, flutter, null
428
+ t.string :app_version, limit: 20 # e.g., "1.2.3"
429
+ t.string :device_id # Persistent device identifier from app
430
+
431
+ # ... rest of columns ...
432
+ end
433
+
434
+ add_index :page_views, :app_platform
435
+ add_index :page_views, :device_id
436
+ ```
437
+
438
+ **Benefits:**
439
+ - ✅ Consistent visitor tracking across app sessions
440
+ - ✅ Distinguish app users from web users
441
+ - ✅ Track app version adoption
442
+ - ✅ Analyze app vs web engagement
443
+ - ✅ Debug app-specific issues
444
+
445
+ ---
446
+
447
+ ### Geolocation Services - Detailed Comparison
448
+
449
+ #### Recommended: MaxMind GeoLite2 (Free) or GeoIP2 (Paid)
450
+
451
+ **Why MaxMind?**
452
+ - ✅ **Local database** - No API calls, no rate limits, instant lookups (1-2ms)
453
+ - ✅ **Privacy-friendly** - Data never leaves your server
454
+ - ✅ **Cost-effective** - Free tier with decent accuracy (~95% country, ~80% city)
455
+ - ✅ **Battle-tested** - Used by Netflix, Cloudflare, AWS
456
+ - ✅ **Offline-capable** - Works without internet connection
457
+ - ✅ **GDPR compliant** - No data sharing with third parties
458
+
459
+ **MaxMind Options:**
460
+
461
+ | Feature | GeoLite2 City (Free) | GeoIP2 City (Paid) |
462
+ |---------|---------------------|-------------------|
463
+ | Price | Free | $40/month or $0.008/lookup |
464
+ | Country accuracy | ~95% | ~99.8% |
465
+ | City accuracy | ~80% | ~96% |
466
+ | Updates | Weekly | Daily |
467
+ | IP coverage | ~200M IPs | ~400M IPs |
468
+ | Additional data | Basic | ISP, connection type, domain |
469
+ | Support | Community | Commercial |
470
+
471
+ **Setup Steps:**
472
+
473
+ 1. **Create MaxMind account** (free): https://www.maxmind.com/en/geolite2/signup
474
+ 2. **Generate license key** in account dashboard
475
+ 3. **Download database** (GeoLite2-City.mmdb, ~70MB)
476
+ 4. **Store in:** `db/GeoLite2-City.mmdb` (add to .gitignore!)
477
+ 5. **Set up weekly auto-updates** (via cron job)
478
+
479
+ **Alternatives (if MaxMind doesn't work for you):**
480
+
481
+ | Service | Type | Free Tier | Accuracy | Best For |
482
+ |---------|------|-----------|----------|----------|
483
+ | **IPinfo.io** | API | 50k/month | Excellent | Low-traffic sites |
484
+ | **IP2Location** | Database | LITE version | Good | MaxMind alternative |
485
+ | **ipapi.co** | API | 1k/day | Good | Quick prototyping |
486
+ | **ipgeolocation.io** | API | 30k/month | Good | More generous free tier |
487
+ | **AbstractAPI** | API | 20k/month | Good | Simple API |
488
+
489
+ **When to use APIs instead of local database:**
490
+ - Very low traffic (<1k requests/month)
491
+ - Don't want to manage database updates
492
+ - Need additional data (timezone, currency, threat detection)
493
+ - Prototyping/development
494
+
495
+ **Implementation approach:**
496
+ ```ruby
497
+ # Gemfile
498
+ gem 'maxminddb' # For local GeoIP2 database
499
+ gem 'browser' # For device/browser detection
500
+
501
+ # Download GeoLite2 database:
502
+ # 1. Sign up at https://www.maxmind.com/en/geolite2/signup
503
+ # 2. Generate license key
504
+ # 3. Download GeoLite2-City.mmdb
505
+ # 4. Place in: db/GeoLite2-City.mmdb
506
+
507
+ # Auto-update script (run weekly via cron)
508
+ # lib/tasks/maxmind.rake
509
+ namespace :maxmind do
510
+ desc "Update GeoLite2 database"
511
+ task :update do
512
+ require 'net/http'
513
+ require 'zlib'
514
+ require 'rubygems/package'
515
+
516
+ license_key = ENV['MAXMIND_LICENSE_KEY']
517
+ edition_id = 'GeoLite2-City'
518
+ url = "https://download.maxmind.com/app/geoip_download?edition_id=#{edition_id}&license_key=#{license_key}&suffix=tar.gz"
519
+
520
+ # Download, extract, and move to db/
521
+ # (implementation details omitted for brevity)
522
+ puts "GeoLite2 database updated successfully"
523
+ end
524
+ end
525
+
526
+ # app/services/visitor_location_service.rb
527
+ class VisitorLocationService
528
+ DB_PATH = Rails.root.join('db', 'GeoLite2-City.mmdb')
529
+
530
+ def self.locate(ip_address)
531
+ return nil if ip_address.blank? || local_ip?(ip_address)
532
+
533
+ # Initialize database connection (cached in production)
534
+ db = @db ||= MaxMindDB.new(DB_PATH)
535
+ result = db.lookup(ip_address)
536
+
537
+ return nil unless result.found?
538
+
539
+ {
540
+ country_code: result.country.iso_code,
541
+ country_name: result.country.name,
542
+ city: result.city.name,
543
+ region: result.subdivisions.most_specific&.name,
544
+ latitude: result.location.latitude,
545
+ longitude: result.location.longitude
546
+ }
547
+ rescue MaxMindDB::Error => e
548
+ Rails.logger.error "MaxMind lookup error: #{e.message}"
549
+ nil
550
+ rescue => e
551
+ Rails.logger.error "Geolocation error: #{e.message}"
552
+ nil
553
+ end
554
+
555
+ def self.local_ip?(ip)
556
+ return true if ip.nil?
557
+ ip.start_with?('127.', '192.168.', '10.', '172.', 'fc00:', 'fe80:') ||
558
+ ip == '::1' ||
559
+ ip == 'localhost'
560
+ end
561
+
562
+ # Fallback to API if database not available
563
+ def self.locate_via_api(ip_address)
564
+ return nil if ip_address.blank? || local_ip?(ip_address)
565
+
566
+ # Using IPinfo.io as fallback
567
+ response = HTTP.get("https://ipinfo.io/#{ip_address}/json", params: { token: ENV['IPINFO_TOKEN'] })
568
+ data = JSON.parse(response.body)
569
+
570
+ return nil unless data['country']
571
+
572
+ lat, lng = data['loc']&.split(',')&.map(&:to_f)
573
+
574
+ {
575
+ country_code: data['country'],
576
+ country_name: ISO3166::Country[data['country']]&.name,
577
+ city: data['city'],
578
+ region: data['region'],
579
+ latitude: lat,
580
+ longitude: lng
581
+ }
582
+ rescue => e
583
+ Rails.logger.error "API geolocation error: #{e.message}"
584
+ nil
585
+ end
586
+ end
587
+
588
+ # Alternative: IP2Location implementation
589
+ # gem 'ip2location_ruby'
590
+ class VisitorLocationServiceIP2Location
591
+ DB_PATH = Rails.root.join('db', 'IP2LOCATION-LITE-DB5.BIN')
592
+
593
+ def self.locate(ip_address)
594
+ return nil if ip_address.blank?
595
+
596
+ db = IP2Location::Database.new(DB_PATH, :file_io)
597
+ result = db.get_all(ip_address)
598
+
599
+ return nil if result[:status] != 'OK'
600
+
601
+ {
602
+ country_code: result[:country_short],
603
+ country_name: result[:country_long],
604
+ city: result[:city],
605
+ region: result[:region],
606
+ latitude: result[:latitude],
607
+ longitude: result[:longitude]
608
+ }
609
+ rescue => e
610
+ Rails.logger.error "IP2Location error: #{e.message}"
611
+ nil
612
+ end
613
+ end
614
+
615
+ # app/services/device_detector_service.rb
616
+ class DeviceDetectorService
617
+ def self.detect(user_agent, app_platform: nil)
618
+ browser = Browser.new(user_agent)
619
+
620
+ {
621
+ device_type: detect_device_type(browser, app_platform),
622
+ browser: browser.name,
623
+ browser_version: browser.version,
624
+ os: browser.platform.name,
625
+ os_version: browser.platform.version,
626
+ is_webview: is_webview?(user_agent)
627
+ }
628
+ end
629
+
630
+ def self.detect_device_type(browser, app_platform)
631
+ # If it's from a mobile app, always mark as hybrid_app
632
+ return 'hybrid_app' if app_platform.present?
633
+ return 'mobile' if browser.device.mobile?
634
+ return 'tablet' if browser.device.tablet?
635
+
636
+ 'desktop'
637
+ end
638
+
639
+ def self.is_webview?(user_agent)
640
+ ua = user_agent.to_s.downcase
641
+ ua.match?(/wv|webview|; wv\)/)
642
+ end
643
+ end
644
+ ```
645
+
646
+ ### Counter Cache
647
+ - Use Rails counter_cache for performance
648
+ - Update counters using `increment_counter` / `decrement_counter`
649
+ - Consider using background jobs for non-critical counter updates
650
+
651
+ ### Privacy & Permissions
652
+ - Respect user privacy settings
653
+ - Allow users to opt-out of tracking (where applicable)
654
+ - Implement proper authorization for viewing follower lists
655
+ - Consider GDPR compliance for personal data
656
+
657
+ ### Performance
658
+ - Cache counter values to avoid N+1 queries
659
+ - Use database indexes on foreign keys
660
+ - Consider Redis for real-time counter updates
661
+ - Implement rate limiting on counter actions to prevent abuse
662
+
663
+ ### Analytics Integration
664
+ - Store historical data for trend analysis
665
+ - Generate reports on engagement metrics
666
+ - Export data for external analytics tools
667
+ - Track counter changes over time
668
+
669
+ ### User Interface
670
+ - Use icons to represent each counter type
671
+ - Implement loading states for counter actions
672
+ - Show confirmation messages for user actions
673
+ - Use optimistic UI updates where appropriate
674
+ - Make counters clickable to show more details (where applicable)
675
+
676
+ ---
677
+
678
+ ## Database Migrations
679
+
680
+ ### Add counter columns to pages table
681
+ ```ruby
682
+ add_column :pages, :page_views_count, :integer, default: 0, null: false
683
+ add_column :pages, :followers_count, :integer, default: 0, null: false
684
+ add_column :pages, :bookmarks_count, :integer, default: 0, null: false
685
+ add_column :pages, :shares_count, :integer, default: 0, null: false
686
+
687
+ add_index :pages, :page_views_count
688
+ add_index :pages, :followers_count
689
+ add_index :pages, :bookmarks_count
690
+ add_index :pages, :shares_count
691
+ ```
692
+
693
+ ### Create association tables
694
+ ```ruby
695
+ # PageFollower
696
+ create_table :page_followers do |t|
697
+ t.references :page, null: false, foreign_key: true
698
+ t.references :user, null: false, foreign_key: true
699
+ t.datetime :followed_at, null: false
700
+ t.timestamps
701
+ end
702
+ add_index :page_followers, [:page_id, :user_id], unique: true
703
+
704
+ # PageBookmark
705
+ create_table :page_bookmarks do |t|
706
+ t.references :page, null: false, foreign_key: true
707
+ t.references :user, null: false, foreign_key: true
708
+ t.text :notes
709
+ t.datetime :bookmarked_at, null: false
710
+ t.timestamps
711
+ end
712
+ add_index :page_bookmarks, [:page_id, :user_id], unique: true
713
+
714
+ # PageView - with demographics and multitenancy
715
+ create_table :content_signals_page_views do |t|
716
+ # Multi-tenancy support (optional)
717
+ t.references :tenant, null: true, index: true
718
+
719
+ # Polymorphic trackable (Page, Profile, Event, etc.)
720
+ t.string :trackable_type, null: false
721
+ t.bigint :trackable_id, null: false
722
+
723
+ t.references :user, null: true, foreign_key: true
724
+ t.string :visitor_id, null: false
725
+ t.string :ip_address
726
+ t.string :user_agent
727
+ t.string :referrer
728
+
729
+ # Demographics
730
+ t.string :country_code, limit: 2
731
+ t.string :country_name
732
+ t.string :city
733
+ t.string :region
734
+ t.decimal :latitude, precision: 10, scale: 6
735
+ t.decimal :longitude, precision: 10, scale: 6
736
+ t.string :locale, limit: 10
737
+
738
+ # Device info
739
+ t.string :device_type, limit: 20
740
+ t.string :browser, limit: 50
741
+ t.string :os, limit: 50
742
+
743
+ # Hybrid app support
744
+ t.string :app_platform, limit: 20 # capacitor, cordova, react_native, flutter
745
+ t.string :app_version, limit: 20 # e.g., "1.2.3"
746
+ t.string :device_id # Persistent device identifier from app
747
+
748
+ t.datetime :viewed_at, null: false
749
+ t.timestamps
750
+
751
+ # Indexes for performance
752
+ t.index [:trackable_type, :trackable_id, :viewed_at], name: 'index_page_views_on_trackable_and_date'
753
+ t.index [:trackable_type, :trackable_id, :country_code], name: 'index_page_views_on_trackable_and_country'
754
+ t.index [:trackable_type, :trackable_id, :city], name: 'index_page_views_on_trackable_and_city'
755
+ t.index [:trackable_type, :trackable_id, :device_type], name: 'index_page_views_on_trackable_and_device'
756
+ t.index [:trackable_type, :trackable_id, :app_platform], name: 'index_page_views_on_trackable_and_platform'
757
+ t.index [:tenant_id, :viewed_at], name: 'index_page_views_on_tenant_and_date'
758
+ t.index [:visitor_id, :viewed_at]
759
+ t.index [:device_id]
760
+ end
761
+
762
+ # PageShare (optional, for detailed tracking)
763
+ create_table :page_shares do |t|
764
+ t.references :page, null: false, foreign_key: true
765
+ t.references :user, null: true, foreign_key: true
766
+ t.string :platform, null: false
767
+ t.string :referrer
768
+ t.datetime :shared_at, null: false
769
+ t.timestamps
770
+ end
771
+ add_index :page_shares, :page_id
772
+ add_index :page_shares, :platform
773
+
774
+ # PageViewStats - aggregated demographics
775
+ create_table :page_view_stats do |t|
776
+ t.references :page, null: false, foreign_key: true
777
+ t.integer :period_type, null: false # daily, weekly, monthly
778
+ t.datetime :period_start, null: false
779
+ t.integer :total_views, default: 0
780
+ t.integer :unique_views, default: 0
781
+
782
+ # Top countries (JSON or separate table)
783
+ t.jsonb :country_stats # { "US": 1234, "RO": 567, ... }
784
+ t.jsonb :city_stats # { "New York": 234, "Bucharest": 123, ... }
785
+ t.jsonb :device_stats # { "mobile": 789, "desktop": 456, "tablet": 12 }
786
+
787
+ t.timestamps
788
+
789
+ t.index [:page_id, :period_type, :period_start], unique: true
790
+ end
791
+ ```
792
+
793
+ ---
794
+
795
+ ## API Endpoints Summary
796
+
797
+ ```
798
+ # Page Views (read-only, auto-incremented on page load)
799
+ GET /cms/pages/:id/views
800
+ GET /cms/pages/:id/analytics/demographics
801
+ GET /cms/pages/:id/analytics/countries
802
+ GET /cms/pages/:id/analytics/cities
803
+ GET /cms/pages/:id/analytics/devices
804
+
805
+ # Followers
806
+ POST /cms/pages/:id/follow
807
+ DELETE /cms/pages/:id/unfollow
808
+ GET /cms/pages/:id/followers
809
+ GET /cms/pages/:id/following_status
810
+
811
+ # Bookmarks
812
+ POST /cms/pages/:id/bookmark
813
+ DELETE /cms/pages/:id/unbookmark
814
+ GET /users/bookmarks
815
+
816
+ # Shares
817
+ POST /cms/pages/:id/share
818
+ GET /cms/pages/:id/shares/stats
819
+ ```
820
+
821
+ ---
822
+
823
+ ## Complete Implementation Example
824
+
825
+ ### 1. Enhanced PageViewTracker Service
826
+
827
+ ```ruby
828
+ # app/services/page_view_tracker.rb
829
+ class PageViewTracker
830
+ def self.track(page:, user:, request:)
831
+ new(page, user, request).track
832
+ end
833
+
834
+ def initialize(page, user, request)
835
+ @page = page
836
+ @user = user
837
+ @request = request
838
+ @visitor_id = identify_visitor
839
+ end
840
+
841
+ def track
842
+ # Always increment total views (fast, non-blocking)
843
+ @page.increment!(:page_views_count)
844
+
845
+ # Track unique view if first time today
846
+ track_unique_view if unique_today?
847
+
848
+ # Gather all tracking data
849
+ tracking_data = compile_tracking_data
850
+
851
+ # Store detailed view record asynchronously
852
+ TrackPageViewJob.perform_later(@page.id, @user&.id, tracking_data)
853
+ end
854
+
855
+ private
856
+
857
+ def identify_visitor
858
+ # 1. Authenticated user (most reliable)
859
+ return "user_#{@user.id}" if @user
860
+
861
+ # 2. Device ID from mobile app (persistent across app sessions)
862
+ device_id = @request.headers['X-Device-ID'] || @request.params[:device_id]
863
+ return "device_#{device_id}" if device_id.present?
864
+
865
+ # 3. Browser cookie (works for web, not reliable in WebViews)
866
+ cookie_id = @request.cookie_jar.signed[:visitor_id]
867
+ return cookie_id if cookie_id.present?
868
+
869
+ # 4. Fallback to IP + User Agent hash
870
+ "anon_#{Digest::SHA256.hexdigest("#{@request.ip}:#{@request.user_agent}")[0..15]}"
871
+ end
872
+
873
+ def unique_today?
874
+ key = "page_view:#{@page.id}:#{@visitor_id}:#{Date.current}"
875
+ !Rails.cache.exist?(key)
876
+ end
877
+
878
+ def track_unique_view
879
+ key = "page_view:#{@page.id}:#{@visitor_id}:#{Date.current}"
880
+ Rails.cache.write(key, true, expires_in: 24.hours)
881
+
882
+ Rails.cache.increment("page_unique_views:#{@page.id}:#{Date.current}", 1)
883
+ end
884
+
885
+ def compile_tracking_data
886
+ location_data = VisitorLocationService.locate(@request.ip)
887
+ app_context = detect_app_context
888
+ device_data = DeviceDetectorService.detect(
889
+ @request.user_agent,
890
+ app_platform: app_context[:app_platform]
891
+ )
892
+
893
+ {
894
+ tenant_id: current_tenant_id,
895
+ trackable_type: @page.class.name,
896
+ trackable_id: @page.id,
897
+ visitor_id: @visitor_id,
898
+ ip_address: @request.ip,
899
+ user_agent: @request.user_agent,
900
+ referrer: @request.referrer,
901
+ locale: @request.headers['Accept-Language']&.split(',')&.first || I18n.locale.to_s,
902
+ viewed_at: Time.current,
903
+ app_platform: app_context[:app_platform],
904
+ app_version: app_context[:app_version],
905
+ device_id: app_context[:device_id]
906
+ }.merge(location_data || {}).merge(device_data || {})
907
+ end
908
+
909
+ def current_tenant_id
910
+ return nil unless ContentSignals.configuration.multitenancy?
911
+
912
+ # Call the configured method to get tenant ID
913
+ method_name = ContentSignals.configuration.current_tenant_method
914
+ @request.env['action_controller.instance']&.send(method_name)
915
+ rescue => e
916
+ Rails.logger.error "Failed to get tenant_id: #{e.message}"
917
+ nil
918
+
919
+ def detect_platform_from_ua
920
+ ua = @request.user_agent.to_s.downcase
921
+ return 'capacitor' if ua.include?('capacitor')
922
+ return 'cordova' if ua.include?('cordova')
923
+ return 'react_native' if ua.include?('react-native')
924
+ return 'flutter' if ua.include?('flutter')
925
+ return 'webview' if ua.match?(/wv|webview/)
926
+ nil
927
+ end
928
+ end
929
+ ```
930
+
931
+ ### 2. Background Job for Detailed Tracking
932
+
933
+ ```ruby
934
+ # app/jobs/track_page_view_job.rb
935
+ class TrackPageViewJob < ApplicationJob
936
+ queue_as :default
937
+
938
+ # Retry on database errors but not on validation errors
939
+ retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
940
+ discard_on ActiveRecord::RecordInvalid
941
+
942
+ def perform(trackable_type, trackable_id, user_id, tracking_data)
943
+ ContentSignals::PageView.create!(
944
+ tenant_id: tracking_data[:tenant_id],
945
+ trackable_type: trackable_type,
946
+ trackable_id: trackable_id,
947
+ user_id: user_id,
948
+ visitor_id: tracking_data[:visitor_id],
949
+ ip_address: tracking_data[:ip_address],
950
+ user_agent: tracking_data[:user_agent],
951
+ referrer: tracking_data[:referrer],
952
+ country_code: tracking_data[:country_code],
953
+ country_name: tracking_data[:country_name],
954
+ city: tracking_data[:city],
955
+ region: tracking_data[:region],
956
+ latitude: tracking_data[:latitude],
957
+ longitude: tracking_data[:longitude],
958
+ locale: tracking_data[:locale],
959
+ device_type: tracking_data[:device_type],
960
+ browser: tracking_data[:browser],
961
+ os: tracking_data[:os],
962
+ app_platform: tracking_data[:app_platform],
963
+ app_version: tracking_data[:app_version],
964
+ device_id: tracking_data[:device_id],
965
+ viewed_at: tracking_data[:viewed_at]
966
+ )
967
+ rescue => e
968
+ Rails.logger.error "Failed to track page view: #{e.message}"
969
+ # Optionally send to error tracking service (Sentry, Rollbar, etc.)
970
+ end
971
+ end
972
+ ```
973
+
974
+ ### 3. PageView Model with Scopes
975
+
976
+ ```ruby
977
+ # app/models/content_signals/page_view.rb
978
+ module ContentSignals
979
+ class PageView < ApplicationRecord
980
+ self.table_name = 'content_signals_page_views'
981
+
982
+ # Associations
983
+ belongs_to :trackable, polymorphic: true
984
+ belongs_to :tenant, optional: true
985
+ belongs_to :user, optional: true
986
+
987
+ # Multi-tenant scoping (automatic)
988
+ if ContentSignals.configuration.multitenancy?
989
+ default_scope -> { where(tenant_id: ContentSignals.current_tenant_id) }
990
+
991
+ # Bypass tenant scoping (admin use only)
992
+ scope :unscoped_by_tenant, -> { unscope(where: :tenant_id) }
993
+ end
994
+
995
+ # Time period scopes
996
+ scope :today, -> { where('viewed_at >= ?', Time.current.beginning_of_day) }
997
+ scope :yesterday, -> { where(viewed_at: 1.day.ago.all_day) }
998
+ scope :this_week, -> { where('viewed_at >= ?', Time.current.beginning_of_week) }
999
+ scope :last_week, -> { where(viewed_at: 1.week.ago.beginning_of_week..1.week.ago.end_of_week) }
1000
+ scope :this_month, -> { where('viewed_at >= ?', Time.current.beginning_of_month) }
1001
+ scope :last_30_days, -> { where('viewed_at >= ?', 30.days.ago) }
1002
+
1003
+ # Device scopes
1004
+ scope :mobile, -> { where(device_type: 'mobile') }
1005
+ scope :desktop, -> { where(device_type: 'desktop') }
1006
+ scope :tablet, -> { where(device_type: 'tablet') }
1007
+
1008
+ # Location scopes
1009
+ scope :from_country, ->(country_code) { where(country_code: country_code) }
1010
+ scope :from_city, ->(city) { where(city: city) }
1011
+
1012
+ # Analytics methods
1013
+ def self.unique_count(period = :today)
1014
+ send(period).distinct.count(:visitor_id)
1015
+ end
1016
+
1017
+ def self.top_countries(limit = 10)
1018
+ where.not(country_name: nil)
1019
+ .group(:country_code, :country_name)
1020
+ .order('count_id DESC')
1021
+ .limit(limit)
1022
+ .count(:id)
1023
+ end
1024
+
1025
+ def self.top_cities(limit = 10)
1026
+ where.not(city: nil)
1027
+ .group(:city, :country_name)
1028
+ .order('count_id DESC')
1029
+ .limit(limit)
1030
+ .count(:id)
1031
+ end
1032
+
1033
+ def self.device_breakdown
1034
+ group(:device_type)
1035
+ .count(:id)
1036
+ .transform_keys { |k| k || 'unknown' }
1037
+ end
1038
+
1039
+ def self.browser_breakdown
1040
+ group(:browser)
1041
+ .order('count_id DESC')
1042
+ .count(:id)
1043
+ .transform_keys { |k| k || 'unknown' }
1044
+ end
1045
+
1046
+ def self.hourly_distribution
1047
+ group("EXTRACT(HOUR FROM viewed_at)::integer")
1048
+ .order("EXTRACT(HOUR FROM viewed_at)::integer")
1049
+ .count(:id)
1050
+ end
1051
+
1052
+ # For map visualization - returns array of [lat, lng, count]
1053
+ def self.location_heatmap
1054
+ where.not(latitude: nil, longitude: nil)
1055
+ .group(:latitude, :longitude)
1056
+ .count(:id)
1057
+ .map { |(lat, lng), count| { lat: lat, lng: lng, count: count } }
1058
+ end
1059
+ end
1060
+ ```
1061
+
1062
+ ### 4. Controller Integration
1063
+
1064
+ ```ruby
1065
+ # app/controllers/concerns/trackable_page_views.rb
1066
+ module TrackablePageViews
1067
+ extend ActiveSupport::Concern
1068
+
1069
+ included do
1070
+ after_action :track_page_view, only: [:show]
1071
+ end
1072
+
1073
+ private
1074
+
1075
+ def track_page_view
1076
+ return if should_skip_tracking?
1077
+
1078
+ PageViewTracker.track(
1079
+ page: @page,
1080
+ user: current_user,
1081
+ request: request
1082
+ )
1083
+ end
1084
+
1085
+ def should_skip_tracking?
1086
+ bot_request? ||
1087
+ admin_viewing? ||
1088
+ preview_mode? ||
1089
+ @page.nil?
1090
+ end
1091
+
1092
+ def bot_request?
1093
+ browser = Browser.new(request.user_agent)
1094
+ browser.bot? || browser.search_engine?
1095
+ rescue
1096
+ false
1097
+ end
1098
+
1099
+ def admin_viewing?
1100
+ current_user&.admin? || can_edit_page?
1101
+ end
1102
+
1103
+ def can_edit_page?
1104
+ return false unless current_user && @page
1105
+ current_user.can?(:edit, @page)
1106
+ end
1107
+
1108
+ def preview_mode?
1109
+ params[:preview].present? || session[:preview_mode]
1110
+ end
1111
+ end
1112
+
1113
+ # Include in your pages controller
1114
+ # app/controllers/pages_controller.rb
1115
+ class PagesController < ApplicationController
1116
+ include TrackablePageViews
1117
+
1118
+ def show
1119
+ @page = Page.find(params[:id])
1120
+ # ... rest of your show action
1121
+ end
1122
+ end
1123
+ ```
1124
+
1125
+ ### 5. Analytics Service
1126
+
1127
+ ```ruby
1128
+ # app/services/page_analytics_service.rb
1129
+ class PageAnalyticsService
1130
+ attr_reader :page, :period
1131
+
1132
+ def initialize(page, period: :last_30_days)
1133
+ @page = page
1134
+ @period = period
1135
+ end
1136
+
1137
+ def stats
1138
+ views = page.page_views.send(period)
1139
+
1140
+ {
1141
+ total_views: views.count,
1142
+ unique_views: views.distinct.count(:visitor_id),
1143
+ demographics: {
1144
+ countries: views.top_countries(10),
1145
+ cities: views.top_cities(10),
1146
+ map_data: views.location_heatmap
1147
+ },
1148
+ devices: {
1149
+ types: views.device_breakdown,
1150
+ browsers: views.browser_breakdown.first(10)
1151
+ },
1152
+ timeline: views.group_by_day(:viewed_at, range: period_range).count,
1153
+ hourly_pattern: views.hourly_distribution,
1154
+ top_referrers: top_referrers(views)
1155
+ }
1156
+ end
1157
+
1158
+ def export_csv
1159
+ require 'csv'
1160
+
1161
+ CSV.generate(headers: true) do |csv|
1162
+ csv << [
1163
+ 'Date', 'Country', 'City', 'Device', 'Browser',
1164
+ 'OS', 'Referrer', 'Unique Visitor'
1165
+ ]
1166
+
1167
+ page.page_views.send(period).find_each do |view|
1168
+ csv << [
1169
+ view.viewed_at.to_date,
1170
+ view.country_name,
1171
+ view.city,
1172
+ view.device_type,
1173
+ view.browser,
1174
+ view.os,
1175
+ view.referrer,
1176
+ view.visitor_id
1177
+ ]
1178
+ end
1179
+ end
1180
+ end
1181
+
1182
+ private
1183
+
1184
+ def period_range
1185
+ case period
1186
+ when :today then Date.current..Date.current
1187
+ when :this_week then Date.current.beginning_of_week..Date.current
1188
+ when :this_month then Date.current.beginning_of_month..Date.current
1189
+ when :last_30_days then 30.days.ago.to_date..Date.current
1190
+ else 30.days.ago.to_date..Date.current
1191
+ end
1192
+ end
1193
+
1194
+ def top_referrers(views)
1195
+ views.where.not(referrer: nil)
1196
+ .group(:referrer)
1197
+ .order('count_id DESC')
1198
+ .limit(10)
1199
+ .count(:id)
1200
+ end
1201
+ end
1202
+ ```
1203
+
1204
+ ### 6. Analytics Controller
1205
+
1206
+ ```ruby
1207
+ # app/controllers/cms/page_analytics_controller.rb
1208
+ module Cms
1209
+ class PageAnalyticsController < Cms::BaseController
1210
+ before_action :set_page
1211
+ before_action :authorize_analytics_access
1212
+
1213
+ def show
1214
+ @period = params[:period]&.to_sym || :last_30_days
1215
+ @analytics = PageAnalyticsService.new(@page, period: @period)
1216
+ @stats = @analytics.stats
1217
+ end
1218
+
1219
+ def demographics
1220
+ @period = params[:period]&.to_sym || :last_30_days
1221
+ views = @page.page_views.send(@period)
1222
+
1223
+ render json: {
1224
+ countries: views.top_countries(20),
1225
+ cities: views.top_cities(20),
1226
+ map_data: views.location_heatmap
1227
+ }
1228
+ end
1229
+
1230
+ def devices
1231
+ @period = params[:period]&.to_sym || :last_30_days
1232
+ views = @page.page_views.send(@period)
1233
+
1234
+ render json: {
1235
+ types: views.device_breakdown,
1236
+ browsers: views.browser_breakdown
1237
+ }
1238
+ end
1239
+
1240
+ def export
1241
+ @period = params[:period]&.to_sym || :last_30_days
1242
+ analytics = PageAnalyticsService.new(@page, period: @period)
1243
+
1244
+ send_data analytics.export_csv,
1245
+ filename: "page-#{@page.id}-analytics-#{Date.current}.csv",
1246
+ type: 'text/csv'
1247
+ end
1248
+
1249
+ private
1250
+
1251
+ def set_page
1252
+ @page = Page.find(params[:page_id])
1253
+ end
1254
+
1255
+ def authorize_analytics_access
1256
+ authorize! :read_analytics, @page
1257
+ end
1258
+ end
1259
+ end
1260
+
1261
+ # config/routes.rb
1262
+ namespace :cms do
1263
+ resources :pages do
1264
+ resource :analytics, only: [:show], controller: 'page_analytics' do
1265
+ get :demographics
1266
+ get :devices
1267
+ get :export
1268
+ end
1269
+ end
1270
+ end
1271
+ ```
1272
+
1273
+ ### 7. Daily Aggregation Job
1274
+
1275
+ ```ruby
1276
+ # app/jobs/aggregate_page_views_job.rb
1277
+ class AggregatePageViewsJob < ApplicationJob
1278
+ queue_as :low_priority
1279
+
1280
+ def perform(date = Date.yesterday)
1281
+ Page.find_each do |page|
1282
+ aggregate_for_page(page, date)
1283
+ end
1284
+ end
1285
+
1286
+ private
1287
+
1288
+ def aggregate_for_page(page, date)
1289
+ views = page.page_views.where(viewed_at: date.all_day)
1290
+
1291
+ return if views.empty?
1292
+
1293
+ PageViewStat.create_or_find_by!(
1294
+ page: page,
1295
+ period_type: :daily,
1296
+ period_start: date.beginning_of_day
1297
+ ).update!(
1298
+ total_views: views.count,
1299
+ unique_views: views.distinct.count(:visitor_id),
1300
+ country_stats: aggregate_countries(views),
1301
+ city_stats: aggregate_cities(views),
1302
+ device_stats: aggregate_devices(views)
1303
+ )
1304
+ end
1305
+
1306
+ def aggregate_countries(views)
1307
+ views.where.not(country_code: nil)
1308
+ .group(:country_code, :country_name)
1309
+ .count(:id)
1310
+ .transform_keys { |(code, name)| "#{code}:#{name}" }
1311
+ end
1312
+
1313
+ def aggregate_cities(views)
1314
+ views.where.not(city: nil)
1315
+ .group(:city)
1316
+ .count(:id)
1317
+ end
1318
+
1319
+ def aggregate_devices(views)
1320
+ views.group(:device_type).count(:id)
1321
+ end
1322
+ end
1323
+
1324
+ # Schedule with sidekiq-cron or whenever
1325
+ # config/schedule.yml (for sidekiq-cron)
1326
+ aggregate_page_views:
1327
+ cron: "0 2 * * *" # Run at 2 AM daily
1328
+ class: "AggregatePageViewsJob"
1329
+ ```
1330
+
1331
+ ### 8. View Example (Analytics Dashboard)
1332
+
1333
+ ```erb
1334
+ <!-- app/views/cms/page_analytics/show.html.erb -->
1335
+ <div class="analytics-dashboard">
1336
+ <div class="analytics-header">
1337
+ <h2>Analytics for <%= @page.title %></h2>
1338
+
1339
+ <%= form_with url: cms_page_analytics_path(@page), method: :get, class: "period-selector" do |f| %>
1340
+ <%= f.select :period,
1341
+ options_for_select([
1342
+ ['Today', 'today'],
1343
+ ['This Week', 'this_week'],
1344
+ ['This Month', 'this_month'],
1345
+ ['Last 30 Days', 'last_30_days']
1346
+ ], @period),
1347
+ {},
1348
+ onchange: 'this.form.submit()' %>
1349
+ <% end %>
1350
+
1351
+ <%= link_to "Export CSV", export_cms_page_analytics_path(@page, period: @period),
1352
+ class: "btn btn--outline" %>
1353
+ </div>
1354
+
1355
+ <div class="analytics-stats">
1356
+ <div class="stat-card">
1357
+ <h3>Total Views</h3>
1358
+ <p class="stat-value"><%= number_with_delimiter(@stats[:total_views]) %></p>
1359
+ </div>
1360
+
1361
+ <div class="stat-card">
1362
+ <h3>Unique Visitors</h3>
1363
+ <p class="stat-value"><%= number_with_delimiter(@stats[:unique_views]) %></p>
1364
+ </div>
1365
+ </div>
1366
+
1367
+ <div class="analytics-charts">
1368
+ <!-- Timeline Chart -->
1369
+ <div class="chart-container">
1370
+ <h3>Views Over Time</h3>
1371
+ <canvas id="timeline-chart"
1372
+ data-timeline="<%= @stats[:timeline].to_json %>"></canvas>
1373
+ </div>
1374
+
1375
+ <!-- Device Breakdown -->
1376
+ <div class="chart-container">
1377
+ <h3>Device Types</h3>
1378
+ <canvas id="device-chart"
1379
+ data-devices="<%= @stats[:devices][:types].to_json %>"></canvas>
1380
+ </div>
1381
+
1382
+ <!-- Top Countries -->
1383
+ <div class="chart-container">
1384
+ <h3>Top Countries</h3>
1385
+ <table class="analytics-table">
1386
+ <% @stats[:demographics][:countries].each do |(code, name), count| %>
1387
+ <tr>
1388
+ <td><%= name %> (<%= code %>)</td>
1389
+ <td><%= number_with_delimiter(count) %></td>
1390
+ </tr>
1391
+ <% end %>
1392
+ </table>
1393
+ </div>
1394
+
1395
+ <!-- Location Map -->
1396
+ <div class="chart-container full-width">
1397
+ <h3>Visitor Locations</h3>
1398
+ <div id="visitor-map"
1399
+ data-locations="<%= @stats[:demographics][:map_data].to_json %>"
1400
+ style="height: 500px;"></div>
1401
+ </div>
1402
+ </div>
1403
+ </div>
1404
+
1405
+ <!-- Include Chart.js and Leaflet for visualizations -->
1406
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
1407
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
1408
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
1409
+ ```
1410
+
1411
+ This complete implementation provides:
1412
+ - ✅ Fast, non-blocking view tracking
1413
+ - ✅ Detailed demographics collection
1414
+ - ✅ Background processing for heavy operations
1415
+ - ✅ Comprehensive analytics with scopes
1416
+ - ✅ Daily aggregation for performance
1417
+ - ✅ CSV export functionality
1418
+ - ✅ Ready-to-use dashboard views
1419
+ - ✅ Error handling and retries
1420
+
1421
+ ---
1422
+
1423
+ ## Analytics Queries Examples
1424
+
1425
+ ```ruby
1426
+ # Top countries for a page
1427
+ PageView.where(page: @page)
1428
+ .group(:country_name)
1429
+ .order('count_id DESC')
1430
+ .limit(10)
1431
+ .count(:id)
1432
+
1433
+ # Top cities
1434
+ PageView.where(page: @page)
1435
+ .where.not(city: nil)
1436
+ .group(:city, :country_name)
1437
+ .order('count_id DESC')
1438
+ .limit(10)
1439
+ .count(:id)
1440
+
1441
+ # Device breakdown
1442
+ PageView.where(page: @page)
1443
+ .group(:device_type)
1444
+ .count(:id)
1445
+
1446
+ # Browser stats
1447
+ PageView.where(page: @page)
1448
+ .group(:browser)
1449
+ .order('count_id DESC')
1450
+ .count(:id)
1451
+
1452
+ # Views over time with location
1453
+ PageView.where(page: @page, country_code: 'US')
1454
+ .group_by_day(:viewed_at)
1455
+ .count
1456
+
1457
+ # Aggregated stats (faster for large datasets)
1458
+ PageViewStat.where(page: @page, period_type: :daily)
1459
+ .where('period_start >= ?', 30.days.ago)
1460
+ .pluck(:period_start, :total_views, :country_stats)
1461
+ ```
1462
+
1463
+ ---
1464
+
1465
+ ## 🎯 Next Steps: Gem Creation
1466
+
1467
+ ### Decision Checklist
1468
+ - [ ] Check gem name availability on RubyGems.org
1469
+ - [ ] Decide: Build gem now (2 days) or extract later (3-6 months)?
1470
+ - [ ] Review [GEM_CREATION_ROADMAP.md](GEM_CREATION_ROADMAP.md) for full plan
1471
+ - [ ] Consider: MVP (10 hours) vs Full Release (16 hours)
1472
+
1473
+ ### If Proceeding with Gem:
1474
+ 1. [ ] Check name: `modest_analytics` on RubyGems
1475
+ 2. [ ] Create GitHub repo: `modest_analytics`
1476
+ 3. [ ] Run: `bundle gem modest_analytics --test=rspec --ci=github --mit`
1477
+ 4. [ ] Copy code from this spec to gem structure
1478
+ 5. [ ] Write tests (RSpec)
1479
+ 6. [ ] Write README & documentation
1480
+ 7. [ ] Publish to RubyGems v0.1.0
1481
+ 8. [ ] Integrate into Stejar CMS
1482
+ 9. [ ] Integrate into Eventya
1483
+ 10. [ ] Announce release!
1484
+
1485
+ ### Gem Features (Confirmed)
1486
+ - ✅ Polymorphic trackable (works with any model)
1487
+ - ✅ Multi-tenant support (optional tenant_id scoping)
1488
+ - ✅ Hybrid mobile app support (Capacitor, Cordova, React Native, Flutter)
1489
+ - ✅ IP geolocation (MaxMind GeoLite2)
1490
+ - ✅ Device detection (mobile, desktop, tablet, browser, OS)
1491
+ - ✅ Background job processing (async PageView creation)
1492
+ - ✅ Redis unique tracking (24-hour deduplication)
1493
+ - ✅ Counter caching for performance
1494
+ - ✅ Privacy-focused (local IP database, bot exclusion)
1495
+
1496
+ ### Time Estimate
1497
+ - **MVP:** 10-12 hours (basic tracking + simple queries)
1498
+ - **Full Release:** 16 hours (including comprehensive tests & docs)
1499
+ - **Integration:** 2-4 hours per app (Stejar, Eventya)
1500
+
1501
+ ### Resources
1502
+ - 📋 **Full Roadmap:** [GEM_CREATION_ROADMAP.md](GEM_CREATION_ROADMAP.md)
1503
+ - 📊 **This Spec:** Complete implementation details (you are here)
1504
+ - 🌐 **MaxMind:** Free GeoLite2 database for geolocation
1505
+ - 🔧 **Browser Gem:** Device/browser detection
1506
+ - ⚡ **Redis:** Optional unique visitor deduplication
1507
+
1508
+ ---
1509
+
1510
+ **Ready to ship! 🚢**
1511
+
1512
+ ---
1513
+
1514
+ ## Testing Requirements
1515
+
1516
+ - Test counter increments/decrements
1517
+ - Test counter cache consistency
1518
+ - Test uniqueness constraints
1519
+ - Test permissions and authorization
1520
+ - Test rate limiting
1521
+ - Test concurrent updates
1522
+ - Test counter reset/recalculation