Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"Recommended" search result sorting #859

Merged
merged 19 commits into from Apr 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -4,6 +4,7 @@

### April

* April 7 - Launch recommended sorting option #859
* April 5 - Distinguish between new profiles and recently updated ones #856
* April 4 - Remove `developers.available_on` #854

Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
@@ -1,5 +1,10 @@
<h2 class="text-sm font-medium text-gray-500"><%= t(".title") %></h2>

<dl class="flex items-baseline space-x-1">
<dd class="text-2xl font-semibold tracking-tight text-gray-900"><%= developer.search_score %></dd>
<dt class="truncate text-sm font-medium text-gray-500">search score</dt>
</dl>

<span class="relative z-0 inline-flex shadow-sm rounded-md">
<%= tag.div data: {controller: "clipboard", clipboard_visibility_class: "hidden", clipboard_content_value: "#{developer.hero} #{developer_url(developer)}", clipboard_html_content_value: link_to(developer.hero, developer_url(developer))} do %>
<button data-action="clipboard#copy clipboard#toggle" type="button" class="relative inline-flex items-center px-4 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-gray-500 focus:border-gray-500">
@@ -1,11 +1,15 @@
<div data-controller="toggle accessibility" data-toggle-visibility-class="hidden" id="sort" class="relative">
<span class="mr-2">
<%= render BadgeComponent.new("New!", color: :purple) %>
</span>

<button data-accessibility-target="button" type="button" class="group inline-flex justify-center text-sm font-medium text-gray-700 hover:text-gray-900" data-action="toggle#toggle accessibility#toggleAriaExpanded" aria-expanded="false" aria-haspopup="true">
<%= t(".sort.title") %>
<%= inline_svg_tag "icons/solid/chevron_down.svg", class: "flex-shrink-0 -mr-1 ml-1 h-5 w-5 text-gray-400 group-hover:text-gray-500", aria_hidden: true %>
</button>

<div data-toggle-target="element" class="hidden origin-top-right absolute right-0 mt-2 w-40 rounded-md shadow-2xl bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10" role="menu" aria-orientation="vertical" aria-labelledby="mobile-menu-button" tabindex="-1">
<%= render SortButtonComponent.new(title: t(".sort.newest"), name: :sort, value: :newest, active: sort == :newest, form_id: form_id, scope: scope) %>
<%= render SortButtonComponent.new(title: t(".sort.availability"), name: :sort, value: :availability, active: sort == :availability, form_id: form_id, scope: scope) %>
<%= render SortButtonComponent.new(title: t(".sort.recommended"), name: :sort, value: :recommended, active: sort == :recommended, form_id: form_id, scope: scope) %>
</div>
</div>
@@ -4,10 +4,6 @@ class QuerySortButtonComponent < ApplicationComponent

delegate :sort, :search_query, to: :query

def render?
Feature.enabled?(:sort)
end

def initialize(query:, user:, form_id:, scope: nil)
@query = query
@user = user
@@ -3,7 +3,7 @@ class DevelopersController < ApplicationController
before_action :require_new_developer!, only: %i[new create]

def index
@developers_count = SignificantFigure.new(Developer.visible.count).rounded
@developers_count = SignificantFigure.new(Developer.actively_looking_or_open.count).rounded
@query = DeveloperQuery.new(permitted_attributes([:developers, :query]).merge(user: current_user))
@meta = Developers::Meta.new(query: @query, count: @developers_count)
Analytics::SearchQuery.create!(permitted_attributes([:developers, :query]))
@@ -0,0 +1,4 @@
class RecommendedSortingsController < ApplicationController
def show
end
end
@@ -4,12 +4,7 @@ class UpdateDeveloperResponseRateJob < ApplicationJob
def perform(developer)
conversations = eligable_conversations(developer)
replied_rate = Stats::Conversation.new(conversations).replied_rate
# TODO: (Fig) - We are using update_column to avoid changing the updated_at
# timestamp on the developer record which would trigger the rendering of a
# 'Recently Active' badge on the developer profile.
# This should be changed to update! once we have implemented cacheing of the
# badges in their own model.
developer.update_column(:response_rate, (replied_rate * 100).round)
developer.update!(response_rate: (replied_rate * 100).round)
end

private
@@ -0,0 +1,67 @@
# Recommended sorting alorithm for developer profiles in search results.
# v1.0 - last updated April 6, 2023
#
# | Property | Effect on score |
# | ------------------------- | --------------- |
# | Add a scheduling link | medium boost |
# | Source contributors | medium boost |
# | Profile added last 7 days | large boost |
# | Bio < 50 characters | medium demotion |
# | Bio > 500 characters | small boost |
# | Updated > 6 months ago | small demotion |
# | Updated 3-6 months ago | small boost |
# | Updated within last month | medium boost |
# | ≥ 90% response rate | medium boost |
# | ≤ 10% response rate | medium demotion |
#
module Developers::SearchScore
extend ActiveSupport::Concern

