Merge pull request #5321 from discourse/time-read-accounting
FEATURE: track and show time read recently
This commit is contained in:
commit
7f0bf3ff3a
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -118,7 +118,13 @@
|
|||
<h3><span class='desc'>{{i18n 'last_post'}}</span> {{format-date user.last_posted_at leaveAgo="true"}}</h3>
|
||||
{{/if}}
|
||||
<h3><span class='desc'>{{i18n 'joined'}}</span> {{format-date user.created_at leaveAgo="true"}}</h3>
|
||||
<h3><span class='desc'>{{i18n 'time_read'}}</span> {{user.time_read}}</h3>
|
||||
<h3>
|
||||
<span class='desc'>{{i18n 'time_read'}}</span>
|
||||
{{format-duration user.time_read}}
|
||||
{{#if showRecentTimeRead}}
|
||||
<span title="{{i18n 'time_read_recently_tooltip' time_read=recentTimeRead}}">({{i18n 'time_read_recently' time_read=recentTimeRead}})</span>
|
||||
{{/if}}
|
||||
</h3>
|
||||
{{plugin-outlet name="user-card-metadata" args=(hash user=user)}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
|
@ -6,8 +6,13 @@
|
|||
{{user-stat value=model.days_visited label="user.summary.days_visited"}}
|
||||
</li>
|
||||
<li>
|
||||
{{user-stat value=model.time_read label="user.summary.time_read" type="string"}}
|
||||
{{user-stat value=timeRead label="user.summary.time_read" type="string"}}
|
||||
</li>
|
||||
{{#if showRecentTimeRead}}
|
||||
<li>
|
||||
{{user-stat value=recentTimeRead label="user.summary.recent_time_read" type="string"}}
|
||||
</li>
|
||||
{{/if}}
|
||||
<li>
|
||||
{{user-stat value=model.posts_read_count label="user.summary.posts_read"}}
|
||||
</li>
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -151,6 +151,10 @@ class UserSummary
|
|||
.count
|
||||
end
|
||||
|
||||
def recent_time_read
|
||||
@user.recent_time_read
|
||||
end
|
||||
|
||||
delegate :likes_given,
|
||||
:likes_received,
|
||||
:days_visited,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue