Skip to content

Commit

Permalink
14 tracking (#34)
Browse files Browse the repository at this point in the history
* Add user references to tweets and metrics

* Add uuid to tweets

* Add API endpoint to receive metrics

* Tracker script [WIP]

* Tracker

* Check access for tracker sctipt

* Dynamic group chart resolution

* Add tweet + better data tracking

* Disable tracker debugging

* Remove custom logger

* Only top 10 tweets with charts

* Better index layout
  • Loading branch information
hoblin authored Oct 8, 2023
1 parent f913f15 commit 06d3228
Show file tree
Hide file tree
Showing 27 changed files with 781 additions and 36 deletions.
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ gem "sqlite3", "~> 1.6"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "annotate", "~> 3.2"
gem "better_errors", "~> 2.10"
gem "binding_of_caller", "~> 1.0"
gem "debug", platforms: %i[mri mingw x64_mingw]
gem "factory_bot_rails", "~> 6.2"
Expand All @@ -60,6 +59,7 @@ group :development, :test do
end

group :development do
gem "better_errors", "~> 2.10"
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
end
2 changes: 1 addition & 1 deletion app/assets/stylesheets/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ h1.rotated {
font-size: 3rem;
}

.hero.is-info .has-dark-text .title {
.hero.is-info .has-dark-text .title, .hero.is-info .has-dark-text .subtitle {
color: #000;
}

Expand Down
4 changes: 4 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ def configure_permitted_parameters
def after_sign_in_path_for(resource)
tweets_path
end

def record_not_found
render plain: "404 Not Found", status: :not_found
end
end
71 changes: 69 additions & 2 deletions app/controllers/tweets_controller.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
class TweetsController < ApplicationController
skip_forgery_protection only: %i[track receive_metrics] # Skip CSRF protection for Tampermonkey POST requests
caches_action :index, :show, expires_in: 15.minutes
before_action :authenticate_user!, only: %i[new create track]

def index
@tweets = Tweet.first(10).map(&:decorate)

scope = Tweet.joins(:tweet_metrics)
.select("tweets.*, COUNT(tweet_metrics.id) AS metrics_count")
.group("tweets.id")
.order("metrics_count DESC")
@tweets = scope.limit(10).map(&:decorate)
@other_tweets = Tweet.limit(400).map(&:decorate)
respond_to do |format|
format.html # renders index.html.slim
end
Expand All @@ -19,5 +25,66 @@ def show
render json: @tweet.tweet_metrics.to_json
end
end
rescue ActiveRecord::RecordNotFound
record_not_found
end

def new
@tweet = Tweet.new
end

def create
@tweet = current_user.tweets.new(create_tweet_params)

if @tweet.save
redirect_to @tweet
else
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace(@tweet, partial: "tweets/form", locals: {tweet: @tweet}) }
format.html { render :new }
end
end
end

def receive_metrics
tweet = Tweet.find_by(uuid: tweet_params[:uuid])
return render json: {error: "Tweet not found"}, status: :not_found unless tweet

tweet.tweet_metrics.create!(metrics_params.merge(user: tweet.user))

tweet.update!(tweet_params.except(:uuid)) if tweet_params[:body].present?

if tweet.body.blank?
render json: {command: :fetch_tweet_details}
else
render json: {message: :ok}
end
end

# render js template using headers expected by Tampermonkey to install it
def track
@tweet = Tweet.find(params[:id]).decorate
@report_url = receive_metrics_url

# TODO: replace with cancancan
return record_not_found unless user_signed_in? && current_user == @tweet.user

respond_to do |format|
format.js { render layout: false, content_type: "application/javascript" }
end
end

private

def create_tweet_params
params.require(:tweet).permit(:url)
end

def tweet_params
params.require(:tweet).permit(:uuid, :body, :avatar)
end

def metrics_params
params.require(:metrics).permit(:likes, :reposts, :replies, :bookmarks, :views)
end
end
35 changes: 20 additions & 15 deletions app/decorators/tweet_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,31 +106,36 @@ def chart_options

def combined_metrics_series
[
likes_metric,
replies_metric,
reposts_metric,
bookmarks_metric,
views_metric
likes_metric(resolution),
replies_metric(resolution),
reposts_metric(resolution),
bookmarks_metric(resolution)
# views_metric TODO: enable after getting rid of chartkick and switching to custom Y axis
]
end

def likes_metric
@likes_metric ||= {name: "Likes", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: 5).maximum(:likes)}
def resolution
# resolution is 1 minute if we don't have many metrics and gradually increases up to 1 hour depending on the number of metrics
@resolution ||= object.tweet_metrics.count / 2000 + 1
end

