[WIP] Improve article boosting abilities and insights (#237)

* Update email subjects split test

* Create alternate editor

* Add proof of concept to emails

* Add utm for internal nav

* Add ability to boost in DEV Digest

* Adjust articles.scss

* Adjust article_form.scss

* Remove unnecessary files

* Remove files from PR

* Adjust boosted email settings

* Conditionally include organization in additional box

* Modify schema.rb
This commit is contained in:
Ben Halpern 2018-05-02 12:24:54 -04:00 committed by GitHub
parent 5a72d0ec56
commit f2101410ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 236 additions and 43 deletions

View file

@ -24,7 +24,7 @@ module Api
tag_list = if params[:tag_list].present?
params[:tag_list].split(",")
else
["career","discuss","productivity"]
["career", "discuss", "productivity"]
end
@articles = []
4.times do

View file

@ -69,6 +69,22 @@ class Internal::ArticlesController < Internal::ApplicationController
page(params[:page]).
per(100).
limited_columns_internal_select
when "boosted-additional-articles"
@articles = Article.
includes(:user).
order("published_at DESC").
boosted_via_additional_articles.
page(params[:page]).
per(100).
limited_columns_internal_select
when "boosted-dev-digest"
@articles = Article.
includes(:user).
order("published_at DESC").
boosted_via_dev_digest_email.
page(params[:page]).
per(100).
limited_columns_internal_select
else #MIX
@articles = Article.
where(published: true).
@ -97,6 +113,9 @@ class Internal::ArticlesController < Internal::ApplicationController
article.featured = article_params[:featured].to_s == "true"
article.approved = article_params[:approved].to_s == "true"
article.live_now = article_params[:live_now].to_s == "true"
article.email_digest_eligible = article_params[:email_digest_eligible].to_s == "true"
article.boosted_additional_articles = article_params[:boosted_additional_articles].to_s == "true"
article.boosted_dev_digest_email = article_params[:boosted_dev_digest_email].to_s == "true"
article.update!(article_params)
if article.live_now
Article.where.not(id: article.id).where(live_now: true).update_all(live_now: false)
@ -115,6 +134,9 @@ class Internal::ArticlesController < Internal::ApplicationController
:body_markdown,
:approved,
:live_now,
:email_digest_eligible,
:boosted_additional_articles,
:boosted_dev_digest_email,
:main_image_background_hex_color,
:featured_number)
end

View file

@ -42,4 +42,13 @@ class ArticleDecorator < ApplicationDecorator
"short"
end
end
def internal_utm_params(place="additional_box")
campaign = if boosted
"#{organization&.slug}_boosted"
else
"regular"
end
"?utm_source=#{place}&utm_medium=internal&utm_campaign=#{ campaign }&booster_org=#{organization&.slug}"
end
end

View file

