diff --git a/CHANGELOG b/CHANGELOG
index 999b21f758a..8914068570e 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -29,7 +29,7 @@ v 7.8.0
-
-
-
- -
+ - Add a commit calendar to the user profile (Hannes Rosenögger)
-
-
-
diff --git a/Gemfile b/Gemfile
index 96a1097d6d8..6e4e20f5e1f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -154,6 +154,9 @@ gem "slack-notifier", "~> 1.0.0"
# d3
gem "d3_rails", "~> 3.1.4"
+#cal-heatmap
+gem "cal-heatmap-rails", "~> 0.0.1"
+
# underscore-rails
gem "underscore-rails", "~> 1.4.4"
diff --git a/Gemfile.lock b/Gemfile.lock
index 18fae9b7001..5c70541a735 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -52,6 +52,7 @@ GEM
sass (~> 3.2)
browser (0.7.2)
builder (3.2.2)
+ cal-heatmap-rails (0.0.1)
capybara (2.2.1)
mime-types (>= 1.16)
nokogiri (>= 1.3.3)
@@ -627,6 +628,7 @@ DEPENDENCIES
binding_of_caller
bootstrap-sass (~> 3.0)
browser
+ cal-heatmap-rails (~> 0.0.1)
capybara (~> 2.2.1)
carrierwave
coffee-rails
diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee
index 337170605dc..4912c534b0e 100644
--- a/app/assets/javascripts/application.js.coffee
+++ b/app/assets/javascripts/application.js.coffee
@@ -39,6 +39,7 @@
#= require shortcuts_dashboard_navigation
#= require shortcuts_issueable
#= require shortcuts_network
+#= require cal-heatmap
#= require_tree .
window.slugify = (text) ->
diff --git a/app/assets/javascripts/calendar.js.coffee b/app/assets/javascripts/calendar.js.coffee
new file mode 100644
index 00000000000..e3bb420a278
--- /dev/null
+++ b/app/assets/javascripts/calendar.js.coffee
@@ -0,0 +1,71 @@
+class @calendar
+ options =
+ month: "short"
+ day: "numeric"
+ year: "numeric"
+
+ constructor: (timestamps,starting_year,starting_month,activities_path) ->
+ cal = new CalHeatMap()
+ cal.init
+ itemName: ["commit"]
+ data: timestamps
+ start: new Date(starting_year, starting_month)
+ domainLabelFormat: "%b"
+ id: "cal-heatmap"
+ domain: "month"
+ subDomain: "day"
+ range: 12
+ tooltip: true
+ domainDynamicDimension: false
+ colLimit: 4
+ label:
+ position: "top"
+ domainMargin: 1
+ legend: [
+ 0
+ 1
+ 4
+ 7
+ ]
+ legendCellPadding: 3
+ onClick: (date, count) ->
+ $.ajax
+ url: activities_path
+ data:
+ date: date
+
+ dataType: "json"
+ success: (data) ->
+ $("#loading_commits").fadeIn()
+ calendar.calendarOnClick data, date, count
+ setTimeout (->
+ $("#calendar_onclick_placeholder").fadeIn 500
+ return
+ ), 400
+ setTimeout (->
+ $("#loading_commits").hide()
+ return
+ ), 400
+ return
+ return
+ return
+
+ @calendarOnClick: (data, date, nb)->
+ $("#calendar_onclick_placeholder").hide()
+ $("#calendar_onclick_placeholder").html ->
+ "" +
+ ((if nb is null then "no" else nb)) +
+ " commit" +
+ ((if (nb isnt 1) then "s" else "")) + " " +
+ date.toLocaleDateString("en-US", options) +
+ "
"
+ $.each data, (key, data) ->
+ $.each data, (index, data) ->
+ $("#calendar_onclick_placeholder").append ->
+ "Pushed " + ((if data is null then "no" else data)) + " commit" +
+ ((if (data isnt 1) then "s" else "")) +
+ " to " +
+ index + "
"
+ return
+ return
+ return
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 3cf08782c3c..8f63a7fee64 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -8,6 +8,7 @@
*= require select2
*= require_self
*= require dropzone/basic
+ *= require cal-heatmap
*/
@import "main/*";
diff --git a/app/assets/stylesheets/generic/calendar.scss b/app/assets/stylesheets/generic/calendar.scss
new file mode 100644
index 00000000000..9483b26164e
--- /dev/null
+++ b/app/assets/stylesheets/generic/calendar.scss
@@ -0,0 +1,95 @@
+.calendar_onclick_placeholder {
+ padding: 0 0 2px 0;
+}
+
+.calendar_commit_activity {
+ padding: 5px 0 0;
+}
+
+.calendar_onclick_second {
+ font-size: 14px;
+ display: block;
+}
+
+.calendar_onclick_hr {
+ padding: 0;
+ margin: 10px 0;
+}
+
+.calendar_commit_date {
+ color: #999;
+}
+
+.calendar_activity_summary {
+ font-size: 14px;
+}
+
+/**
+* This overwrites the default values of the cal-heatmap gem
+*/
+.calendar {
+ .qi {
+ background-color: #999;
+ fill: #fff;
+ }
+
+ .q1 {
+ background-color: #dae289;
+ fill: #ededed;
+ }
+
+ .q2 {
+ background-color: #cedb9c;
+ fill: #ACD5F2;
+ }
+
+ .q3 {
+ background-color: #b5cf6b;
+ fill: #7FA8D1;
+ }
+
+ .q4 {
+ background-color: #637939;
+ fill: #49729B;
+ }
+
+ .q5 {
+ background-color: #3b6427;
+ fill: #254E77;
+ }
+
+ .domain-background {
+ fill: none;
+ shape-rendering: crispedges;
+ }
+
+ .ch-tooltip {
+ position: absolute;
+ display: none;
+ margin-top: 22px;
+ margin-left: 1px;
+ font-size: 13px;
+ padding: 3px;
+ font-weight: 550;
+ background-color: #222;
+ span {
+ position: absolute;
+ width: 200px;
+ text-align: center;
+ visibility: hidden;
+ border-radius: 10px;
+ &:after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ margin-left: -8px;
+ width: 0;
+ height: 0;
+ border-top: 8px solid #000000;
+ border-right: 8px solid transparent;
+ border-left: 8px solid transparent;
+ }
+ }
+ }
+}
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 67af1801bda..a5e80f7e008 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,5 +1,5 @@
class UsersController < ApplicationController
- skip_before_filter :authenticate_user!, only: [:show]
+ skip_before_filter :authenticate_user!, only: [:show, :activities]
layout :determine_layout
def show
@@ -10,7 +10,8 @@ class UsersController < ApplicationController
end
# Projects user can view
- authorized_projects_ids = ProjectsFinder.new.execute(current_user).pluck(:id)
+ visible_projects = ProjectsFinder.new.execute(current_user)
+ authorized_projects_ids = visible_projects.pluck(:id)
@projects = @user.personal_projects.
where(id: authorized_projects_ids)
@@ -24,12 +25,32 @@ class UsersController < ApplicationController
@title = @user.name
+ user_repositories = visible_projects.map(&:repository)
+ @timestamps = Gitlab::CommitsCalendar.create_timestamp(user_repositories,
+ @user, false)
+ @starting_year = Gitlab::CommitsCalendar.starting_year(@timestamps)
+ @starting_month = Gitlab::CommitsCalendar.starting_month(@timestamps)
+ @last_commit_date = Gitlab::CommitsCalendar.last_commit_date(@timestamps)
+
respond_to do |format|
format.html
format.atom { render layout: false }
end
end
+ def activities
+ user = User.find_by_username!(params[:username])
+ # Projects user can view
+ visible_projects = ProjectsFinder.new.execute(current_user)
+
+ user_repositories = visible_projects.map(&:repository)
+ user_activities = Gitlab::CommitsCalendar.create_timestamp(user_repositories,
+ user, true)
+ user_activities = Gitlab::CommitsCalendar.commit_activity_match(
+ user_activities, params[:date])
+ render json: user_activities.to_json
+ end
+
def determine_layout
if current_user
'navless'
diff --git a/app/models/repository.rb b/app/models/repository.rb
index e93c76790c7..e44ecca865c 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -139,21 +139,46 @@ class Repository
def graph_log
Rails.cache.fetch(cache_key(:graph_log)) do
- commits = raw_repository.log(limit: 6000, skip_merges: true,
- ref: root_ref)
- commits.map do |rugged_commit|
- commit = Gitlab::Git::Commit.new(rugged_commit)
+ # handle empty repos that don't have a root_ref set yet
+ unless raw_repository.root_ref.present?
+ raw_repository.root_ref = 'refs/heads/master'
+ end
+
+ commits = raw_repository.log(limit: 6000, skip_merges: true,
+ ref: raw_repository.root_ref)
+
+ commits.map do |rugged_commit|
+
+ commit = Gitlab::Git::Commit.new(rugged_commit)
{
author_name: commit.author_name.force_encoding('UTF-8'),
author_email: commit.author_email.force_encoding('UTF-8'),
additions: commit.stats.additions,
- deletions: commit.stats.deletions
+ deletions: commit.stats.deletions,
+ date: commit.committed_date
}
end
end
end
+ def graph_logs_by_user_email(user)
+ graph_log.select { |u_email| u_email[:author_email] == user.email }
+ end
+
+ def timestamps_by_user_from_graph_log(user)
+ graph_logs_by_user_email(user).map { |graph_log| graph_log[:date].to_time.to_i }
+ end
+
+ def commits_log_of_user_by_date(user)
+ timestamps_by_user_from_graph_log(user).
+ group_by { |commit_date| commit_date }.
+ inject({}) do |hash, (timestamp_date, commits)|
+ hash[timestamp_date] = commits.count
+ hash
+ end
+ end
+
def cache_key(type)
"#{type}:#{path_with_namespace}"
end
diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml
index 61383315373..c7976ba564f 100644
--- a/app/views/events/_event.html.haml
+++ b/app/views/events/_event.html.haml
@@ -11,5 +11,4 @@
- elsif event.note?
= render "events/event/note", event: event
- else
- = render "events/event/common", event: event
-
+ = render "events/event/common", event: event
\ No newline at end of file
diff --git a/app/views/users/_calendar.html.haml b/app/views/users/_calendar.html.haml
new file mode 100644
index 00000000000..70d5cca854d
--- /dev/null
+++ b/app/views/users/_calendar.html.haml
@@ -0,0 +1,9 @@
+#cal-heatmap.calendar
+ :javascript
+ new calendar(
+ #{@timestamps.to_json},
+ #{@starting_year},
+ #{@starting_month},
+ '#{user_activities_path}'
+ );
+= render "calendar_onclick"
diff --git a/app/views/users/_calendar_onclick.html.haml b/app/views/users/_calendar_onclick.html.haml
new file mode 100644
index 00000000000..1514b56bb23
--- /dev/null
+++ b/app/views/users/_calendar_onclick.html.haml
@@ -0,0 +1,25 @@
+#calendar_commit_activity.calendar_commit_activity
+ %h4.activity_title Commit Activity:
+
+ #loading_commits
+ %section.text-center
+ %h3
+ %i.icon-spinner.icon-spin
+
+ #calendar_onclick_placeholder.calendar_onclick_placeholder
+ %span.calendar_onclick_second.calendar_onclick_second
+ - if @timestamps.empty?
+ %span.calendar_activity_summary
+ %strong> #{@user.username}
+ has no activity
+ - else
+ %span.calendar_activity_summary
+ %strong> #{@user.username}
+ 's last commit was on
+ %span.commit_date #{@last_commit_date}
+
+ %hr.calendar_onclick_hr
+
+:javascript
+ $("#loading_commits").hide();
+
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 54f2666ce5d..0d214d31607 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -18,6 +18,8 @@
%h4 Groups:
= render 'groups', groups: @groups
%hr
+ %h4 Calendar:
+ = render 'calendar'
%h4
User Activity:
diff --git a/config/routes.rb b/config/routes.rb
index f29b620e079..5d61de29b9a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -157,6 +157,10 @@ Gitlab::Application.routes.draw do
end
end
+ # route for commits used by the cal-heatmap
+ get 'u/:username/activities' => 'users#activities', as: :user_activities,
+ constraints: { username: /(?:[^.]|\.(?!atom$))+/, format: /atom/ },
+ via: :get
get '/u/:username' => 'users#show', as: :user,
constraints: { username: /(?:[^.]|\.(?!atom$))+/, format: /atom/ }
diff --git a/lib/gitlab/commits_calendar.rb b/lib/gitlab/commits_calendar.rb
new file mode 100644
index 00000000000..a862e67a598
--- /dev/null
+++ b/lib/gitlab/commits_calendar.rb
@@ -0,0 +1,79 @@
+module Gitlab
+ class CommitsCalendar
+ def self.create_timestamp(repositories, user, show_activity)
+ timestamps = {}
+ repositories.each do |raw_repository|
+ if raw_repository.exists?
+ commits_log = raw_repository.commits_log_of_user_by_date(user)
+
+ populated_timestamps =
+ if show_activity
+ populate_timestamps_by_project(
+ commits_log,
+ timestamps,
+ raw_repository
+ )
+ else
+ populate_timestamps(commits_log, timestamps)
+ end
+ timestamps.merge!(populated_timestamps)
+ end
+ end
+ timestamps
+ end
+
+ def self.populate_timestamps(commits_log, timestamps)
+ commits_log.each do |timestamp_date, commits_count|
+ hash = { "#{timestamp_date}" => commits_count }
+ if timestamps.has_key?("#{timestamp_date}")
+ timestamps.merge!(hash) do |timestamp_date, commits_count,
+ new_commits_count| commits_count = commits_count.to_i +
+ new_commits_count
+ end
+ else
+ timestamps.merge!(hash)
+ end
+ end
+ timestamps
+ end
+
+ def self.populate_timestamps_by_project(commits_log, timestamps,
+ project)
+ commits_log.each do |timestamp_date, commits_count|
+ if timestamps.has_key?("#{timestamp_date}")
+ timestamps["#{timestamp_date}"].
+ merge!(project.path_with_namespace => commits_count)
+ else
+ hash = { "#{timestamp_date}" => { project.path_with_namespace =>
+ commits_count } }
+ timestamps.merge!(hash)
+ end
+ end
+ timestamps
+ end
+
+ def self.latest_commit_date(timestamps)
+ if timestamps.nil? || timestamps.empty?
+ DateTime.now.to_date
+ else
+ Time.at(timestamps.keys.first.to_i).to_date
+ end
+ end
+
+ def self.starting_year(timestamps)
+ DateTime.now.to_date - 1
+ end
+
+ def self.starting_month(timestamps)
+ Date.today.strftime("%m").to_i
+ end
+
+ def self.last_commit_date(timestamps)
+ latest_commit_date(timestamps).to_formatted_s(:long).to_s
+ end
+
+ def self.commit_activity_match(user_activities, date)
+ user_activities.select { |x| Time.at(x.to_i) == Time.parse(date) }
+ end
+ end
+end
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
new file mode 100644
index 00000000000..bfbe5254bbe
--- /dev/null
+++ b/spec/controllers/users_controller_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe UsersController do
+ let(:user) { create(:user, username: "user1", name: "User 1", email: "user1@gitlab.com") }
+
+ before do
+ sign_in(user)
+ end
+
+ describe "GET #show" do
+ render_views
+ before do
+ get :show, username: user.username
+ end
+
+ it "renders the show template" do
+ expect(response.status).to eq(200)
+ expect(response).to render_template("show")
+ end
+
+ it "renders calendar" do
+ controller.prepend_view_path 'app/views/users'
+ expect(response).to render_template("_calendar")
+ end
+ end
+end
+