analytic 0.2.0 → 0.5.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.
@@ -0,0 +1,148 @@
1
+ import { useMemo, type FC } from "react";
2
+
3
+ import { Group } from "@visx/group";
4
+ import { Bar } from "@visx/shape";
5
+ import { scaleBand, scaleLinear } from "@visx/scale";
6
+ import { AxisBottom, AxisLeft } from "@visx/axis";
7
+ import { GridColumns, GridRows } from "@visx/grid";
8
+ import { ParentSize } from "@visx/responsive";
9
+
10
+ const BAR_BG = "#6366f1"; // indigo-500
11
+ const CHART_BG = "#f8fafc"; // slate-50
12
+ const GRID_COLOR = "#e2e8f0"; // slate-200
13
+ const AXIS_TICK_COLOR = GRID_COLOR;
14
+ const AXIS_LINE_COLOR = GRID_COLOR;
15
+
16
+ const CHART_ML = 60; // px
17
+ const CHART_MB = 60; // px
18
+ const CHART_MT = 40; // px
19
+ const CHART_MR = 40; // px
20
+
21
+ const TICK_LABEL_PROPS = {
22
+ fill: "#718096", // slate-600
23
+ fontSize: 10,
24
+ fontFamily: `"ui-sans-serif", "system-ui", "sans-serif"`,
25
+ } as const;
26
+
27
+ type Entry = {
28
+ label: string;
29
+ value: number;
30
+ };
31
+
32
+ const ENTRIES: Entry[] = [
33
+ { label: "Jan, 2024", value: 404 },
34
+ { label: "Feb, 2024", value: 303 },
35
+ { label: "Mar, 2024", value: 250 },
36
+ { label: "Apr, 2024", value: 304 },
37
+ { label: "May, 2024", value: 404 },
38
+ { label: "Jun, 2024", value: 604 },
39
+ ];
40
+
41
+ const Bars: FC<{
42
+ width: number;
43
+ height: number;
44
+ entries: Entry[];
45
+ }> = ({ width, height, entries }) => {
46
+ const xScale = useMemo(
47
+ () =>
48
+ scaleBand<string>({
49
+ range: [0, width],
50
+ round: true,
51
+ domain: entries.map(({ label }) => label),
52
+ padding: 0.4,
53
+ }),
54
+ [width]
55
+ );
56
+ const yScale = useMemo(
57
+ () =>
58
+ scaleLinear<number>({
59
+ range: [height, 0],
60
+ round: true,
61
+ domain: [0, Math.max(...entries.map(({ value }) => value))],
62
+ }),
63
+ [height]
64
+ );
65
+
66
+ return (
67
+ <>
68
+ <GridRows
69
+ scale={yScale}
70
+ width={width}
71
+ height={height}
72
+ stroke={GRID_COLOR}
73
+ />
74
+
75
+ <GridColumns
76
+ scale={xScale}
77
+ width={width}
78
+ height={height}
79
+ stroke={GRID_COLOR}
80
+ />
81
+
82
+ {entries.map((entry, index) => {
83
+ const barWidth = xScale.bandwidth();
84
+ const barHeight = height - yScale(entry.value);
85
+ const barX = xScale(entry.label);
86
+ const barY = height - barHeight;
87
+
88
+ return (
89
+ <Bar
90
+ key={index}
91
+ x={barX}
92
+ y={barY}
93
+ rx={8}
94
+ ry={8}
95
+ width={barWidth}
96
+ height={barHeight}
97
+ fill={BAR_BG}
98
+ />
99
+ );
100
+ })}
101
+
102
+ <AxisBottom
103
+ top={height}
104
+ scale={xScale}
105
+ stroke={AXIS_LINE_COLOR}
106
+ tickStroke={AXIS_TICK_COLOR}
107
+ tickLabelProps={TICK_LABEL_PROPS}
108
+ />
109
+
110
+ <AxisLeft
111
+ left={0}
112
+ scale={yScale}
113
+ stroke={AXIS_LINE_COLOR}
114
+ tickStroke={AXIS_TICK_COLOR}
115
+ tickLabelProps={TICK_LABEL_PROPS}
116
+ />
117
+ </>
118
+ );
119
+ };
120
+
121
+ export const Chart: FC<{
122
+ height?: number;
123
+ entries?: Entry[];
124
+ }> = ({ height = 300, entries = ENTRIES }) => (
125
+ <ParentSize>
126
+ {({ width }) => (
127
+ <svg width={width} height={height}>
128
+ <rect
129
+ x={0}
130
+ y={0}
131
+ rx={4}
132
+ ry={4}
133
+ width={width}
134
+ height={height}
135
+ fill={CHART_BG}
136
+ />
137
+
138
+ <Group top={CHART_MT} left={CHART_ML}>
139
+ <Bars
140
+ width={width - CHART_MR - CHART_ML}
141
+ height={height - CHART_MT - CHART_MB}
142
+ entries={entries}
143
+ />
144
+ </Group>
145
+ </svg>
146
+ )}
147
+ </ParentSize>
148
+ );
@@ -0,0 +1,21 @@
1
+ import { type FC } from "react";
2
+
3
+ import { Chart } from "./chart";
4
+ import { createRoot } from "react-dom/client";
5
+
6
+ const COMPONENTS: Record<string, FC> = {
7
+ Chart,
8
+ };
9
+
10
+ document.addEventListener("DOMContentLoaded", () => {
11
+ const elements = document.querySelectorAll("[data-react]");
12
+ for (const element of elements) {
13
+ const root = createRoot(element);
14
+
15
+ const name = element.getAttribute("data-react");
16
+
17
+ const Component = COMPONENTS[name!];
18
+
19
+ root.render(<Component />);
20
+ }
21
+ });
@@ -1 +1,2 @@
1
1
  import "./initializers";