@ -2,16 +2,20 @@ import { h, render } from 'preact';
import Onboarding from '../src/Onboarding';
import { getUserData } from '../src/utils/getUserData';
HTMLDocument.prototype.ready = new Promise((resolve) => {
if (document.readyState !== 'loading') { return resolve(); }
HTMLDocument.prototype.ready = new Promise(resolve => {
if (document.readyState !== 'loading') {
return resolve();
}
document.addEventListener('DOMContentLoaded', () => resolve());
});
function shouldShowOnboarding() {
return document.head.getElementsByTagName('meta')[2].content === 'true' &&
return (
document.head.getElementsByTagName('meta')[2].content === 'true' &&
document.body.getAttribute('data-user') &&
document.body.getAttribute('data-user') !== 'undefined' &&
JSON.parse(document.body.getAttribute('data-user')).saw_onboarding === false
);
}
function renderPage() {
@ -22,8 +26,8 @@ function renderPage() {
}
}
document.ready
.then(getUserData()
.then(() => {
renderPage();
}));
document.ready.then(
getUserData().then(() => {
renderPage();
}),
);

View file

@ -7,11 +7,19 @@ class BoostedArticle
@not_ids = options[:not_ids]
end
def get
Article.where(boosted: true).
includes(:user).
includes(:organization).
where.not(id: not_ids, organization_id: nil).
tagged_with(tags, any: true).sample
def get(area = "additional_articles")
if area == "additional_articles"
Article.boosted_via_additional_articles.
includes(:user).
includes(:organization).
where.not(id: not_ids, organization_id: nil).
tagged_with(tags, any: true).sample
else
Article.boosted_via_dev_digest_email.
includes(:user).
includes(:organization).
where.not(id: not_ids, organization_id: nil).
tagged_with(tags, any: true).sample
end
end
end

View file

@ -33,7 +33,7 @@ class EmailLogic
articles = if user_has_followings?
@user.followed_articles.
where("published_at > ?", fresh_date).
where(published: true).
where(published: true, email_digest_eligible: true).
where.not(user_id: @user.id).
where("positive_reactions_count > ?", 15).
order("positive_reactions_count DESC").

View file

@ -5,6 +5,9 @@ class DigestMailer < ApplicationMailer
@user = user
@articles = articles.first(6)
@unsubscribe = generate_unsubscribe_token(@user.id, :email_digest_periodic)
@boosted_article = BoostedArticle.new(@user,
@articles.first,
{not_ids: @articles.pluck(:id)}).get("dev_digest_email")
subject = generate_title(@articles)
mail(to: @user.email, subject: subject)
end

View file

@ -4,6 +4,7 @@ class Article < ApplicationRecord
include CloudinaryHelper
include ActionView::Helpers
include AlgoliaSearch
include Storext.model
acts_as_taggable_on :tags
@ -63,14 +64,23 @@ class Article < ApplicationRecord
scope :limited_columns_internal_select, -> {
select(:path, :title, :id, :featured, :approved, :published,
:comments_count, :positive_reactions_count, :cached_tag_list,
:main_image, :main_image_background_hex_color, :updated_at,
:main_image, :main_image_background_hex_color, :updated_at, :boost_states,
:video, :user_id, :organization_id, :video_source_url, :video_code,
:video_thumbnail_url, :video_closed_caption_track_url, :social_image,
:published_from_feed, :crossposted_at, :published_at, :featured_number,
:live_now, :last_buffered, :facebook_last_buffered, :created_at, :body_markdown
:live_now, :last_buffered, :facebook_last_buffered, :created_at, :body_markdown,
:email_digest_eligible
)
}
scope :boosted_via_additional_articles, -> {
where("boost_states ->> 'boosted_additional_articles' = 'true'")
}
scope :boosted_via_dev_digest_email, -> {
where("boost_states ->> 'boosted_dev_digest_email' = 'true'")
}
algoliasearch per_environment: true, enqueue: :trigger_delayed_index do
add_index "searchables",
id: :index_id,
@ -142,6 +152,12 @@ class Article < ApplicationRecord
end
end
store_attributes :boost_states do
boosted_additional_articles Boolean, default: false
boosted_dev_digest_email Boolean, default: false
end
def self.filter_excluded_tags(tag = nil)
if tag == "hiring"
tagged_with("hiring")

View file

@ -12,6 +12,7 @@
</div>
<div class="secondary-content-display">
<%= render "additional_content_boxes/article_followable_area",
article: article,
followable: article.organization || article.user,
classification: classification,
follow_cue: follow_cue %>

View file

@ -1,12 +1,12 @@
<h2><a href="<%= article.path %>" class="<%= 'partner-link' if classification == "boosted" %>" data-details="<%= organization&.slug %>__<%= article.slug %>"><%= article.title %></a></h2>
<h2><a href="<%= article.path + article.decorate.internal_utm_params %>" class="<%= 'partner-link' if classification == "boosted" %>" data-details="<%= organization&.slug %>__<%= article.slug %>"><%= article.title %></a></h2>
<div class="content-author">
<a href="<%= article.user.path %>">
<a href="<%= article.user.path + article.decorate.internal_utm_params %>">
<img class="profile-pic" src="<%= ProfileImage.new(article.user).get(50)%>" alt="<%= article.user.username %> profile image"/>
<span><%= article.user.name %></span>
</a>
</div>
<p>
<a href="<%= article.path %>" class="<%= 'partner-link' if classification == "boosted" %>" data-details="<%= organization&.slug %>__<%= article.slug %>">
<a href="<%= article.path + article.decorate.internal_utm_params%>" class="<%= 'partner-link' if classification == "boosted" %>" data-details="<%= organization&.slug %>__<%= article.slug %>">
<%= article.description %>
</a>
<div class="engagement-count">
@ -18,7 +18,7 @@
<% end %>
</div>
</p>
<a href="<%= article.path %>" class="cta<%= ' partner-link' if classification == "boosted" %>" data-details="<%= organization&.slug %>__<%= article.slug %>">READ POST</a>
<a href="<%= article.path + article.decorate.internal_utm_params%>" class="cta<%= ' partner-link' if classification == "boosted" %>" data-details="<%= organization&.slug %>__<%= article.slug %>">READ POST</a>
<button
class="article-engagement-count bookmark-engage<%= ' partner-link' if classification == "boosted" %>"
data-reactable-id="<%= article.id %>"

View file

@ -1,6 +1,6 @@
<% if followable.class.name == "Organization" && followable.nav_image.present? %>
<div class="profile-pic-wrapper wide-profile-image-wrapper">
<a href="<%= followable.path %>" />
<a href="<%= followable.path + article.decorate.internal_utm_params %>" />
<img class="wide-image" src="<%= CloudinaryHelper.cl_image_path(followable.nav_image_url,
type: "fetch",
crop: "fill",
@ -13,7 +13,7 @@
</div>
<% else %>
<div class="profile-pic-wrapper">
<a href="<%= followable.path %>"
<a href="<%= followable.path + article.decorate.internal_utm_params %>"
class="<%= ' partner-link' if classification == "boosted" %>"
data-details="<%= followable&.slug if classification == "boosted" %>__PROFILE"/>
<img class="profile-image"
@ -22,7 +22,7 @@
style="border: 4px solid <%= followable.bg_color_hex %>" />
</div>
<div class="org-name">
<a href="<%= followable.path %>"
<a href="<%= followable.path + article.decorate.internal_utm_params %>"
class="<%= ' partner-link' if classification == "boosted" %>"
data-details="<%= followable&.slug if classification == "boosted" %>__PROFILE" /><%= followable.name %></a>
</div>

View file

@ -3,7 +3,7 @@
article: @boosted_article,
classification: "boosted",
classification_text: "From one of our Community Sponsors",
follow_cue: @boosted_article.organization.tag_line || @boosted_article.organization.summary %>
follow_cue: @boosted_article.organization&.tag_line || @boosted_article.organization&.tag_line %>
<% elsif @alt_classic %>
<%= render "additional_content_boxes/article_box",
article: @alt_classic,

View file

@ -37,9 +37,13 @@
&nbsp Featured: <input name="article[featured]" type="checkbox" <%= "checked" if article.featured %>>
&nbsp Approved: <input name="article[approved]" type="checkbox" <%= "checked" if article.approved %>>
&nbsp Live Now: <input name="article[live_now]" type="checkbox" <%= "checked" if article.live_now %>>
&nbsp Image BG color: <input name="article[main_image_background_hex_color]" value="<%= article.main_image_background_hex_color %>" id="featured_number_<%= article.id %>"><br/><br/>
&nbsp Email Digest Eligible: <input name="article[email_digest_eligible]" type="checkbox" <%= "checked" if article.email_digest_eligible %>>
&nbsp Image BG color: <input name="article[main_image_background_hex_color]" value="<%= article.main_image_background_hex_color %>" id="featured_number_<%= article.id %>">
Social Image: <input name="article[social_image]" value="<%= article.social_image %>" id="featured_number_<%= article.id %>">
<br/><br/>
Boosted (Additional articles): <input name="article[boosted_additional_articles]" type="checkbox" <%= "checked" if article.boosted_additional_articles %>>
&nbsp Boosted (DEV Digest): <input name="article[boosted_dev_digest_email]" type="checkbox" <%= "checked" if article.boosted_dev_digest_email %>>
<% if params[:state]&.include?("classic") %>
<br/><br>
<textarea cols="70" rows="6" wrap="hard" name="article[body_markdown]" /><%= article.body_markdown %></textarea>
@ -47,6 +51,18 @@
&nbsp &nbsp <button class="btn btn-primary">SUBMIT</button>
</form>
<% if article.boosted_dev_digest_email %>
<br/><br/>
<% phrase = "#{article.path}?booster_org=#{article.organization&.slug || "generic"}" %>
<b>BOOSTED IN <%= EmailMessage.where("subject LIKE ?", "%#{phrase}%").where.not(opened_at:nil).size %> EMAILS</b>
<br/>
<b>BOOSTED IN <%= EmailMessage.where("subject LIKE ?", "%#{phrase}%").where.not(opened_at:nil).where("sent_at > ?", 1.week.ago).size %> EMAILS IN THE PAST WEEK</b>
<br/></br/>
<b>BOOSTED IN <%= EmailMessage.where("subject LIKE ?", "%#{phrase}%").where.not(opened_at:nil).where.not(opened_at:nil).size %> OPENED EMAILS</b>
<br/>
<b>BOOSTED IN <%= EmailMessage.where("subject LIKE ?", "%#{phrase}%").where.not(opened_at:nil).where.not(opened_at:nil).where("sent_at > ?", 1.week.ago).size %> OPENED EMAILS IN THE PAST WEEK</b>
<% end %>
</div>
<% unless params[:state]&.include?("classic") %>

View file

@ -29,6 +29,8 @@
<a href="/internal/articles?state=spam" style="padding:10px;<%= "background:#89ffba" if params[:state] == "spam" %>">Spam?</a>
<a href="/internal/articles?state=classic" style="padding:10px;<%= "background:#89ffba" if params[:state] == "classic" %>">Classic</a>
<a href="/internal/articles?state=classic-candidate" style="padding:10px;<%= "background:#89ffba" if params[:state] == "classic-candidate" %>">Classic Candidate</a>
<a href="/internal/articles?state=boosted-additional-articles" style="padding:10px;<%= "background:#89ffba" if params[:state] == "boosted-additional-articles" %>">Boosted (Additional articles)</a>
<a href="/internal/articles?state=boosted-dev-digest" style="padding:10px;<%= "background:#89ffba" if params[:state] == "boosted-dev-digest" %>">Boosted (DEV Digest)</a>
</h3>
<br/>
<% if params[:state] && params[:state].include?("top-") && params[:state] != "top-3" && params[:state] != "top-6" %>

View file

@ -1 +1,92 @@
<%= render "individual_article", article: @article %>
<script>
function timeNow(objectID){
var seconds = new Date().getTime() / 1000;
document.getElementById("featured_number_"+objectID).value = Math.round(seconds);
}
function sink(objectID){
var seconds = new Date().getTime() / 1080;
document.getElementById("featured_number_"+objectID).value = Math.round(seconds);
}
$('.row').on("submit", "form", function() {
var form = $(this);
var valuesToSubmit = $(this).serialize();
$.ajax({
type: "POST",
url: $(this).attr('action'), //sumbits it to the given url of the form
data: valuesToSubmit,
// dataType: "JSON" // you want a difference between normal and ajax-calls, and json is standard
}).success(function(json){
console.log("success")
form.parents(".row").addClass("highlighted-bg")
form.parents(".row").addClass("highlighted-border")
setTimeout(function(){
form.parents(".row").removeClass("highlighted-bg")
},350)
});
return false; // prevents normal behaviour
});
document.getElementById('image-upload-button').onclick = function (e) {
e.preventDefault();
document.getElementById('image-upload').click();
}
document.getElementById('image-upload').onchange = function (e) {
var image = document.getElementById('image-upload').files;
if (image.length > 0) {
document.getElementById('image-upload-file-label').style = 'color:#888888';
document.getElementById('image-upload-file-label').innerHTML = "Uploading...";
document.getElementById('uploaded-image').style = 'display:none';
document.getElementById('image-upload-submit').value = "uploading";
setTimeout(function(){
document.getElementById('image-upload-submit').click(function(){
});
},50)
}
}
document.getElementById('image-upload-submit').onclick = function (e) {
e.preventDefault();
var image = document.getElementById('image-upload').files;
if (image.length > 0) {
var ajaxReq = createAjaxReq();
ajaxReq.onreadystatechange = uploadImageCb.bind(this, ajaxReq);
ajaxReq.open('POST', '/image_uploads', true);
ajaxReq.send(generateUploadFormdata(image));
}
}
function generateUploadFormdata(image) {
var token = document.getElementsByName('authenticity_token')[0].value;
var formData = new FormData();
formData.append('authenticity_token', token);
formData.append('image', image[0]);
return formData;
}
function uploadImageCb(ajaxReq) {
if (ajaxReq.status === 200 && ajaxReq.readyState === XMLHttpRequest.DONE) {
var address = document.getElementById('uploaded-image');
address.value = JSON.parse(ajaxReq.response).link;
address.style.display = "inline-block";
address.style.width = "90%";
address.select();
var uploadedMessage = '';
document.getElementById('image-upload-file-label').innerHTML = uploadedMessage;
document.getElementById('image-upload-file-label').style.color = '#00c673';
document.getElementById('image-upload-submit').style.display = 'none';
}
}
function createAjaxReq() {
if (window.XMLHttpRequest) {
return new XMLHttpRequest();
} else {
return new ActiveXObject("Microsoft.XMLHTTP");
}
}
</script>

View file

@ -7,18 +7,20 @@
</td>
</tr>
<tr>
<td style="padding:3% 12% 2% 4%;font-size:16px;line-height:22px;color:#7d7d7d">
<%if @unsubscribe %>
<%= link_to "Unsubscribe", email_subscriptions_unsubscribe_url(ut: @unsubscribe) %> | <a href="https://dev.to/settings/notifications">Adjust your email settings</a>
<% else %>
Don't want to get emails like this? Adjust your email settings at <a href="https://dev.to/settings/notifications">dev.to/settings/notifications</a>
<% end %>
<br/><br/>
<td style="padding:3% 12% 2% 4%;font-size:18px;line-height:22px;color:#7d7d7d">
<% if @user.twitter_username.blank? %>
Reminder: You used <b>GitHub</b> to authenticate your account, so use that to sign in if prompted.
<% else %>
Reminder: You used <b>Twitter</b> to authenticate your account, so use that to sign in if prompted.
<% end %>
<br/><br/>
<div style="font-size:0.78em">
<%if @unsubscribe %>
<%= link_to "Unsubscribe", email_subscriptions_unsubscribe_url(ut: @unsubscribe), style:"color:#7d7d7d" %> | <a href="https://dev.to/settings/notifications" style="color:#7d7d7d">Adjust your email settings</a>
<% else %>
Don't want to get emails like this? Adjust your email settings at <a href="https://dev.to/settings/notifications">dev.to/settings/notifications</a>
<% end %>
</div>
</td>
</tr>
</table>

View file

@ -2,13 +2,20 @@
DEV Digest
</h1>
<h4 style="text-align: center;">Recent posts you might find valuable based on your interests ❤️</h4>
<% @articles.each do |article| %>
<ul>
<li>
<ul>
<% @articles.each do |article| %>
<li style="margin:18px auto">
<a href="https://dev.to<%= article.path %>" style="font-weight: bold;color:#0045ff;font-size:1.06em;"><%= article.title.strip %></a> <%= truncate(article.description, length: 80) %>
</li>
</ul>
<% end %>
</li>
<% end %>
<% if @boosted_article && @user.display_sponsors %>
<h4 style="background:#e3fffc;display:inline-block;margin:0;">And sponsor-boosted</h4>
<li style="margin:18px auto">
<a href="https://dev.to<%= @boosted_article.path %>?booster_org=<%= @boosted_article.organization&.slug || "generic" %>" style="font-weight: bold;color:#0045ff;font-size:1.06em;"><%= @boosted_article.title.strip %></a> <%= truncate(@boosted_article.description, length: 80) %>
</li>
<% end %>
</ul>
<center style="margin-top:50px;">
<em>

View file

@ -0,0 +1,5 @@
class AddEmailDigestEligibleToArticles < ActiveRecord::Migration[5.1]
def change
add_column :articles, :email_digest_eligible, :boolean, default: true
end
end

View file

@ -8,9 +8,9 @@
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues).
#
# It's strongly recommended that you check this file into your version control system.
# It's strongly recommended that you check this file into your version control system. ❤️
ActiveRecord::Schema.define(version: 20180427160903) do
ActiveRecord::Schema.define(version: 20180502160428) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -57,6 +57,7 @@ ActiveRecord::Schema.define(version: 20180427160903) do
t.datetime "crossposted_at"
t.string "description"
t.datetime "edited_at"
t.boolean "email_digest_eligible", default: true
t.datetime "facebook_last_buffered"
t.boolean "featured", default: false
t.integer "featured_number"

View file

@ -26,6 +26,12 @@ RSpec.describe EmailLogic do
h = described_class.new(user).analyze
expect(h.should_receive_email?).to eq(false)
end
it "marks as not ready if there isn't at least 3 email-digest-eligible articles" do
2.times { create(:article, positive_reactions_count: 40) }
2.times { create(:article, positive_reactions_count: 40, email_digest_eligible: false) }
h = described_class.new(user).analyze
expect(h.should_receive_email?).to eq(false)
end
end
context "when a user's open_percentage is low " do