quarterdeck 0.1.0

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +93 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/stylesheets/quarterdeck/application.css +15 -0
  6. data/app/controllers/quarterdeck/application_controller.rb +73 -0
  7. data/app/controllers/quarterdeck/campaigns_controller.rb +52 -0
  8. data/app/controllers/quarterdeck/events_controller.rb +38 -0
  9. data/app/controllers/quarterdeck/geographics_controller.rb +41 -0
  10. data/app/controllers/quarterdeck/live_controller.rb +32 -0
  11. data/app/controllers/quarterdeck/overview_controller.rb +89 -0
  12. data/app/controllers/quarterdeck/visits_controller.rb +46 -0
  13. data/app/helpers/quarterdeck/application_helper.rb +4 -0
  14. data/app/javascript/quarterdeck/application.js +9 -0
  15. data/app/javascript/quarterdeck/controllers/chart_controller.js +99 -0
  16. data/app/javascript/quarterdeck/controllers/date_range_controller.js +18 -0
  17. data/app/javascript/quarterdeck/controllers/live_controller.js +59 -0
  18. data/app/jobs/quarterdeck/application_job.rb +4 -0
  19. data/app/mailers/quarterdeck/application_mailer.rb +6 -0
  20. data/app/models/quarterdeck/application_record.rb +5 -0
  21. data/app/views/layouts/quarterdeck/application.html.erb +36 -0
  22. data/app/views/quarterdeck/campaigns/show.html.erb +35 -0
  23. data/app/views/quarterdeck/events/index.html.erb +89 -0
  24. data/app/views/quarterdeck/geographics/show.html.erb +32 -0
  25. data/app/views/quarterdeck/live/show.html.erb +75 -0
  26. data/app/views/quarterdeck/overview/show.html.erb +38 -0
  27. data/app/views/quarterdeck/shared/_data_table.html.erb +29 -0
  28. data/app/views/quarterdeck/shared/_nav.html.erb +36 -0
  29. data/app/views/quarterdeck/shared/_period_tabs.html.erb +31 -0
  30. data/app/views/quarterdeck/shared/_stat_card.html.erb +22 -0
  31. data/app/views/quarterdeck/visits/index.html.erb +115 -0
  32. data/app/views/quarterdeck/visits/show.html.erb +72 -0
  33. data/config/importmap.rb +5 -0
  34. data/config/routes.rb +8 -0
  35. data/lib/generators/quarterdeck/install_generator.rb +39 -0
  36. data/lib/quarterdeck/engine.rb +19 -0
  37. data/lib/quarterdeck/version.rb +3 -0
  38. data/lib/quarterdeck.rb +19 -0
  39. data/lib/tasks/quarterdeck_tasks.rake +4 -0
  40. metadata +139 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 84e4ec81af825568dd7458fba3c8a209ebce868478a15db046e0ebc17bcbd1e7