included do
small, medium, large = 10, 20, 30

scores :scheduling_link?, by: small, if: :present?
scores :source_contributor?, by: medium, if: :present?
scores :recently_added?, by: large, if: :present?

scores :bio, by: -medium, if: -> { _1.length < 50 if _1 }
scores :bio, by: small, if: -> { _1.length > 500 if _1 }

scores :profile_updated_at, by: -small, if: -> { _1&.before? 6.months.ago }
scores :profile_updated_at, by: small, if: -> { _1 && (3.months.ago..1.month.ago).cover?(_1) }
scores :profile_updated_at, by: medium, if: -> { _1&.after? 1.month.ago }

scores :response_rate, by: medium, if: -> { conversations? && _1 >= HasBadges::HIGH_RESPONSE_RATE_CUTTOFF }
scores :response_rate, by: -medium, if: -> { conversations? && _1 <= HasBadges::LOW_RESPONSE_RATE_CUTTOFF }

after_create_commit :update_search_score
before_update :update_search_score
end

class_methods do
attr_reader :scorings

def scores(attribute, by:, **options)
@scorings ||= Hash.new { |h, k| h[k] = [] }
@scorings[attribute] << -> { by if instance_exec(public_send(attribute), &options.fetch(:if)) }
end
end

MAX_SCORE = 110

def update_search_score
score = score_for(*self.class.scorings.keys)
normalized_score = score.fdiv(MAX_SCORE) * 100
self.search_score = normalized_score.round
end

def score_for(*attributes)
self.class.scorings.fetch_values(*attributes).flatten(1).filter_map { instance_exec(&_1) }.sum
end

private

def conversations?
conversations_count.positive?
end
end
@@ -11,6 +11,7 @@ module HasBadges

RECENT_CHANGES_LENGTH = 1.week
HIGH_RESPONSE_RATE_CUTTOFF = 90
LOW_RESPONSE_RATE_CUTTOFF = 50

included do
scope :high_response_rate, -> { where("response_rate >= ?", HIGH_RESPONSE_RATE_CUTTOFF) }
@@ -1,7 +1,7 @@
class Conversation < ApplicationRecord
has_secure_token :inbound_email_token

belongs_to :developer
belongs_to :developer, counter_cache: true
belongs_to :business
belongs_to :user_with_unread_messages, class_name: :User, inverse_of: :unread_conversations, optional: true

@@ -4,6 +4,7 @@ class Developer < ApplicationRecord
include Developers::Notifications
include Developers::PublicChanges
include Developers::RichText
include Developers::SearchScore
include HasBadges
include HasSpecialties
include Hashid::Rails
@@ -70,6 +71,7 @@ class Developer < ApplicationRecord
scope :actively_looking_or_open, -> { where(search_status: [:actively_looking, :open, nil]) }
scope :featured, -> { where("featured_at >= ?", FEATURE_LENGTH.ago).order(featured_at: :desc) }
scope :newest_first, -> { order(created_at: :desc) }
scope :by_score, -> { order(search_score: :desc, created_at: :asc) }
scope :product_announcement_notifications, -> { where(product_announcement_notifications: true) }
scope :profile_reminder_notifications, -> { where(profile_reminder_notifications: true) }
scope :visible, -> { where.not(search_status: :invisible).or(where(search_status: nil)) }
@@ -11,8 +11,6 @@ def self.enabled?(feature_name)
true
when :developer_specialties
!Rails.env.production?
when :sort
!Rails.env.production?
else
raise "Unknown feature name: #{feature_name}"
end
@@ -41,7 +41,7 @@ def featured_records
end

def sort
@sort.to_s.downcase.to_sym == :availability ? :availability : :newest
@sort.to_s.downcase.to_sym == :recommended ? :recommended : :newest
end

def countries
@@ -128,7 +128,11 @@ def specialty_filter_records
end

def sort_records
@_records.merge!(Developer.newest_first)
if sort == :recommended
@_records.merge!(Developer.by_score)
else
@_records.merge!(Developer.newest_first)
end
end

def country_filter_records
@@ -0,0 +1,16 @@
<div class="flex items-center bg-gray-900 px-6 py-2.5 sm:px-3.5 sm:before:flex-1">
<p class="text-sm leading-6 text-white">
<%= t(".title") %>
<%= link_to recommended_sorting_path, class: "whitespace-nowrap font-semibold" do %>
<%= t(".cta") %>
<span aria-hidden="true">&rarr;</span>
<% end %>
</p>