def replies_metric
@replies_metric ||= {name: "Replies", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: 5).maximum(:replies)}
def likes_metric(minutes = 5)
@likes_metric ||= {name: "Likes", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: minutes).maximum(:likes)}
end

def reposts_metric
@reposts_metric ||= {name: "Reposts", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: 5).maximum(:reposts)}
def replies_metric(minutes = 5)
@replies_metric ||= {name: "Replies", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: minutes).maximum(:replies)}
end

def bookmarks_metric
@bookmarks_metric ||= {name: "Bookmarks", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: 5).maximum(:bookmarks)}
def reposts_metric(minutes = 5)
@reposts_metric ||= {name: "Reposts", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: minutes).maximum(:reposts)}
end

def views_metric
@views_metric ||= {name: "Views", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: 5).maximum(:views)}
def bookmarks_metric(minutes = 5)
@bookmarks_metric ||= {name: "Bookmarks", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: minutes).maximum(:bookmarks)}
end

def views_metric(minutes = 5)
@views_metric ||= {name: "Views", data: object.tweet_metrics.group_by_minute(:created_at, series: false, n: minutes).maximum(:views)}
end
end
30 changes: 26 additions & 4 deletions app/models/tweet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,23 @@
# url :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class Tweet < ApplicationRecord
has_many :tweet_metrics, dependent: :destroy
validates :url, presence: true, uniqueness: true
belongs_to :user

