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 6 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
5 changes: 5 additions & 0 deletions app/assets/config/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
//= link_tree ../opensearch .xml
//= link_directory ../stylesheets .css


//= link_tree ../../../vendor/assets/iD/iD/img
//= link_directory ../../../vendor/assets/iD/iD/data .json
//= link_directory ../../../vendor/assets/iD/iD/locales .json
//= link_directory ../../../vendor/assets/iD/iD/mapillary-js .css
//= link_directory ../../../vendor/assets/iD/iD/mapillary-js .js
//= link_directory ../../../vendor/assets/iD/iD/pannellum .js
//= link_directory ../../../vendor/assets/iD/iD/pannellum .css
//= link_directory ../../../vendor/assets/d3 .js
//= link_directory ../../../vendor/assets/cal-heatmap .js
//= link_directory ../../../vendor/assets/cal-heatmap .css


//= link_tree ../../../vendor/assets/leaflet .png

Expand Down
5 changes: 5 additions & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
//= require matomo
//= require richtext
//= require qs/dist/qs
//= require d3
//= require cal-heatmap
//= require calendar-label
//= require popper
//= require tooltip

/*
* Called as the user scrolls/zooms around to manipulate hrefs of the
Expand Down
119 changes: 119 additions & 0 deletions app/assets/javascripts/heatmap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/* global CalHeatmap, CalendarLabel, Tooltip */

document.addEventListener("DOMContentLoaded", () => {
const heatmapElement = document.querySelector("#cal-heatmap");

// Ensure the heatmap element exists in the DOM
if (!heatmapElement) {
console.warn("Heatmap element not found in the DOM.");
return;
}

// Retrieve heatmap data and site color scheme
const heatmapData = heatmapElement.dataset.heatmap ? JSON.parse(heatmapElement.dataset.heatmap) : [];
const colorScheme = heatmapElement.dataset.siteColorScheme || "auto"; // Default to "auto" if not defined

const locale = $("head").data().locale;
const weekdays = getLocalizedWeekdays(locale);
const rangeColors = ["#14432a", "#166b34", "#37a446", "#4dd05a"];
const startDate = new Date(Date.now() - (365 * 24 * 60 * 60 * 1000));

// Determine theme based on color scheme
const theme = getThemeFromColorScheme(colorScheme);

// Render the heatmap
const cal = new CalHeatmap();
cal.paint({
itemSelector: "#cal-heatmap",
theme: theme,
domain: {
type: "month",
gutter: 4,
label: {
text: (timestamp) => getMonthNameFromTranslations(locale, new Date(timestamp).getMonth()),
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: theme === "dark" ? rangeColors : rangeColors.reverse(),
domain: [10, 20, 30, 40]
}
}
}, [
[CalendarLabel, {
position: "left",
key: "left",
text: () => weekdays,
textAlign: "end",
width: 30,
padding: [23, 10, 0, 0]
}],
[Tooltip, {
text: (date, value) => getTooltipText(date, value, locale)
}]
]);
}, { once: true });

function getThemeFromColorScheme(colorScheme) {
if (colorScheme === "auto") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return colorScheme; // Return "light" or "dark" directly if specified
}

function getLocalizedWeekdays(locale) {
const translations = I18n.translations[locale] || I18n.translations.en;
const date = translations && translations.date;
const abbrDayNames = date && date.abbr_day_names;

return (abbrDayNames || []).map((day, index) =>
index % 2 === 0 ? "" : day
);
}

function getMonthNameFromTranslations(locale, monthIndex) {
const translations = I18n.translations[locale] || I18n.translations.en;
const date = translations && translations.date;
const abbrMonthNames = date && date.abbr_month_names;

const months = abbrMonthNames || [];
return months[monthIndex + 1] || "";
}

function getTooltipText(date, value, locale) {
const jsDate = new Date(date);
const translations = I18n.translations[locale] || I18n.translations.en;
const dateTranslations = translations && translations.date;
const monthNames = dateTranslations && dateTranslations.month_names;

const months = monthNames || [];
const monthName = months[jsDate.getMonth() + 1] || `${jsDate.getMonth + 1}.`;
const day = jsDate.getDate();
const year = jsDate.getFullYear();

const localizedDate = `${day}. ${monthName} ${year}.`;
return value > 0 ?
I18n.t("javascripts.heatmap.tooltip.contributions", { count: value, date: localizedDate }) :
I18n.t("javascripts.heatmap.tooltip.no_contributions", { date: localizedDate });
}
3 changes: 3 additions & 0 deletions app/assets/stylesheets/heatmap.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/*
*= require cal-heatmap
*/
17 changes: 17 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ def show
if @user &&
(@user.visible? || current_user&.administrator?)
@title = @user.display_name

one_year_ago = 1.year.ago
@heatmap_data = Rails.cache.fetch("heatmap_data_user_#{@user.id}", :expires_in => 1.day) do
Changeset
.where(:user_id => @user.id)
.where(:created_at => one_year_ago..)
.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
10 changes: 10 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 "heatmap" %>
<%= javascript_include_tag "heatmap" %>
<% end %>
<% content_for :heading do %>
<div class="row">
<div class="col-sm-auto">
Expand Down Expand Up @@ -235,6 +239,12 @@

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

<% if @heatmap_data.present? %>
<div class="overflow-auto" style="max-height: 500px;">
<%= tag.div(:id => "cal-heatmap", :data => { :heatmap => @heatmap_data.to_json, :site_color_scheme => preferred_color_scheme(:site) }) %>
</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
4 changes: 4 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3215,6 +3215,10 @@ en:
show_address: Show address
query_features: Query features
centre_map: Centre map here
heatmap:
tooltip:
contributions: "%{count} contributions on %{date}"
no_contributions: "No contributions on %{date}"
redactions:
edit:
heading: "Edit Redaction"
Expand Down
93 changes: 93 additions & 0 deletions test/controllers/users_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -679,4 +679,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 # Ensure no heatmap is rendered

# 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
1 change: 1 addition & 0 deletions vendor/assets/cal-heatmap/cal-heatmap.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions vendor/assets/cal-heatmap/cal-heatmap.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions vendor/assets/cal-heatmap/calendar-label.js

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions vendor/assets/cal-heatmap/popper.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions vendor/assets/cal-heatmap/tooltip.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions vendor/assets/d3/d3.js

Large diffs are not rendered by default.

Loading