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/script/pull_translations.rb b/script/pull_translations.rb index e5e6dce8972..990751a927b 100644 --- a/script/pull_translations.rb +++ b/script/pull_translations.rb @@ -310,6 +310,7 @@ YML_DIRS.each do |dir| # fix_invalid_yml(filename) # TODO check if this is still needed with recent Transifex changes + # Nov 14, 2017: yup, still needed add_anchors_and_aliases(english_alias_data, filename) update_file_header(filename, language) 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"); +});