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

Add user profile heatmap visualization for contributions #5402

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions app/assets/javascripts/heatmap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//= require d3/dist/d3
//= require cal-heatmap/dist/cal-heatmap
//= require popper
//= require cal-heatmap/dist/plugins/Tooltip

/* global CalHeatmap, Tooltip */
document.addEventListener("DOMContentLoaded", () => {
const heatmapElement = document.querySelector("#cal-heatmap");

if (!heatmapElement) {
return;
}

const heatmapData = heatmapElement.dataset.heatmap ? JSON.parse(heatmapElement.dataset.heatmap) : [];
const colorScheme = document.documentElement.getAttribute("data-bs-theme") ?? "auto";
const rangeColors = ["#14432a", "#166b34", "#37a446", "#4dd05a"];
const startDate = new Date(Date.now() - (365 * 24 * 60 * 60 * 1000));

const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

let cal = new CalHeatmap();
let currentTheme = getTheme();

function renderHeatmap() {
cal.destroy();
cal = new CalHeatmap();

cal.paint({
itemSelector: "#cal-heatmap",
theme: currentTheme,
domain: {
type: "month",
gutter: 4,
label: {
text: (timestamp) => I18n.strftime(new Date(2000, new Date(timestamp).getMonth(), 15), "%b"),
position: "top",
textAlign: "middle"
},
dynamicDimension: true
},
subDomain: {
type: "ghDay",
radius: 2,
width: 11,
height: 11,
gutter: 4
},
date: {
start: startDate
},
range: 13,
data: {
source: heatmapData,
type: "json",
x: "date",
y: "total_changes"
},
scale: {
color: {
type: "threshold",
range: currentTheme === "dark" ? rangeColors : Array.from(rangeColors).reverse(),
domain: [10, 20, 30, 40]
}
}
}, [
[Tooltip, {
text: (date, value) => getTooltipText(date, value)
}]
]);
}

function getTooltipText(date, value) {
const localizedDate = I18n.l("date.formats.long", date);

if (value > 0) {
return I18n.t("javascripts.heatmap.tooltip.contributions", { count: value, date: localizedDate });
}

return I18n.t("javascripts.heatmap.tooltip.no_contributions", { date: localizedDate });
}

function getTheme() {
if (colorScheme === "auto") {
return mediaQuery.matches ? "dark" : "light";
}

return colorScheme;
}

if (colorScheme === "auto") {
mediaQuery.addEventListener("change", (e) => {
currentTheme = e.matches ? "dark" : "light";
renderHeatmap();
});
}

renderHeatmap();
});


4 changes: 4 additions & 0 deletions app/assets/stylesheets/common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1007,3 +1007,7 @@ img.trace_image {
}
}
}

.heatmap-wrapper {
height: 130px;
}
22 changes: 20 additions & 2 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,27 @@ class UsersController < ApplicationController
def show
@user = User.find_by(:display_name => params[:display_name])

if @user &&
(@user.visible? || current_user&.administrator?)
if @user && (@user.visible? || current_user&.administrator?)
@title = @user.display_name

@heatmap_data = Rails.cache.fetch("heatmap_data_user_#{@user.id}", :expires_in => 1.day) do
one_year_ago = 1.year.ago.beginning_of_day
today = Time.zone.now.end_of_day

Changeset
.where(:user_id => @user.id)
.where(:created_at => one_year_ago..today)
.where(:num_changes => 1..)
.group("date_trunc('day', created_at)")
.select("date_trunc('day', created_at) AS date, SUM(num_changes) AS total_changes")
.order("date")
.map do |changeset|
{
:date => changeset.date.to_date.to_s,
:total_changes => changeset.total_changes.to_i
}
end
end
else
render_unknown_user params[:display_name]
end
Expand Down
27 changes: 27 additions & 0 deletions app/views/users/show.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<% content_for :head do %>
<%= stylesheet_link_tag "cal-heatmap/dist/cal-heatmap" %>
<%= javascript_include_tag "heatmap" %>
<% end %>
<% content_for :heading do %>
<div class="row">
<div class="col-sm-auto">
Expand Down Expand Up @@ -233,6 +237,29 @@

<div class="richtext text-break clearfix"><%= @user.description.to_html %></div>

<% if @heatmap_data.present? %>
<div class="row">
<div class="col overflow-auto">
<div class="heatmap-wrapper d-flex align-items-start">
<!-- Labels -->
<ul class="list-unstyled d-flex flex-column justify-content-between ch-domain-text mb-0 mt-4">
<li>&nbsp;</li>
<li><%= t("date.abbr_day_names")[1] %></li>
<li>&nbsp;</li>
<li><%= t("date.abbr_day_names")[3] %></li>
<li>&nbsp;</li>
<li><%= t("date.abbr_day_names")[5] %></li>
<li>&nbsp;</li>
</ul>
<!-- Heatmap -->
<div id="cal-heatmap" class="ms-2"
data-heatmap="<%= @heatmap_data.to_json %>">
</div>
</div>
</div>
</div>
<% end %>