4
+ data.tar.gz: f1f269be8224650baa0aea62679cdf0bddea64c43536e31c7d8c46acb2bd4ff6
5
+ SHA512:
6
+ metadata.gz: edfde0d7b87eefa9eb730baac6f3bf1cf7d781ca07e89414316f5606afac90bbc7aaffc18f2b22501e1b0b5f38f878797fe74ee8d28f1edcd2403c256ffcf191
7
+ data.tar.gz: 845bae97704d3c2aafb59bc1d377647207efa5cf33880583bd788778bb289779b6d185afff3c9f793fecef32217deb7f90ddd4734d71d94a5ad8a0b20cee651b
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright leopolicastro
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # Quarterdeck
2
+
3
+ A beautiful analytics dashboard engine for Rails apps already using [ahoy_matey](https://github.com/ankane/ahoy) for tracking. Quarterdeck reads your existing Ahoy data and presents it in a modern dashboard with charts, tables, and filters.
4
+
5
+ No database migrations needed — Quarterdeck reads directly from your `ahoy_visits` and `ahoy_events` tables.
6
+
7
+ ## Features
8
+
9
+ - **Overview** — Total visits, unique visitors, events, daily chart, top pages, referrers, browsers, devices, OS, countries, campaigns
10
+ - **Visits** — Searchable/filterable visit log with pagination and detail views
11
+ - **Events** — Event breakdown with daily chart, filtering by event name
12
+ - **Campaigns** — UTM breakdowns (source, medium, campaign, term, content) with charts
13
+ - **Geographic** — Country, region, and city breakdowns with charts
14
+ - **Live** — Real-time active visitors, current pages, and live event stream
15
+ - **Trend comparisons** — Stat cards show % change vs previous period
16
+ - **Custom date ranges** — Pick arbitrary start/end dates alongside preset periods
17
+ - **CSV export** — Download data from any page
18
+
19
+ Built with Tailwind CSS, Chart.js, and Stimulus.
20
+
21
+ ## Requirements
22
+
23
+ Your Rails app must have the following set up before installing Quarterdeck:
24
+
25
+ - **Rails 8.1+**
26
+ - **[ahoy_matey](https://github.com/ankane/ahoy) 4+** — already installed and tracking visits/events
27
+ - **[importmap-rails](https://github.com/rails/importmap-rails)** — for JavaScript module loading
28
+ - **[stimulus-rails](https://github.com/hotwired/stimulus-rails)** — for interactive UI components
29
+
30
+ ## Installation
31
+
32
+ Add to your Gemfile:
33
+
34
+ ```ruby
35
+ gem "quarterdeck"
36
+ ```
37
+
38
+ Run the install generator:
39
+
40
+ ```bash
41
+ bundle install
42
+ rails generate quarterdeck:install
43
+ ```
44
+
45
+ This will:
46
+ 1. Create `config/initializers/quarterdeck.rb`
47
+ 2. Mount the engine at `/analytics` in your routes
48
+
49
+ Or set up manually:
50
+
51
+ ```ruby
52
+ # config/routes.rb
53
+ mount Quarterdeck::Engine => "/analytics"
54
+ ```
55
+
56
+ ## Configuration
57
+
58
+ ```ruby
59
+ # config/initializers/quarterdeck.rb
60
+
61
+ # Require authentication
62
+ Quarterdeck.authenticate_with do
63
+ redirect_to main_app.root_path unless current_user&.admin?
64
+ end
65
+ ```
66
+
67
+ ## Pages
68
+
69
+ | Path | Description |
70
+ |------|-------------|
71
+ | `/analytics` | Overview dashboard |
72
+ | `/analytics/visits` | Visit log with filters |
73
+ | `/analytics/visits/:id` | Visit detail with events |
74
+ | `/analytics/events` | Event breakdown |
75
+ | `/analytics/campaigns` | UTM campaign analysis |
76
+ | `/analytics/geographic` | Geographic breakdown |
77
+ | `/analytics/live` | Real-time active visitors |
78
+
79
+ All pages support period filtering: Today, 7 days, 30 days, 90 days, or a custom date range.
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ bundle install
85
+ bundle exec rspec
86
+ ```
87
+
88
+ ## License
89
+
90
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
91
+
92
+
93
+ [![Certified Shovelware](https://justin.searls.co/img/shovelware.svg)](https://justin.searls.co/shovelware/)
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,73 @@
1
+ require "csv"
2
+
3
+ module Quarterdeck
4
+ class ApplicationController < ActionController::Base
5
+ include Pagy::Method
6
+
7
+ before_action :authenticate_quarterdeck!
8
+
9
+ helper_method :period, :period_range, :previous_period_range
10
+
11
+ private
12
+
13
+ def authenticate_quarterdeck!
14
+ if Quarterdeck.authentication_block
15
+ instance_exec(&Quarterdeck.authentication_block)
16
+ end
17
+ end
18
+
19
+ def period
20
+ @period ||= params[:period].presence || "30d"
21
+ end
22
+
23
+ def period_range
24
+ @period_range ||= case period
25
+ when "today"
26
+ Time.current.beginning_of_day..Time.current
27
+ when "7d"
28
+ 7.days.ago.beginning_of_day..Time.current
29
+ when "30d"
30
+ 30.days.ago.beginning_of_day..Time.current
31
+ when "90d"
32
+ 90.days.ago.beginning_of_day..Time.current
33
+ when "custom"
34
+ start_date = Date.parse(params[:start_date]).beginning_of_day
35
+ end_date = Date.parse(params[:end_date]).end_of_day
36
+ start_date..end_date
37
+ else
38
+ 30.days.ago.beginning_of_day..Time.current
39
+ end
40
+ end
41
+
42
+ def previous_period_range
43
+ @previous_period_range ||= begin
44
+ duration = period_range.last - period_range.first
45
+ (period_range.first - duration)..period_range.first
46
+ end
47
+ end
48
+
49
+ def visits_in_period
50
+ @visits_in_period ||= Ahoy::Visit.where(started_at: period_range)
51
+ end
52
+
53
+ def events_in_period
54
+ @events_in_period ||= Ahoy::Event.where(time: period_range)
55
+ end
56
+
57
+ def previous_visits
58
+ Ahoy::Visit.where(started_at: previous_period_range)
59
+ end
60
+
61
+ def previous_events
62
+ Ahoy::Event.where(time: previous_period_range)
63
+ end
64
+
65
+ def send_csv(filename, headers, rows)
66
+ csv_data = CSV.generate(headers: true) do |csv|
67
+ csv << headers
68
+ rows.each { |row| csv << row }
69
+ end
70
+ send_data csv_data, filename: "#{filename}-#{Date.current}.csv", type: "text/csv"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,52 @@
1
+ module Quarterdeck
2
+ class CampaignsController < ApplicationController
3
+ def show
4
+ campaign_visits = visits_in_period.where.not(utm_source: [ nil, "" ])
5
+
6
+ @total_campaign_visits = campaign_visits.count
7
+
8
+ @sources = campaign_visits
9
+ .group(:utm_source)
10
+ .order(Arel.sql("count(*) DESC"))
11
+ .count
12
+
13
+ @mediums = campaign_visits
14
+ .where.not(utm_medium: [ nil, "" ])
15
+ .group(:utm_medium)
16
+ .order(Arel.sql("count(*) DESC"))
17
+ .count
18
+
19
+ @campaigns = campaign_visits
20
+ .where.not(utm_campaign: [ nil, "" ])
21
+ .group(:utm_campaign)
22
+ .order(Arel.sql("count(*) DESC"))
23
+ .count
24
+
25
+ @terms = campaign_visits
26
+ .where.not(utm_term: [ nil, "" ])
27
+ .group(:utm_term)
28
+ .order(Arel.sql("count(*) DESC"))
29
+ .count
30
+
31
+ @contents = campaign_visits
32
+ .where.not(utm_content: [ nil, "" ])
33
+ .group(:utm_content)
34
+ .order(Arel.sql("count(*) DESC"))
35
+ .count
36
+
37
+ respond_to do |format|
38
+ format.html
39
+ format.csv do
40
+ rows = []
41
+ rows << ["--- Sources ---", ""]
42
+ @sources.each { |s, c| rows << [s, c] }
43
+ rows << ["--- Mediums ---", ""]
44
+ @mediums.each { |m, c| rows << [m, c] }
45
+ rows << ["--- Campaigns ---", ""]
46
+ @campaigns.each { |camp, c| rows << [camp, c] }
47
+ send_csv("campaigns", ["Item", "Visits"], rows)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,38 @@
1
+ module Quarterdeck
2
+ class EventsController < ApplicationController
3
+ def index
4
+ scope = events_in_period.order(time: :desc)
5
+
6
+ if params[:name].present?
7
+ scope = scope.where(name: params[:name])
8
+ end
9
+
10
+ @total_events = scope.count
11
+ @unique_events = scope.distinct.count(:name)
12
+
13
+ prev_scope = previous_events
14
+ prev_scope = prev_scope.where(name: params[:name]) if params[:name].present?
15
+ @prev_total_events = prev_scope.count
16
+ @prev_unique_events = prev_scope.distinct.count(:name)
17
+
18
+ @daily_events = scope
19
+ .group_by_day(:time)
20
+ .count
21
+
22
+ @event_names = events_in_period
23
+ .group(:name)
24
+ .order(Arel.sql("count(*) DESC"))
25
+ .count
26
+
27
+ respond_to do |format|
28
+ format.html do
29
+ @pagy, @events = pagy(:offset, scope, limit: 25)
30
+ end
31
+ format.csv do
32
+ rows = @event_names.map { |name, count| [name, count] }
33
+ send_csv("events", ["Event Name", "Count"], rows)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ module Quarterdeck
2
+ class GeographicsController < ApplicationController
3
+ def show
4
+ geo_visits = visits_in_period
5
+
6
+ @countries = geo_visits
7
+ .where.not(country: [ nil, "" ])
8
+ .group(:country)
9
+ .order(Arel.sql("count(*) DESC"))
10
+ .count
11
+
12
+ @regions = geo_visits
13
+ .where.not(region: [ nil, "" ])
14
+ .group(:region)
15
+ .order(Arel.sql("count(*) DESC"))
16
+ .limit(25)
17
+ .count
18
+
19
+ @cities = geo_visits
20
+ .where.not(city: [ nil, "" ])
21
+ .group(:city)
22
+ .order(Arel.sql("count(*) DESC"))
23
+ .limit(25)
24
+ .count
25
+
26
+ respond_to do |format|
27
+ format.html
28
+ format.csv do
29
+ rows = []
30
+ rows << ["--- Countries ---", ""]
31
+ @countries.each { |c, count| rows << [c, count] }
32
+ rows << ["--- Regions ---", ""]
33
+ @regions.each { |r, count| rows << [r, count] }
34
+ rows << ["--- Cities ---", ""]
35
+ @cities.each { |c, count| rows << [c, count] }
36
+ send_csv("geographic", ["Location", "Visits"], rows)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,32 @@
1
+ module Quarterdeck
2
+ class LiveController < ApplicationController
3
+ def show
4
+ @active_visitors = Ahoy::Visit.where(started_at: 5.minutes.ago..Time.current).distinct.count(:visitor_token)
5
+ @current_pages = Ahoy::Event
6
+ .where(name: "$view", time: 5.minutes.ago..Time.current)
7
+ .group("properties->>'url'")
8
+ .order(Arel.sql("count(*) DESC"))
9
+ .limit(10)
10
+ .count
11
+ @recent_events = Ahoy::Event.order(time: :desc).limit(20).includes(:visit)
12
+
13
+ respond_to do |format|
14
+ format.html
15
+ format.json do
16
+ render json: {
17
+ active_visitors: @active_visitors,
18
+ current_pages: @current_pages.map { |url, count| { url: url, count: count } },
19
+ recent_events: @recent_events.map { |e|
20
+ {
21
+ time: e.time.strftime("%H:%M:%S"),
22
+ name: e.name,
23
+ properties: e.properties,
24
+ visitor: e.visit&.visitor_token&.first(8)
25
+ }
26
+ }
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,89 @@
1
+ module Quarterdeck
2
+ class OverviewController < ApplicationController
3
+ def show
4
+ @total_visits = visits_in_period.count
5
+ @unique_visitors = visits_in_period.distinct.count(:visitor_token)
6
+ @total_events = events_in_period.count
7
+
8
+ @prev_total_visits = previous_visits.count
9
+ @prev_unique_visitors = previous_visits.distinct.count(:visitor_token)
10
+ @prev_total_events = previous_events.count
11
+
12
+ @daily_visits = visits_in_period
13
+ .group_by_day(:started_at)
14
+ .count
15
+
16
+ @top_pages = events_in_period
17
+ .where(name: "$view")
18
+ .group("properties->>'url'")
19
+ .order(Arel.sql("count(*) DESC"))
20
+ .limit(10)
21
+ .count
22
+
23
+ @top_referrers = visits_in_period
24
+ .where.not(referring_domain: [ nil, "" ])
25
+ .group(:referring_domain)
26
+ .order(Arel.sql("count(*) DESC"))
27
+ .limit(10)
28
+ .count
29
+
30
+ @browsers = visits_in_period
31
+ .where.not(browser: [ nil, "" ])
32
+ .group(:browser)
33
+ .order(Arel.sql("count(*) DESC"))
34
+ .limit(10)
35
+ .count
36
+
37
+ @devices = visits_in_period
38
+ .where.not(device_type: [ nil, "" ])
39
+ .group(:device_type)
40
+ .order(Arel.sql("count(*) DESC"))
41
+ .limit(10)
42
+ .count
43
+
44
+ @os = visits_in_period
45
+ .where.not(os: [ nil, "" ])
46
+ .group(:os)
47
+ .order(Arel.sql("count(*) DESC"))
48
+ .limit(10)
49
+ .count
50
+
51
+ @countries = visits_in_period
52
+ .where.not(country: [ nil, "" ])
53
+ .group(:country)
54
+ .order(Arel.sql("count(*) DESC"))
55
+ .limit(10)
56
+ .count
57
+
58
+ @top_events = events_in_period
59
+ .where.not(name: "$view")
60
+ .group(:name)
61
+ .order(Arel.sql("count(*) DESC"))
62
+ .limit(10)
63
+ .count
64
+
65
+ @campaigns = visits_in_period
66
+ .where.not(utm_source: [ nil, "" ])
67
+ .group(:utm_source)
68
+ .order(Arel.sql("count(*) DESC"))
69
+ .limit(10)
70
+ .count
71
+
72
+ respond_to do |format|
73
+ format.html
74
+ format.csv do
75
+ rows = []
76
+ rows << ["--- Top Pages ---", ""]
77
+ @top_pages.each { |page, count| rows << [page, count] }
78
+ rows << ["--- Top Referrers ---", ""]
79
+ @top_referrers.each { |ref, count| rows << [ref, count] }
80
+ rows << ["--- Browsers ---", ""]
81
+ @browsers.each { |b, count| rows << [b, count] }
82
+ rows << ["--- Countries ---", ""]
83
+ @countries.each { |c, count| rows << [c, count] }
84
+ send_csv("overview", ["Item", "Count"], rows)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,46 @@
1
+ module Quarterdeck
2
+ class VisitsController < ApplicationController
3
+ def index
4
+ scope = visits_in_period.order(started_at: :desc)
5
+
6
+ if params[:search].present?
7
+ scope = scope.where(
8
+ "landing_page LIKE :q OR referring_domain LIKE :q OR ip LIKE :q",
9
+ q: "%#{params[:search]}%"
10
+ )
11
+ end
12
+
13
+ scope = scope.where(browser: params[:browser]) if params[:browser].present?
14
+ scope = scope.where(device_type: params[:device_type]) if params[:device_type].present?
15
+ scope = scope.where(os: params[:os]) if params[:os].present?
16
+ scope = scope.where(country: params[:country]) if params[:country].present?
17
+
18
+ if params[:utm_only] == "1"
19
+ scope = scope.where.not(utm_source: [ nil, "" ])
20
+ end
21
+
22
+ respond_to do |format|
23
+ format.html do
24
+ @pagy, @visits = pagy(:offset, scope, limit: 25)
25
+ @browsers = visits_in_period.where.not(browser: [ nil, "" ]).distinct.pluck(:browser).sort
26
+ @devices = visits_in_period.where.not(device_type: [ nil, "" ]).distinct.pluck(:device_type).sort
27
+ @operating_systems = visits_in_period.where.not(os: [ nil, "" ]).distinct.pluck(:os).sort
28
+ @countries_list = visits_in_period.where.not(country: [ nil, "" ]).distinct.pluck(:country).sort
29
+ end
30
+ format.csv do
31
+ visits = scope.limit(10_000)
32
+ headers = ["Started At", "Landing Page", "Referrer", "Browser", "Device", "OS", "Country", "City", "IP", "UTM Source", "UTM Medium", "UTM Campaign"]
33
+ rows = visits.map do |v|
34
+ [v.started_at, v.landing_page, v.referring_domain, v.browser, v.device_type, v.os, v.country, v.city, v.ip, v.utm_source, v.utm_medium, v.utm_campaign]
35
+ end
36
+ send_csv("visits", headers, rows)
37
+ end
38
+ end
39
+ end
40
+
41
+ def show
42
+ @visit = Ahoy::Visit.find(params[:id])
43
+ @events = @visit.events.order(time: :desc)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,4 @@
1
+ module Quarterdeck
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import ChartController from "quarterdeck/controllers/chart_controller"
3
+ import DateRangeController from "quarterdeck/controllers/date_range_controller"
4
+ import LiveController from "quarterdeck/controllers/live_controller"
5
+
6
+ const application = Application.start()
7
+ application.register("chart", ChartController)
8
+ application.register("date-range", DateRangeController)
9
+ application.register("live", LiveController)
@@ -0,0 +1,99 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import "chart.js/auto"
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ type: { type: String, default: "line" },
7
+ labels: Array,
8
+ data: Array,
9
+ label: { type: String, default: "" },
10
+ colors: Array
11
+ }
12
+
13
+ connect() {
14
+ this.buildChart()
15
+ }
16
+
17
+ disconnect() {
18
+ if (this.chart) {
19
+ this.chart.destroy()
20
+ }
21
+ }
22
+
23
+ buildChart() {
24
+ const ctx = this.element.getContext("2d")
25
+ const chartType = this.typeValue
26
+
27
+ const defaultColors = [
28
+ "#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe", "#e0e7ff",
29
+ "#4f46e5", "#4338ca", "#3730a3", "#312e81", "#1e1b4b"
30
+ ]
31
+ const colors = this.hasColorsValue ? this.colorsValue : defaultColors
32
+
33
+ const config = {
34
+ type: chartType,
35
+ data: this.chartData(chartType, colors),
36
+ options: this.chartOptions(chartType)
37
+ }
38
+
39
+ const ChartJS = window.Chart
40
+ this.chart = new ChartJS(ctx, config)
41
+ }
42
+
43
+ chartData(type, colors) {
44
+ if (type === "doughnut" || type === "pie") {
45
+ return {
46
+ labels: this.labelsValue,
47
+ datasets: [{
48
+ data: this.dataValue,
49
+ backgroundColor: colors.slice(0, this.dataValue.length),
50
+ borderWidth: 0
51
+ }]
52
+ }
53
+ }
54
+
55
+ return {
56
+ labels: this.labelsValue,
57
+ datasets: [{
58
+ label: this.labelValue,
59
+ data: this.dataValue,
60
+ borderColor: "#6366f1",
61
+ backgroundColor: type === "bar" ? "#6366f1" : "rgba(99, 102, 241, 0.1)",
62
+ fill: type === "line",
63
+ tension: 0.3,
64
+ borderWidth: 2,
65
+ pointRadius: 2,
66
+ pointHoverRadius: 4
67
+ }]
68
+ }
69
+ }
70
+
71
+ chartOptions(type) {
72
+ const base = {
73
+ responsive: true,
74
+ maintainAspectRatio: false,
75
+ plugins: {
76
+ legend: {
77
+ display: type === "doughnut" || type === "pie",
78
+ position: "bottom"
79
+ }
80
+ }
81
+ }
82
+
83
+ if (type === "line" || type === "bar") {
84
+ base.scales = {
85
+ x: {
86
+ grid: { display: false },
87
+ ticks: { font: { size: 11 } }
88
+ },
89
+ y: {
90
+ beginAtZero: true,
91
+ grid: { color: "rgba(0,0,0,0.05)" },
92
+ ticks: { font: { size: 11 } }
93
+ }
94
+ }
95
+ }
96
+
97
+ return base
98
+ }
99
+ }
@@ -0,0 +1,18 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["startDate", "endDate"]
5
+
6
+ submit() {
7
+ const startDate = this.startDateTarget.value
8
+ const endDate = this.endDateTarget.value
9
+
10
+ if (startDate && endDate) {
11
+ const url = new URL(window.location.href)
12
+ url.searchParams.set("period", "custom")
13
+ url.searchParams.set("start_date", startDate)
14
+ url.searchParams.set("end_date", endDate)
15
+ window.location.href = url.toString()
16
+ }
17
+ }
18
+ }