<div class="flex flex-1 justify-end">
<%= button_tag form: form_id, scope: form_id, name: :sort, value: :newest, type: :submit, class: "-m-3 p-3 focus-visible:outline-offset-[-4px]" do %>
<span class="sr-only"><%= t(".dismiss") %></span>
<%= inline_svg_tag "icons/outline/x.svg", class: "h-4 w-4 text-white", aria_hidden: true %>
<% end %>
</div>
</div>
@@ -43,6 +43,10 @@

<!-- Paginated list of developers -->
<div class="col-span-12 md:col-span-9 border-l">
<% if @query.sort == :recommended %>
<%= render "recommended_sort_banner", form_id: "developer-filters-desktop" %>
<% end %>

<%= render Developers::CountComponent.new(count: @query.pagy.count, total: @developers_count) %>

<% if @query.records.any? %>
@@ -0,0 +1,93 @@
<%= open_graph_tags title: "Higher quality search results on RailsDevs", description: "It's now easier than ever to hire a Rails developer on RailsDevs. A new approach to search results promises to bring higher quality profiles to the top. Read on for how it works and how developers can improve their search rank.", image: image_url("opengraph/recommended.png") %>

<div class="bg-white px-6 py-8 md:py-16 lg:px-8">
<div class="mx-auto max-w-3xl text-base leading-7 text-gray-700">
<p class="text-base font-semibold leading-7 text-indigo-600">Announcing</p>
<h1 class="mt-2 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">Higher quality search results on RailsDevs</h1>
<p class="mt-6 text-xl leading-8">It's now easier than ever to hire a Rails developer on RailsDevs. A new approach to search results promises to bring higher quality profiles to the top. Read on for how it works and how developers can improve their search rank.</p>

<div class="mt-10 max-w-2xl">
<p class="mt-8">Previously, new developer profiles appeared first. With no way to effect their rank, a developer's profile would be continuously pushed farther and farther down results. A great candidate could be buried on page 5!</p>
<p class="mt-6">Now, sorting by "recommended" surfaces higher quality profiles to the top based on:</p>

<ul role="list" class="mt-8 space-y-6 text-gray-600">
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/check_circle.svg", class: "mt-1 h-5 w-5 flex-none text-indigo-600", aria_hidden: true %>
<span><strong class="font-semibold text-gray-900">Responsiveness.</strong> How frequently a developer responds to conversations.</span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/check_circle.svg", class: "mt-1 h-5 w-5 flex-none text-indigo-600", aria_hidden: true %>
<span><strong class="font-semibold text-gray-900">Frequency.</strong> How often a developer’s profile is updated.</span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/check_circle.svg", class: "mt-1 h-5 w-5 flex-none text-indigo-600", aria_hidden: true %>
<span><strong class="font-semibold text-gray-900">Completeness.</strong> How much of a developer’s profile is filled out.</span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/check_circle.svg", class: "mt-1 h-5 w-5 flex-none text-indigo-600", aria_hidden: true %>
<span><strong class="font-semibold text-gray-900">Contributors.</strong> Developers contributing to the <%= link_to "source code", "https://github.com/joemasilotti/railsdevs.com", class: "font-semibold underline text-gray-800" %> of RailsDevs.</span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/check_circle.svg", class: "mt-1 h-5 w-5 flex-none text-indigo-600", aria_hidden: true %>
<span>And more (see below for a complete breakdown).</span>
</li>
</ul>

<p class="mt-8">You can try the new system today by selecting <%= link_to "Recommended from the Sort dropdown", developers_path(sort: :recommended), class: "font-semibold underline text-gray-800" %>.</p>
</div>

<%= image_tag "recommended.png", class: "aspect-video rounded-xl bg-gray-50 object-cover mt-8", alt: "Sort Ruby on Rails developers by recommended on RailsDevs." %>

<div class="mt-16 max-w-2xl">
<h2 class="mt-16 text-2xl font-bold tracking-tight text-gray-900">How can developers increase their rank?</h2>
<p class="mt-8">Developers have direct control over each criteria that contributes to their rank and can increase their rank by:</p>