<% if current_user and @user.id == current_user.id %>
<div class="my-3">
<%= link_to t(".edit_profile"), edit_profile_path, :class => "btn btn-outline-primary" %>
Expand Down
6 changes: 6 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3329,6 +3329,12 @@ en:
home:
marker_title: My home location
not_set: Home location is not set for your account
heatmap:
tooltip:
no_contributions: "No contributions on %{date}"
contributions:
one: "%{count} contribution on %{date}"
other: "%{count} contributions on %{date}"
redactions:
edit:
heading: "Edit Redaction"
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@
"name": "openstreetmap",
"private": true,
"dependencies": {
"cal-heatmap": "^4.2.4",
"jquery-simulate": "^1.0.2",
"js-cookie": "^3.0.0",
"leaflet": "^1.8.0",
"leaflet.locatecontrol": "^0.83.0",
"osm-community-index": "^5.2.0"
},
"devDependencies": {
"@stylistic/eslint-plugin-js": "^4.0.0",
"@types/jquery": "^3.5.0",
"@types/leaflet": "^1.9.0",
"eslint": "^9.0.0",
"eslint-plugin-erb": "^2.1.0",
"@stylistic/eslint-plugin-js": "^4.0.0",
"eslint-formatter-compact": "^8.40.0"
"eslint-formatter-compact": "^8.40.0",
"eslint-plugin-erb": "^2.1.0"
}
}
93 changes: 93 additions & 0 deletions test/controllers/users_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -329,4 +329,97 @@ def test_auth_failure_callback
get auth_failure_path, :params => { :origin => "http://www.google.com" }
assert_redirected_to login_path
end

def test_show_heatmap_data
user = create(:user)
# Create two changesets
create(:changeset, :user => user, :created_at => 6.months.ago, :num_changes => 10)
create(:changeset, :user => user, :created_at => 3.months.ago, :num_changes => 20)

get user_path(user.display_name)
assert_response :success
# The data should not be empty
assert_not_nil assigns(:heatmap_data)

heatmap_data = assigns(:heatmap_data)
# The data should be in the right format
assert(heatmap_data.all? { |entry| entry[:date] && entry[:total_changes] }, "Heatmap data should have :date and :total_changes keys")
end

def test_show_heatmap_data_caching
# Enable caching to be able to test
Rails.cache.clear
@original_cache_store = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new

user = create(:user)

# Create an initial changeset
create(:changeset, :user => user, :created_at => 6.months.ago, :num_changes => 15)

# First request to populate the cache
get user_path(user.display_name)
first_response_data = assigns(:heatmap_data)
assert_not_nil first_response_data, "Expected heatmap data to be assigned on the first request"
assert_equal 1, first_response_data.size, "Expected one entry in the heatmap data"

# Inspect cache after the first request
cached_data = Rails.cache.read("heatmap_data_user_#{user.id}")
assert_equal first_response_data, cached_data, "Expected the cache to contain the first response data"

# Add a new changeset to the database
create(:changeset, :user => user, :created_at => 3.months.ago, :num_changes => 20)

# Second request
get user_path(user.display_name)
second_response_data = assigns(:heatmap_data)

# Confirm that the cache is still being used
assert_equal first_response_data, second_response_data, "Expected cached data to be returned on the second request"

# Clear the cache and make a third request to confirm new data is retrieved
Rails.cache.clear
get user_path(user.display_name)
third_response_data = assigns(:heatmap_data)

# Ensure the new entry is now included
assert_equal 2, third_response_data.size, "Expected two entries in the heatmap data after clearing the cache"

# Reset caching config to defaults
Rails.cache.clear
Rails.cache = @original_cache_store
end

def test_show_heatmap_data_no_changesets
user = create(:user)

get user_path(user.display_name)
assert_response :success
# There should be no entries in heatmap data
assert_empty assigns(:heatmap_data)
end

def test_heatmap_rendering
# Test user with no changesets
user_without_changesets = create(:user)
get user_path(user_without_changesets)
assert_response :success
assert_select "div#cal-heatmap", 0

# Test user with changesets
user_with_changesets = create(:user)
create(:changeset, :user => user_with_changesets, :created_at => 3.months.ago.beginning_of_day, :num_changes => 42)
create(:changeset, :user => user_with_changesets, :created_at => 4.months.ago.beginning_of_day, :num_changes => 39)
get user_path(user_with_changesets)
assert_response :success
assert_select "div#cal-heatmap[data-heatmap]" do |elements|
# Check the data-heatmap attribute is present and contains expected JSON
heatmap_data = JSON.parse(elements.first["data-heatmap"])
expected_data = [
{ "date" => 4.months.ago.to_date.to_s, "total_changes" => 39 },
{ "date" => 3.months.ago.to_date.to_s, "total_changes" => 42 }
]
assert_equal expected_data, heatmap_data
end
end
end
Loading
Loading