validates :url, presence: true, uniqueness: true, format: %r{https://(twitter|x).com/\w+/status/\d+}
validates :uuid, presence: true, uniqueness: true

before_validation :set_author
before_validation :set_author, :set_uuid

def author_avatar_url
# TODO: Implement a way to get the avatar url from the tweet
"https://pbs.twimg.com/profile_images/1678305940481753089/g751T5c__400x400.jpg"
avatar
end

def author_url
Expand Down Expand Up @@ -53,13 +60,28 @@ def views
last_metric&.views
end

def match_url
url.sub(%r{https://(twitter|x).com}, "https://*").sub(/\?.*/, "*")
end

private

def last_metric
@last_metric ||= tweet_metrics.last
end

def set_author
return unless valid_url?

self.author = url&.split("/")&.fetch(3)
end

def set_uuid
self.uuid = SecureRandom.uuid if uuid.blank?
end

def valid_url?
# Ensure the valid tweet url: https://twitter.com/P_Kallioniemi/status/1674360288445964288
url&.match?(%r{https://(twitter|x).com/\w+/status/\d+})
end
end
3 changes: 3 additions & 0 deletions app/models/tweet_metric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# tweet_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
Expand All @@ -20,7 +21,9 @@
# Foreign Keys
#
# fk_rails_... (tweet_id => tweets.id)
# fk_rails_... (user_id => users.id)
#
class TweetMetric < ApplicationRecord
belongs_to :tweet
belongs_to :user
end
9 changes: 9 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,17 @@
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:rememberable, :validatable

# TODO: Implement transfering tweets and tweet metrics to admin before deleting the user
has_many :tweets
has_many :tweet_metrics

validates :username, presence: true, uniqueness: {case_sensitive: false}

def admin?
username == Rails.application.credentials.admin_user.username
end

def email_required?
false
end
Expand Down
11 changes: 11 additions & 0 deletions app/views/tweets/_form.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#new_tweet
h3.has-text-centered.subtitle Add a New Tweet
= form_with model: tweet do |f|
.field.has-addons.is-large
.control.is-expanded.is-large
= f.text_field :url, class: "input is-large#{ ' is-danger' if tweet.errors[:url].any? }", placeholder: 'Enter Tweet URL'
.control
= f.submit 'Add Tweet', class: 'button is-primary is-large'
- tweet.errors.full_messages.each do |message|
p.help.is-danger.is-size-4
= message
31 changes: 30 additions & 1 deletion app/views/tweets/index.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ section.hero.is-info
.columns.is-centered
.column.is-1
h1.rotated.has-text-grey Tracked Tweets
/ Display top tweets with charts
.column.is-10
.section
= link_to 'Add Tweet', new_tweet_path, class: 'button is-primary is-fullwidth is-large'
- @tweets.each do |tweet|
.card
.card-content
Expand All @@ -17,7 +20,7 @@ section.hero.is-info
p class="title is-4"
= link_to tweet.author_name, tweet.author_url, target: "_blank"
p class="subtitle is-6"
= tweet.body.truncate(280)
= tweet.body&.truncate(280)
.content
= tweet.combined_chart
footer.card-footer
Expand All @@ -30,3 +33,29 @@ section.hero.is-info
span.icon
i class="fa fa-twitter"
.column.is-1
/ Display other tweets as cards
.columns.is-centered
.column.is-10
.columns.is-centered.is-multiline
- @other_tweets.each do |tweet|
.column.is-3.is-flex.is-flex-direction-column
.card.is-flex.is-flex-direction-column.is-flex-grow-1
.card-content.is-flex.is-flex-direction-column.is-flex-grow-1
.media
.media-left
figure class="image is-48x48"
img src="#{tweet.author_avatar_url}" class="is-rounded"
.media-content
.title.is-4
= link_to tweet.author_name, tweet.url, target: "_blank"
.content
= tweet.body&.truncate(280)
.card-footer
= link_to tweet_path(tweet), class: "card-footer-item primary" do
span View Metrics
span.icon
i class="fa fa-chart-line"
= link_to tweet.url, class: "card-footer-item", target: "_blank" do
span View Tweet
span.icon
i class="fa fa-twitter"
53 changes: 53 additions & 0 deletions app/views/tweets/new.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
section.hero.is-info.is-fullheight
.hero-head
= render "shared/navbar"
.hero-body
.container
.columns.is-centered
.column.is-10
.box.has-dark-text
.content
h1.title.has-text-centered Before you start tracking
p.subtitle.has-text-centered
| X-Tracker provides real-time tweet metrics tracking right in your browser. Here's a brief on how it works:
ul
li
strong> Tampermonkey Extension:
| Our tracker operates through the Tampermonkey browser extension. It's a user script manager that allows the script to interact with the tweet page and send metrics to X-Tracker.
li
strong> Tracker Script:
| The tracker script does the magic. Once installed, it observes the tweet page, collects metrics, and sends them to X-Tracker, where you can visualize the data.
li
strong> Real-Time Tracking:
| Metrics are updated in real-time. You can start and stop tracking anytime, and analyze the tweet's performance right on X-Tracker.
p.has-text-centered.subtitle
| Ready to dive in? Follow the steps below:

ol
li
strong Add a Tweet to X-Tracker:
ul
li Input the URL of the tweet you want to track in the form below and click "Submit."
li You'll be redirected to the tweet's page on X-Tracker.
li.mt-3
strong Install the Tracker:
ul
li Click the "Install Tracker" button on the tweet's page on X-Tracker. A Tampermonkey window will pop up.
li Click "Install" on the Tampermonkey window to add the tracker script.
li.mt-3
strong Start Tracking:
ul
li Now, click the "Start Tracking" button on the tweet's page on X-Tracker.
li The tweet URL will open in a new browser tab.
li A prompt will appear, asking for your permission to send data to X-Tracker. Click "Allow" to start tracking the tweet metrics.
li The tracking will commence, and you can view the metrics on the tweet's page on X-Tracker.
p.has-text-centered.subtitle
| Install the Tampermonkey extension on your browser to get started:
nav.buttons.is-centered
- %w(chrome firefox safari edge opera).each do |browser|
a.button.is-link.is-outlined.is-rounded href="https://www.tampermonkey.net/index.php?browser=#{browser}" target="_blank"
span.icon
i.fab class="fa-#{browser}" aria-hidden="true"
span= browser.capitalize

= render partial: 'tweets/form', locals: { tweet: @tweet }
11 changes: 11 additions & 0 deletions app/views/tweets/show.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ section.hero.is-info
| Comments: #{number_with_delimiter(@tweet.replies)}
p
| Views: #{number_with_delimiter(@tweet.views)}
- if user_signed_in? && @tweet.user == current_user
.card
.card-content
.content
.columns.is-centered
.column.is-6
.content
= link_to "Install tracker", track_user_js_path(@tweet), class: "button is-primary is-fullwidth is-large"
.column.is-6
.content
= link_to "Start tracking", @tweet.url, class: "button is-primary is-fullwidth is-large", target: "_blank"
hr
.content
.card
Expand Down
Loading

0 comments on commit 06d3228

Please sign in to comment.