diff --git a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 b/app/assets/javascripts/discourse/components/user-card-contents.js.es6
index c7215915633..2f960c9dbd3 100644
--- a/app/assets/javascripts/discourse/components/user-card-contents.js.es6
+++ b/app/assets/javascripts/discourse/components/user-card-contents.js.es6
@@ -6,6 +6,7 @@ import { default as computed, observes } from 'ember-addons/ember-computed-decor
import DiscourseURL from 'discourse/lib/url';
import User from 'discourse/models/user';
import { userPath } from 'discourse/lib/url';
+import { durationTiny } from 'discourse/lib/formatter';
const clickOutsideEventName = "mousedown.outside-user-card";
const clickDataExpand = "click.discourse-user-card";
@@ -87,6 +88,16 @@ export default Ember.Component.extend(CleansUp, {
$this.css('background-image', bg);
},
+ @computed('user.time_read', 'user.recent_time_read')
+ showRecentTimeRead(timeRead, recentTimeRead) {
+ return timeRead !== recentTimeRead && recentTimeRead !== 0;
+ },
+
+ @computed('user.recent_time_read')
+ recentTimeRead(recentTimeReadSeconds) {
+ return durationTiny(recentTimeReadSeconds);
+ },
+
_show(username, $target) {
// No user card for anon
if (this.siteSettings.hide_user_profiles_from_public && !this.currentUser) {
diff --git a/app/assets/javascripts/discourse/controllers/user-summary.js.es6 b/app/assets/javascripts/discourse/controllers/user-summary.js.es6
index bb4bec20775..d489f9ba288 100644
--- a/app/assets/javascripts/discourse/controllers/user-summary.js.es6
+++ b/app/assets/javascripts/discourse/controllers/user-summary.js.es6
@@ -1,4 +1,5 @@
import computed from 'ember-addons/ember-computed-decorators';
+import { durationTiny } from 'discourse/lib/formatter';
// should be kept in sync with 'UserSummary::MAX_BADGES'
const MAX_BADGES = 6;
@@ -9,4 +10,19 @@ export default Ember.Controller.extend({
@computed("model.badges.length")
moreBadges(badgesLength) { return badgesLength >= MAX_BADGES; },
+
+ @computed('model.time_read')
+ timeRead(timeReadSeconds) {
+ return durationTiny(timeReadSeconds);
+ },
+
+ @computed('model.time_read', 'model.recent_time_read')
+ showRecentTimeRead(timeRead, recentTimeRead) {
+ return timeRead !== recentTimeRead && recentTimeRead !== 0;
+ },
+
+ @computed('model.recent_time_read')
+ recentTimeRead(recentTimeReadSeconds) {
+ return recentTimeReadSeconds > 0 ? durationTiny(recentTimeReadSeconds) : null;
+ }
});
diff --git a/app/assets/javascripts/discourse/helpers/format-age.js.es6 b/app/assets/javascripts/discourse/helpers/format-age.js.es6
index 75119d0c5f2..a2a52d3d8cc 100644
--- a/app/assets/javascripts/discourse/helpers/format-age.js.es6
+++ b/app/assets/javascripts/discourse/helpers/format-age.js.es6
@@ -1,7 +1,11 @@
-import { autoUpdatingRelativeAge } from 'discourse/lib/formatter';
+import { autoUpdatingRelativeAge, durationTiny } from 'discourse/lib/formatter';
import { registerUnbound } from 'discourse-common/lib/helpers';
registerUnbound('format-age', function(dt) {
dt = new Date(dt);
return new Handlebars.SafeString(autoUpdatingRelativeAge(dt));
});
+
+registerUnbound('format-duration', function(seconds) {
+ return new Handlebars.SafeString(durationTiny(seconds));
+});
diff --git a/app/assets/javascripts/discourse/lib/formatter.js.es6 b/app/assets/javascripts/discourse/lib/formatter.js.es6
index b478ddfa438..07b8a2153f8 100644
--- a/app/assets/javascripts/discourse/lib/formatter.js.es6
+++ b/app/assets/javascripts/discourse/lib/formatter.js.es6
@@ -129,6 +129,56 @@ function wrapAgo(dateStr) {
return I18n.t("dates.wrap_ago", { date: dateStr });
}
+export function durationTiny(distance, ageOpts) {
+ const dividedDistance = Math.round(distance / 60.0);
+ const distanceInMinutes = (dividedDistance < 1) ? 1 : dividedDistance;
+
+ const t = function(key, opts) {
+ const result = I18n.t("dates.tiny." + key, opts);
+ return (ageOpts && ageOpts.addAgo) ? wrapAgo(result) : result;
+ };
+
+ let formatted;
+
+ switch(true) {
+ case(distance <= 59):
+ formatted = t("less_than_x_minutes", {count: 1});
+ break;
+ case(distanceInMinutes >= 0 && distanceInMinutes <= 44):
+ formatted = t("x_minutes", {count: distanceInMinutes});
+ break;
+ case(distanceInMinutes >= 45 && distanceInMinutes <= 89):
+ formatted = t("about_x_hours", {count: 1});
+ break;
+ case(distanceInMinutes >= 90 && distanceInMinutes <= 1409):
+ formatted = t("about_x_hours", {count: Math.round(distanceInMinutes / 60.0)});
+ break;
+ case(distanceInMinutes >= 1410 && distanceInMinutes <= 2519):
+ formatted = t("x_days", {count: 1});
+ break;
+ case(distanceInMinutes >= 2520 && distanceInMinutes <= 129599):
+ formatted = t("x_days", {count: Math.round(distanceInMinutes / 1440.0)});
+ break;
+ case(distanceInMinutes >= 129600 && distanceInMinutes <= 525599):
+ formatted = t("x_months", {count: Math.round(distanceInMinutes / 43200.0)});
+ break;
+ default:
+ const numYears = distanceInMinutes / 525600.0;
+ const remainder = numYears % 1;
+ if (remainder < 0.25) {
+ formatted = t("about_x_years", {count: parseInt(numYears)});
+ } else if (remainder < 0.75) {
+ formatted = t("over_x_years", {count: parseInt(numYears)});
+ } else {
+ formatted = t("almost_x_years", {count: parseInt(numYears) + 1});
+ }
+
+ break;
+ }
+
+ return formatted;
+}
+
function relativeAgeTiny(date, ageOpts) {
const format = "tiny";
const distance = Math.round((new Date() - date) / 1000);
diff --git a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs
index 48e10c6dcb7..9d479a5723f 100644
--- a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs
+++ b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs
@@ -118,7 +118,13 @@
{{i18n 'last_post'}} {{format-date user.last_posted_at leaveAgo="true"}}
{{/if}}
{{i18n 'joined'}} {{format-date user.created_at leaveAgo="true"}}
- {{i18n 'time_read'}} {{user.time_read}}
+
+ {{i18n 'time_read'}}
+ {{format-duration user.time_read}}
+ {{#if showRecentTimeRead}}
+ ({{i18n 'time_read_recently' time_read=recentTimeRead}})
+ {{/if}}
+
{{plugin-outlet name="user-card-metadata" args=(hash user=user)}}
{{/if}}
diff --git a/app/assets/javascripts/discourse/templates/user/summary.hbs b/app/assets/javascripts/discourse/templates/user/summary.hbs
index 97c7cf9c4e1..e69a785b12c 100644
--- a/app/assets/javascripts/discourse/templates/user/summary.hbs
+++ b/app/assets/javascripts/discourse/templates/user/summary.hbs
@@ -6,8 +6,13 @@
{{user-stat value=model.days_visited label="user.summary.days_visited"}}
- {{user-stat value=model.time_read label="user.summary.time_read" type="string"}}
+ {{user-stat value=timeRead label="user.summary.time_read" type="string"}}
+ {{#if showRecentTimeRead}}
+
+ {{user-stat value=recentTimeRead label="user.summary.recent_time_read" type="string"}}
+
+ {{/if}}
{{user-stat value=model.posts_read_count label="user.summary.posts_read"}}
diff --git a/app/jobs/onceoff/retro_recent_time_read.rb b/app/jobs/onceoff/retro_recent_time_read.rb
new file mode 100644
index 00000000000..1dd1ce0289f
--- /dev/null
+++ b/app/jobs/onceoff/retro_recent_time_read.rb
@@ -0,0 +1,21 @@
+module Jobs
+ class RetroRecentTimeRead < Jobs::Onceoff
+ def execute_onceoff(args)
+ # update past records by evenly distributing total time reading among each post read
+ sql = <<~SQL
+ UPDATE user_visits uv1
+ SET time_read = (
+ SELECT (
+ uv1.posts_read
+ / (SELECT CAST(sum(uv2.posts_read) AS FLOAT) FROM user_visits uv2 where uv2.user_id = uv1.user_id)
+ * COALESCE((SELECT us.time_read FROM user_stats us WHERE us.user_id = uv1.user_id), 0)
+ )
+ )
+ WHERE EXISTS (SELECT 1 FROM user_stats stats WHERE stats.user_id = uv1.user_id AND stats.posts_read_count > 0 LIMIT 1)
+ AND EXISTS (SELECT 1 FROM user_visits visits WHERE visits.user_id = uv1.user_id AND visits.posts_read > 0 LIMIT 1)
+ SQL
+
+ UserVisit.exec_sql(sql)
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 0db29c11e01..aaa6055b120 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -965,6 +965,12 @@ class User < ActiveRecord::Base
end
end
+ def recent_time_read
+ self.created_at && self.created_at < 60.days.ago ?
+ self.user_visits.where('visited_at >= ?', 60.days.ago).sum(:time_read) :
+ self.user_stat&.time_read
+ end
+
protected
def badge_grant
diff --git a/app/models/user_stat.rb b/app/models/user_stat.rb
index f0b01fad498..8e7b3da43b5 100644
--- a/app/models/user_stat.rb
+++ b/app/models/user_stat.rb
@@ -72,7 +72,9 @@ class UserStat < ActiveRecord::Base
if last_seen = last_seen_cached
diff = (Time.now.to_f - last_seen.to_f).round
if diff > 0 && diff < MAX_TIME_READ_DIFF
- UserStat.where(user_id: id, time_read: time_read).update_all ["time_read = time_read + ?", diff]
+ update_args = ["time_read = time_read + ?", diff]
+ UserStat.where(user_id: id, time_read: time_read).update_all(update_args)
+ UserVisit.where(user_id: id, visited_at: Time.zone.now.to_date).update_all(update_args)
end
end
cache_last_seen(Time.now.to_f)
diff --git a/app/models/user_summary.rb b/app/models/user_summary.rb
index 0cf714cfac0..86d940a97a1 100644
--- a/app/models/user_summary.rb
+++ b/app/models/user_summary.rb
@@ -151,6 +151,10 @@ class UserSummary
.count
end
+ def recent_time_read
+ @user.recent_time_read
+ end
+
delegate :likes_given,
:likes_received,
:days_visited,
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index b81bbcbe7fc..6a436eb6428 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -67,6 +67,7 @@ class UserSerializer < BasicUserSerializer
:pending_count,
:profile_view_count,
:time_read,
+ :recent_time_read,
:primary_group_name,
:primary_group_flair_url,
:primary_group_flair_bg_color,
@@ -403,7 +404,11 @@ class UserSerializer < BasicUserSerializer
end
def time_read
- AgeWords.age_words(object.user_stat&.time_read)
+ object.user_stat&.time_read
+ end
+
+ def recent_time_read
+ time = object.recent_time_read
end
end
diff --git a/app/serializers/user_summary_serializer.rb b/app/serializers/user_summary_serializer.rb
index 76662e1ea62..4a61538d918 100644
--- a/app/serializers/user_summary_serializer.rb
+++ b/app/serializers/user_summary_serializer.rb
@@ -37,6 +37,7 @@ class UserSummarySerializer < ApplicationSerializer
:topic_count,
:post_count,
:time_read,
+ :recent_time_read,
:bookmark_count
def include_badges?
@@ -48,6 +49,10 @@ class UserSummarySerializer < ApplicationSerializer
end
def time_read
- AgeWords.age_words(object.time_read)
+ object.time_read
+ end
+
+ def recent_time_read
+ object.recent_time_read
end
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index c566ded478d..64304dd1c06 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -74,6 +74,9 @@ en:
x_seconds:
one: "1s"
other: "%{count}s"
+ less_than_x_minutes:
+ one: "< 1m"
+ other: "< %{count}m"
x_minutes:
one: "1m"
other: "%{count}m"
@@ -83,6 +86,9 @@ en:
x_days:
one: "1d"
other: "%{count}d"
+ x_months:
+ one: "1mon"
+ other: "%{count}mon"
about_x_years:
one: "1y"
other: "%{count}y"
@@ -892,6 +898,7 @@ en:
title: "Summary"
stats: "Stats"
time_read: "read time"
+ recent_time_read: "recent read time"
topic_count:
one: "topic created"
other: "topics created"
@@ -1005,6 +1012,8 @@ en:
unmute: Unmute
last_post: Posted
time_read: Read
+ time_read_recently: '%{time_read} recently'
+ time_read_recently_tooltip: '%{time_read} read time in the last 60 days'
last_reply_lowercase: last reply
replies_lowercase:
one: reply
diff --git a/db/migrate/20171113214725_add_time_read_to_user_visits.rb b/db/migrate/20171113214725_add_time_read_to_user_visits.rb
new file mode 100644
index 00000000000..4079fcdef41
--- /dev/null
+++ b/db/migrate/20171113214725_add_time_read_to_user_visits.rb
@@ -0,0 +1,11 @@
+class AddTimeReadToUserVisits < ActiveRecord::Migration[5.1]
+ def up
+ add_column :user_visits, :time_read, :integer, null: false, default: 0 # in seconds
+ add_index :user_visits, [:user_id, :visited_at, :time_read]
+ end
+
+ def down
+ remove_index :user_visits, [:user_id, :visited_at, :time_read]
+ remove_column :user_visits, :time_read
+ end
+end
diff --git a/test/javascripts/lib/formatter-test.js.es6 b/test/javascripts/lib/formatter-test.js.es6
index 9e929e1853e..7856c9b9585 100644
--- a/test/javascripts/lib/formatter-test.js.es6
+++ b/test/javascripts/lib/formatter-test.js.es6
@@ -1,6 +1,6 @@
var clock;
-import { relativeAge, autoUpdatingRelativeAge, updateRelativeAge, breakUp, number, longDate } from 'discourse/lib/formatter';
+import { relativeAge, autoUpdatingRelativeAge, updateRelativeAge, breakUp, number, longDate, durationTiny } from 'discourse/lib/formatter';
QUnit.module("lib:formatter", {
beforeEach() {
@@ -211,4 +211,26 @@ QUnit.test("number", assert => {
assert.equal(number(NaN), "0", "it returns 0 for NaN");
assert.equal(number(3333), "3.3k", "it abbreviates thousands");
assert.equal(number(2499999), "2.5M", "it abbreviates millions");
-});
\ No newline at end of file
+});
+
+QUnit.test("durationTiny", assert => {
+ assert.equal(durationTiny(0), '< 1m', "0 seconds shows as < 1m");
+ assert.equal(durationTiny(59), '< 1m', "59 seconds shows as < 1m");
+ assert.equal(durationTiny(60), '1m', "60 seconds shows as 1m");
+ assert.equal(durationTiny(90), '2m', "90 seconds shows as 2m");
+ assert.equal(durationTiny(120), '2m', "120 seconds shows as 2m");
+ assert.equal(durationTiny(60 * 45), '1h', "45 minutes shows as 1h");
+ assert.equal(durationTiny(60 * 60), '1h', "60 minutes shows as 1h");
+ assert.equal(durationTiny(60 * 90), '2h', "90 minutes shows as 2h");
+ assert.equal(durationTiny(3600 * 23), '23h', "23 hours shows as 23h");
+ assert.equal(durationTiny(3600 * 24 - 29), '1d', "23 hours 31 mins shows as 1d");
+ assert.equal(durationTiny(3600 * 24 * 89), '89d', "89 days shows as 89d");
+ assert.equal(durationTiny(60 * (525600 - 1)), '12mon', "364 days shows as 12mon");
+ assert.equal(durationTiny(60 * 525600), '1y', "365 days shows as 1y");
+ assert.equal(durationTiny(86400 * 456), '1y', "456 days shows as 1y");
+ assert.equal(durationTiny(86400 * 457), '> 1y', "457 days shows as > 1y");
+ assert.equal(durationTiny(86400 * 638), '> 1y', "638 days shows as > 1y");
+ assert.equal(durationTiny(86400 * 639), '2y', "639 days shows as 2y");
+ assert.equal(durationTiny(86400 * 821), '2y', "821 days shows as 2y");
+ assert.equal(durationTiny(86400 * 822), '> 2y', "822 days shows as > 2y");
+});