<ul role="list" class="mt-8 space-y-6 text-gray-600">
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/plus_circle.svg", class: "mt-1 h-5 w-5 flex-none text-green-600", aria_hidden: true %>
<span>Responding to 90% of conversations &rarr; <strong class="font-semibold text-gray-900">20 points</strong></span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/plus_circle.svg", class: "mt-1 h-5 w-5 flex-none text-green-600", aria_hidden: true %>
<span>Contributing to the <%= link_to "RailsDevs source code", "https://github.com/joemasilotti/railsdevs.com", class: "font-medium underline" %> &rarr; <strong class="font-semibold text-gray-900">20 points</strong></span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/plus_circle.svg", class: "mt-1 h-5 w-5 flex-none text-green-600", aria_hidden: true %>
<span>Adding a scheduling link &rarr; <strong class="font-semibold text-gray-900">20 points</strong></span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/plus_circle.svg", class: "mt-1 h-5 w-5 flex-none text-green-600", aria_hidden: true %>
<span>Updating your profile every month &rarr; <strong class="font-semibold text-gray-900">20 points</strong></span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/plus_circle.svg", class: "mt-1 h-5 w-5 flex-none text-green-600", aria_hidden: true %>
<span>Having a bio with more than 500 characters &rarr; <strong class="font-semibold text-gray-900">10 points</strong></span>
</li>
</ul>

<p class="mt-8">New developers to the platform will also receive a healthy <strong class="font-semibold text-gray-900">30 point boost</strong> for 7 days.</p>
<p class="mt-6">Note that there is also behavior that can indicate a developer is no longer job searching or hasn’t put much effort into their profile. Here are ways a developer’s rank could decrease.</p>

<ul role="list" class="mt-8 space-y-6 text-gray-600">
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/minus_circle.svg", class: "mt-1 h-5 w-5 flex-none text-red-600", aria_hidden: true %>
<span>Responding to fewer than 50% of conversations &rarr; <strong class="font-semibold text-gray-900">20 point deduction</strong></span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/minus_circle.svg", class: "mt-1 h-5 w-5 flex-none text-red-600", aria_hidden: true %>
<span>Having a bio with less than 50 characters &rarr; <strong class="font-semibold text-gray-900">20 point deduction</strong></span>
</li>
<li class="flex gap-x-3">
<%= inline_svg_tag "icons/solid/minus_circle.svg", class: "mt-1 h-5 w-5 flex-none text-red-600", aria_hidden: true %>
<span>Not updating your profile in 6+ months &rarr; <strong class="font-semibold text-gray-900">10 points</strong></span>
</li>
</ul>
</div>

<div class="mt-16 max-w-2xl">
<h2 class="mt-16 text-2xl font-bold tracking-tight text-gray-900">Feedback</h2>
<p class="mt-8">Note that this new system is still in beta! And I’d love to hear your feedback. Please <%= link_to "send me an email", "mailto:#{Rails.application.config.emails.support!}", class: "font-semibold underline text-gray-800" %> if you have ideas on how it could be improved or something looks weird.</p>
<p class="mt-6">For transparency, the code powering the system is <%= link_to "available on GitHub", "https://github.com/joemasilotti/railsdevs.com/blob/main/app/models/concerns/developers/search_score.rb", class: "font-semibold underline text-gray-800" %>.</p>
</div>
</div>
</div>
@@ -509,9 +509,13 @@ en:
title: Work preference
query_sort_button_component:
sort:
availability: Availability
newest: Newest
recommended: Recommended
title: Sort
recommended_sort_banner:
cta: Read more
dismiss: Dismiss
title: Your sorting by recommended, which is still in beta.
search_status:
actively_looking: Actively looking for work
actively_looking_tag: Actively looking
@@ -366,7 +366,6 @@ fr:
title: Préférence de travail
query_sort_button_component:
sort:
availability: Disponibilité
newest: Le plus récent
title: Trier
search_status:
@@ -247,7 +247,6 @@ zh-CN:
title: 工作偏好
query_sort_button_component:
sort:
availability: 有空与否
newest: 最新的
title: 排序
search_status:
@@ -7,11 +7,12 @@
}

resource :about, only: :show, controller: :about
resources :affiliates, only: %w[index create new], controller: "affiliates/registrations"
resource :conduct, only: :show
resource :home, only: :show
resource :pricing, only: :show, controller: :pricing
resource :recommended_sorting, only: :show
resource :role, only: :new
resources :affiliates, only: %w[index create new], controller: "affiliates/registrations"

resources :businesses, except: :destroy

@@ -0,0 +1,5 @@
class AddSearchScoreToDevelopers < ActiveRecord::Migration[7.0]
def change
add_column :developers, :search_score, :integer, null: false, default: 0
end
end
@@ -0,0 +1,7 @@
class AddCounterCacheToDeveloperConversations < ActiveRecord::Migration[7.0]
def change
add_column :developers, :conversations_count, :integer, null: false, default: 0

Developer.find_each { |d| Developer.reset_counters(d.id, :conversations) }
end
end