2
+ import "./components";
@@ -10,14 +10,20 @@ import { faGlobe } from "@fortawesome/free-solid-svg-icons/faGlobe";
10
10
  import { faHouse } from "@fortawesome/free-solid-svg-icons/faHouse";
11
11
  import { faSliders } from "@fortawesome/free-solid-svg-icons/faSliders";
12
12
  import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
13
+ import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
14
+ import { faArrowDown } from "@fortawesome/free-solid-svg-icons/faArrowDown";
15
+ import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark";
13
16
 
14
17
  library.add(
18
+ faArrowDown,
19
+ faArrowUp,
15
20
  faClock,
16
21
  faEye,
22
+ faFileCode,
17
23
  faGauge,
18
24
  faGlobe,
19
25
  faHouse,
20
26
  faSliders,
21
27
  faUsers,
22
- faFileCode
28
+ faXmark
23
29
  );
@@ -3,8 +3,40 @@
3
3
  @tailwind utilities;
4
4
 
5
5
  @layer components {
6
+ .stat {
7
+ @apply flex gap-2 justify-between items-center;
8
+ }
9
+
10
+ .delta {
11
+ @apply font-normal flex gap-2 items-center;
12
+ }
13
+
14
+ .delta--neutral {
15
+ @apply text-slate-400;
16
+ }
17
+
18
+ .delta--positive {
19
+ @apply text-emerald-600;
20
+ }
21
+
22
+ .delta--negative {
23
+ @apply text-rose-600;
24
+ }
25
+
26
+ .pills {
27
+ @apply inline-flex gap-2 bg-slate-100 border-slate-200 rounded-full text-slate-600;
28
+ }
29
+
6
30
  .pill {
7
- @apply bg-slate-200 text-slate-800 hover:text-white hover:bg-indigo-600 rounded-full px-3 py-2;
31
+ @apply flex items-center justify-center gap-2 rounded-full px-6 py-2;
32
+ }
33
+
34
+ .pill--default {
35
+ @apply bg-slate-100 font-medium hover:bg-slate-200 hover:text-slate-800;
36
+ }
37
+
38
+ .pill--active {
39
+ @apply bg-white shadow font-semibold text-slate-800;
8
40
  }
9
41
 
10
42
  .card {
@@ -20,7 +52,7 @@
20
52
  }
21
53
 
22
54
  .card__content {
23
- @apply px-5 py-4
55
+ @apply px-5 py-4;
24
56
  }
25
57
 
26
58
  .card__title {
@@ -28,7 +60,11 @@
28
60
  }
29
61
 
30
62
  .card__value {
31
- @apply uppercase text-slate-800 font-extrabold text-lg;
63
+ @apply text-slate-800 font-extrabold text-lg;
64
+ }
65
+
66
+ .cards {
67
+ @apply grid gap-4 grid-cols-1 md:grid-cols-3;
32
68
  }
33
69
 
34
70
  .table {
@@ -1,25 +1,28 @@
1
1
  <%- provide(:title, @dashboard.name) %>
2
2
 
3
3
  <div class="space-y-4">
4
- <div class="flex gap-2">
5
- <%= link_to 'Today', analytic.dashboard_path(period: 'today'), class: 'pill' %>
6
- <%= link_to 'Yesterday', analytic.dashboard_path(period: 'yesterday'), class: 'pill' %>
7
- <%= link_to 'Week', analytic.dashboard_path(period: 'week'), class: 'pill' %>
8
- <%= link_to 'Month', analytic.dashboard_path(period: 'month'), class: 'pill' %>
9
- <%= link_to 'Year', analytic.dashboard_path(period: 'year'), class: 'pill' %>
4
+ <div class="pills">
5
+ <%= link_to 'All', analytic.dashboard_path, class: "pill #{@dashboard.period.nil? ? 'pill--active' : 'pill--default'}" %>
6
+ <%= link_to '24 hours', analytic.dashboard_path(period: '24h'), class: "pill #{@dashboard.period.eql?('24h') ? 'pill--active' : 'pill--default'}" %>
7
+ <%= link_to '7 days', analytic.dashboard_path(period: '7d'), class: "pill #{@dashboard.period.eql?('7d') ? 'pill--active' : 'pill--default'}" %>
8
+ <%= link_to '4 weeks', analytic.dashboard_path(period: '4w'), class: "pill #{@dashboard.period.eql?('4w') ? 'pill--active' : 'pill--default'}" %>
9
+ <%= link_to '12 months', analytic.dashboard_path(period: '12m'), class: "pill #{@dashboard.period.eql?('12m') ? 'pill--active' : 'pill--default'}" %>
10
10
  </div>
11
11
 
12
- <div class="grid grid-flow-col justify-stretch gap-4">
12
+ <div class="cards">
13
13
  <div class="card">
14
14
  <div class="card__header">
15
15
  <div class="card__title">
16
- <%= tag.i class: "fa-solid fa-users" %>
16
+ <%= fa_icon_tag("fa-solid fa-users") %>
17
17
  Visitors
18
18
  </div>
19
19
  </div>
20
20
  <div class="card__content">
21
21
  <div class="card__value">
22
- <%= number_with_delimiter @dashboard.distinct_visitors_count %>
22
+ <div class="stat">
23
+ <%= number_with_delimiter @dashboard.visitors.count %>
24
+ <%= delta_tag(@dashboard.visitors.delta) %>
25
+ </div>
23
26
  </div>
24
27
  </div>
25
28
  </div>
@@ -27,13 +30,16 @@
27
30
  <div class="card">
28
31
  <div class="card__header">
29
32
  <div class="card__title">
30
- <%= tag.i class: "fa-solid fa-globe" %>
33
+ <%= fa_icon_tag("fa-solid fa-globe") %>
31
34
  Sessions
32
35
  </div>
33
36
  </div>
34
37
  <div class="card__content">
35
38
  <div class="card__value">
36
- <%= number_with_delimiter @dashboard.distinct_sessions_count %>
39
+ <div class="stat">
40
+ <%= number_with_delimiter @dashboard.sessions.count %>
41
+ <%= delta_tag(@dashboard.sessions.delta) %>
42
+ </div>
37
43
  </div>
38
44
  </div>
39
45
  </div>
@@ -41,13 +47,16 @@
41
47
  <div class="card">
42
48
  <div class="card__header">
43
49
  <div class="card__title">
44
- <%= tag.i class: "fa-solid fa-eye" %>
50
+ <%= fa_icon_tag("fa-solid fa-eye") %>
45
51
  Views
46
52
  </div>
47
53
  </div>
48
54
  <div class="card__content">
49
55
  <div class="card__value">
50
- <%= number_with_delimiter @dashboard.count %>
56
+ <div class="stat">
57
+ <%= number_with_delimiter @dashboard.views.count %>
58
+ <%= delta_tag(@dashboard.views.delta) %>
59
+ </div>
51
60
  </div>
52
61
  </div>
53
62
  </div>
@@ -70,7 +79,7 @@
70
79
  </tr>
71
80
  </thead>
72
81
  <tbody>
73
- <%- @dashboard.pages.each do |(host, path, views)| -%>
82
+ <%- @dashboard.current.pages.each do |(host, path, views)| -%>
74
83
  <tr>
75
84
  <td><%= host %></td>
76
85
  <td><%= path %></td>
@@ -81,4 +90,42 @@
81
90
  </table>
82
91
  </div>
83
92
  </div>
93
+
94
+ <div class="cards">
95
+ <div class="card">
96
+ <div class="card__header">
97
+ <div class="card__title">
98
+ <%= tag.i class: "fa-solid fa-file-code" %>
99
+ Visitors
100
+ </div>
101
+ </div>
102
+ <div class="card__content">
103
+ <%= react(component: "Chart") %>
104
+ </div>
105
+ </div>
106
+
107
+ <div class="card">
108
+ <div class="card__header">
109
+ <div class="card__title">
110
+ <%= tag.i class: "fa-solid fa-globe" %>
111
+ Sessions
112
+ </div>
113
+ </div>
114
+ <div class="card__content">
115
+ <%= react(component: "Chart") %>
116
+ </div>
117
+ </div>
118
+
119
+ <div class="card">
120
+ <div class="card__header">
121
+ <div class="card__title">
122
+ <%= tag.i class: "fa-solid fa-eye" %>
123
+ Views
124
+ </div>
125
+ </div>
126
+ <div class="card__content">
127
+ <%= react(component: "Chart") %>
128
+ </div>
129
+ </div>
130
+ </div>
84
131
  </div>
@@ -6,7 +6,7 @@
6
6
  <%= csp_meta_tag %>
7
7
  <%= javascript_include_tag 'analytic/application' %>
8
8
  <%= stylesheet_link_tag 'analytic/application' %>
9
- <%= %>
9
+ <%= favicon_link_tag 'analytic/icon.svg', type: 'image/svg+xml' %>
10
10
  </head>
11
11
  <body class="bg-slate-50">
12
12
 
@@ -32,11 +32,5 @@
32
32
  <main class="container mx-auto px-4 py-8">
33
33
  <%= yield %>
34
34
  </main>
35
-
36
- <footer class="container mx-auto px-4 text-center">
37
- <div class="flex gap-2 justify-center">
38
- <%= time_tag(Time.current) %>
39
- </div>
40
- </footer>
41
35
  </body>
42
36
  </html>
data/bin/rails CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- ENGINE_ROOT = File.expand_path("..", __dir__)
4
- ENGINE_PATH = File.expand_path("../lib/analytic/engine", __dir__)
5
- APP_PATH = File.expand_path("../spec/dummy/config/application", __dir__)
3
+ # frozen_string_literal: true
6
4
 
7
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
8
- require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
5
+ ENGINE_ROOT = File.expand_path('..', __dir__)
6
+ ENGINE_PATH = File.expand_path('../lib/analytic/engine', __dir__)
7
+ APP_PATH = File.expand_path('../spec/dummy/config/application', __dir__)
9
8
 
10
- require "rails/all"
11
- require "rails/engine/commands"
9
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
10
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
11
+
12
+ require 'rails/all'
13
+ require 'rails/engine/commands'
@@ -1,20 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateAnalyticViews < ActiveRecord::Migration[7.1]
3
+ class CreateAnalyticEvents < ActiveRecord::Migration[7.1]
4
4
  def change
5
- create_table :analytic_views do |t|
5
+ create_table :analytic_events do |t|
6
6
  t.uuid :visitor_id, null: false
7
7
  t.uuid :session_id, null: false
8
+ t.inet :ip, null: false
8
9
  t.string :path, null: false
9
10
  t.string :host, null: false
10
11
  t.jsonb :params, null: false, default: {}
11
12
  t.datetime :timestamp, null: false
13
+ t.text :referrer, null: true
14
+ t.text :user_agent, null: true
12
15
 
13
16
  t.timestamps
14
17
 
15
18
  t.index %i[timestamp path]
16
19
  t.index %i[timestamp visitor_id]
17
20
  t.index %i[timestamp session_id]
21
+ t.index %i[timestamp referrer]
18
22
  end
19
23
  end
20
24
  end
@@ -2,11 +2,71 @@
2
2
 
3
3
  module Analytic
4
4
  class Config
5
- # @return [String]
6
- attr_accessor :timezone
5
+ # @example
6
+ # config.time_zone = ActiveSupport::TimeZone['Tokyo']
7
+ #
8
+ # @!attribute [rw] time_zone
9
+ # @return [ActiveSupport::TimeZone]
10
+ attr_accessor :time_zone
11
+
12
+ # @example
13
+ # config.ip_v4_mask = 24
14
+ #
15
+ # @!attribute [rw] ip_v4_mask
16
+ # @return [Integer]
17
+ attr_accessor :ip_v4_mask
18
+
19
+ # @example
20
+ # config.ip_v6_mask = 48
21
+ #
22
+ # @!attribute [rw] ip_v6_mask
23
+ # @return [Integer]
24
+ attr_accessor :ip_v6_mask
25
+
26
+ # @example
27
+ # config.connects_to = database: { writing: :primary, reading: :replica }
28
+ #
29
+ # @!attribute [rw] connects_to
30
+ # @return [Hash, nil]
31
+ attr_accessor :connects_to
32
+
33
+ # @!attribute [rw] middleware
34
+ # @return [Array<Rack::Middleware>]
35
+ attr_accessor :middleware
36
+
37
+ # @example
38
+ # config.params = %i[utm_source utm_medium utm_campaign utm_content utm_term ref source]
39
+ #
40
+ # @!attribute [rw] params
41
+ # @return [Array<Symbol>]
42
+ attr_accessor :params
7
43
 
8
44
  def initialize
9
- @timezone = Time.zone
45
+ @time_zone = Time.zone
46
+ @ip_v4_mask = 24 # e.g. 255.255.255.255 => '255.255.255.0/255.255.255.0'
47
+ @ip_v6_mask = 48 # e.g. 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => 'ffff:ffff:ffff:0000:0000:0000:0000:0000'
48
+ @middleware = []
49
+ @params = %i[utm_source utm_medium utm_campaign utm_content utm_term ref source]
50
+ end
51
+
52
+ # @return [Boolean]
53
+ def ip_v4_mask?
54
+ @ip_v4_mask.present?
55
+ end
56
+
57
+ # @return [Boolean]
58
+ def ip_v6_mask?
59
+ @ip_v6_mask.present?
60
+ end
61
+
62
+ # @return [Boolean]
63
+ def connects_to?
64
+ @connects_to.present?
65
+ end
66
+
67
+ # @param middleware [Rack::Middleware]
68
+ def use(*args, &block)
69
+ middleware << [args, block]
10
70
  end
11
71
  end
12
72
  end
@@ -3,5 +3,9 @@
3
3
  module Analytic
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Analytic
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
6
10
  end
7
11
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Analytic
4
- VERSION = '0.2.0'
4
+ VERSION = '0.5.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: analytic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Sylvestre
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-08-06 00:00:00.000000000 Z
11
+ date: 2024-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -34,10 +34,8 @@ files:
34
34
  - LICENSE
35
35
  - README.md
36
36
  - Rakefile
37
- - app/assets/builds/analytic/application.css
38
- - app/assets/builds/analytic/application.js
39
- - app/assets/builds/analytic/application.js.map
40
37
  - app/assets/config/analytic_manifest.js
38
+ - app/assets/images/analytic/icon.svg
41
39
  - app/controllers/analytic/application_controller.rb
42
40
  - app/controllers/analytic/dashboard_controller.rb
43
41
  - app/controllers/concerns/analytic/trackable.rb
@@ -47,7 +45,11 @@ files:
47
45
  - app/mailers/analytic/application_mailer.rb
48
46
  - app/models/analytic/application_record.rb
49
47
  - app/models/analytic/dashboard.rb
50
- - app/models/analytic/view.rb
48
+ - app/models/analytic/event.rb
49
+ - app/models/analytic/period.rb
50
+ - app/models/analytic/stat.rb
51
+ - app/packs/analytic/application/components/chart.tsx
52
+ - app/packs/analytic/application/components/index.tsx
51
53
  - app/packs/analytic/application/index.ts
52
54
  - app/packs/analytic/application/initializers/fontawesome.ts
53
55
  - app/packs/analytic/application/initializers/index.ts
@@ -58,7 +60,7 @@ files:
58
60
  - bin/dev
59
61
  - bin/rails
60
62
  - config/routes.rb
61
- - db/migrate/20240805210911_create_analytic_views.rb
63
+ - db/migrate/20240805210911_create_analytic_events.rb
62
64
  - lib/analytic.rb
63
65
  - lib/analytic/config.rb
64
66
  - lib/analytic/engine.rb
@@ -68,7 +70,7 @@ licenses:
68
70
  - MIT
69
71
  metadata:
70
72
  rubygems_mfa_required: 'true'
71
- post_install_message:
73
+ post_install_message:
72
74
  rdoc_options: []
73
75
  require_paths:
74
76
  - lib
@@ -83,8 +85,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
85
  - !ruby/object:Gem::Version
84
86
  version: '0'
85
87
  requirements: []
86
- rubygems_version: 3.5.11
87
- signing_key:
88
+ rubygems_version: 3.5.18
89
+ signing_key:
88
90
  specification_version: 4
89
91
  summary: Analytic
90
92
  test_files: []