analytic 0.2.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []