Merge pull request #5321 from discourse/time-read-accounting

FEATURE: track and show time read recently
This commit is contained in:
Neil Lalonde 2017-11-16 17:02:33 -05:00 committed by GitHub
commit 7f0bf3ff3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 186 additions and 8 deletions

View File

@ -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) {

View File

@ -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;
}
});

View File

@ -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));
});

View File

@ -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);

View File

@ -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}}

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -151,6 +151,10 @@ class UserSummary
.count
end
def recent_time_read
@user.recent_time_read
end
delegate :likes_given,
:likes_received,
:days_visited,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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");
});