FEATURE: track statistics around post creation

- how long were people typing?
- how long was composer open?
- how many drafts were created?
- correct, draft saved to go away after you continue typing

store in Post.find(xyz).post_stat
This commit is contained in:
Sam 2015-08-03 14:29:04 +10:00
parent 5d40695908
commit 7b8b96446e
11 changed files with 148 additions and 12 deletions

View file

@ -413,7 +413,7 @@ export default Ember.ObjectController.extend(Presence, {
} }
// we need a draft sequence for the composer to work // we need a draft sequence for the composer to work
if (opts.draftSequence === void 0) { if (opts.draftSequence === undefined) {
return Discourse.Draft.get(opts.draftKey).then(function(data) { return Discourse.Draft.get(opts.draftKey).then(function(data) {
opts.draftSequence = data.draft_sequence; opts.draftSequence = data.draft_sequence;
opts.draft = data.draft; opts.draft = data.draft;

View file

@ -22,7 +22,9 @@ const CLOSED = 'closed',
topic_id: 'topic.id', topic_id: 'topic.id',
is_warning: 'isWarning', is_warning: 'isWarning',
archetype: 'archetypeId', archetype: 'archetypeId',
target_usernames: 'targetUsernames' target_usernames: 'targetUsernames',
typing_duration_msecs: 'typingTime',
composer_open_duration_msecs: 'composerTime'
}, },
_edit_topic_serializer = { _edit_topic_serializer = {
@ -52,6 +54,31 @@ const Composer = RestModel.extend({
viewOpen: Em.computed.equal('composeState', OPEN), viewOpen: Em.computed.equal('composeState', OPEN),
viewDraft: Em.computed.equal('composeState', DRAFT), viewDraft: Em.computed.equal('composeState', DRAFT),
composeStateChanged: function() {
var oldOpen = this.get('composerOpened');
if (this.get('composeState') === OPEN) {
this.set('composerOpened', oldOpen || new Date());
} else {
if (oldOpen) {
var oldTotal = this.get('composerTotalOpened') || 0;
this.set('composerTotalOpened', oldTotal + (new Date() - oldOpen));
}
this.set('composerOpened', null);
}
}.observes('composeState'),
composerTime: function() {
var total = this.get('composerTotalOpened') || 0;
var oldOpen = this.get('composerOpened');
if (oldOpen) {
total += (new Date() - oldOpen);
}
return total;
}.property().volatile(),
archetype: function() { archetype: function() {
return this.get('archetypes').findProperty('id', this.get('archetypeId')); return this.get('archetypes').findProperty('id', this.get('archetypeId'));
}.property('archetypeId'), }.property('archetypeId'),
@ -60,6 +87,12 @@ const Composer = RestModel.extend({
return this.set('metaData', Em.Object.create()); return this.set('metaData', Em.Object.create());
}.observes('archetype'), }.observes('archetype'),
// view detected user is typing
typing: _.throttle(function(){
var typingTime = this.get("typingTime") || 0;
this.set("typingTime", typingTime + 100);
}, 100, {leading: false, trailing: true}),
editingFirstPost: Em.computed.and('editingPost', 'post.firstPost'), editingFirstPost: Em.computed.and('editingPost', 'post.firstPost'),
canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'), canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'),
canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'), canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'),
@ -349,7 +382,9 @@ const Composer = RestModel.extend({
composeState: opts.composerState || OPEN, composeState: opts.composerState || OPEN,
action: opts.action, action: opts.action,
topic: opts.topic, topic: opts.topic,
targetUsernames: opts.usernames targetUsernames: opts.usernames,
composerTotalOpened: opts.composerTime,
typingTime: opts.typingTime
}); });
if (opts.post) { if (opts.post) {
@ -420,7 +455,10 @@ const Composer = RestModel.extend({
post: null, post: null,
title: null, title: null,
editReason: null, editReason: null,
stagedPost: false stagedPost: false,
typingTime: 0,
composerOpened: null,
composerTotalOpened: 0
}); });
}, },
@ -502,7 +540,9 @@ const Composer = RestModel.extend({
admin: user.get('admin'), admin: user.get('admin'),
yours: true, yours: true,
read: true, read: true,
wiki: false wiki: false,
typingTime: this.get('typingTime'),
composerTime: this.get('composerTime')
}); });
this.serialize(_create_serializer, createdPost); this.serialize(_create_serializer, createdPost);
@ -603,13 +643,20 @@ const Composer = RestModel.extend({
postId: this.get('post.id'), postId: this.get('post.id'),
archetypeId: this.get('archetypeId'), archetypeId: this.get('archetypeId'),
metaData: this.get('metaData'), metaData: this.get('metaData'),
usernames: this.get('targetUsernames') usernames: this.get('targetUsernames'),
composerTime: this.get('composerTime'),
typingTime: this.get('typingTime')
}; };
this.set('draftStatus', I18n.t('composer.saving_draft_tip')); this.set('draftStatus', I18n.t('composer.saving_draft_tip'));
const composer = this; const composer = this;
if (this._clearingStatus) {
Em.run.cancel(this._clearingStatus);
this._clearingStatus = null;
}
// try to save the draft // try to save the draft
return Discourse.Draft.save(this.get('draftKey'), this.get('draftSequence'), data) return Discourse.Draft.save(this.get('draftKey'), this.get('draftSequence'), data)
.then(function() { .then(function() {
@ -617,7 +664,20 @@ const Composer = RestModel.extend({
}).catch(function() { }).catch(function() {
composer.set('draftStatus', I18n.t('composer.drafts_offline')); composer.set('draftStatus', I18n.t('composer.drafts_offline'));
}); });
} },
dataChanged: function(){
const draftStatus = this.get('draftStatus');
const self = this;
if (draftStatus && !this._clearingStatus) {
this._clearingStatus = Em.run.later(this, function(){
self.set('draftStatus', null);
self._clearingStatus = null;
}, 1000);
}
}.observes('title','reply')
}); });
@ -657,7 +717,9 @@ Composer.reopenClass({
metaData: draft.metaData, metaData: draft.metaData,
usernames: draft.usernames, usernames: draft.usernames,
draft: true, draft: true,
composerState: DRAFT composerState: DRAFT,
composerTime: draft.composerTime,
typingTime: draft.typingTime
}); });
} }
}, },

View file

@ -85,6 +85,8 @@ const ComposerView = Discourse.View.extend(Ember.Evented, {
const controller = this.get('controller'); const controller = this.get('controller');
controller.checkReplyLength(); controller.checkReplyLength();
this.get('controller.model').typing();
const lastKeyUp = new Date(); const lastKeyUp = new Date();
this.set('lastKeyUp', lastKeyUp); this.set('lastKeyUp', lastKeyUp);

View file

@ -403,7 +403,6 @@ class PostsController < ApplicationController
# Awful hack, but you can't seem to remove the `default_scope` when joining # Awful hack, but you can't seem to remove the `default_scope` when joining
# So instead I grab the topics separately # So instead I grab the topics separately
topic_ids = posts.dup.pluck(:topic_id) topic_ids = posts.dup.pluck(:topic_id)
secured_category_ids = guardian.secure_category_ids
topics = Topic.where(id: topic_ids).with_deleted.where.not(archetype: 'private_message') topics = Topic.where(id: topic_ids).with_deleted.where.not(archetype: 'private_message')
topics = topics.secured(guardian) topics = topics.secured(guardian)
@ -422,7 +421,9 @@ class PostsController < ApplicationController
:category, :category,
:target_usernames, :target_usernames,
:reply_to_post_number, :reply_to_post_number,
:auto_track :auto_track,
:typing_duration_msecs,
:composer_open_duration_msecs
] ]
# param munging for WordPress # param munging for WordPress

View file

@ -7,7 +7,11 @@ class Draft < ActiveRecord::Base
d = find_draft(user,key) d = find_draft(user,key)
if d if d
return if d.sequence > sequence return if d.sequence > sequence
d.update_columns(data: data, sequence: sequence) exec_sql("UPDATE drafts
SET data = :data,
sequence = :sequence,
revisions = revisions + 1
WHERE id = :id", id: d.id, sequence: sequence, data: data)
else else
Draft.create!(user_id: user.id, draft_key: key, data: data, sequence: sequence) Draft.create!(user_id: user.id, draft_key: key, data: data, sequence: sequence)
end end

View file

@ -37,6 +37,7 @@ class Post < ActiveRecord::Base
has_many :uploads, through: :post_uploads has_many :uploads, through: :post_uploads
has_one :post_search_data has_one :post_search_data
has_one :post_stat
has_many :post_details has_many :post_details

3
app/models/post_stat.rb Normal file
View file

@ -0,0 +1,3 @@
class PostStat < ActiveRecord::Base
belongs_to :post
end

View file

@ -0,0 +1,16 @@
class AddPostStats < ActiveRecord::Migration
def change
add_column :drafts, :revisions, :int, null: false, default: 1
create_table :post_stats do |t|
t.integer :post_id
t.integer :drafts_saved
t.integer :typing_duration_msecs
t.integer :composer_open_duration_msecs
t.timestamps
end
add_index :post_stats, [:post_id]
end
end

View file

@ -113,6 +113,7 @@ class PostCreator
def create def create
if valid? if valid?
transaction do transaction do
build_post_stats
create_topic create_topic
save_post save_post
extract_links extract_links
@ -146,6 +147,14 @@ class PostCreator
@post @post
end end
def self.track_post_stats
Rails.env != "test".freeze || @track_post_stats
end
def self.track_post_stats=(val)
@track_post_stats = val
end
def self.create(user, opts) def self.create(user, opts)
PostCreator.new(user, opts).create PostCreator.new(user, opts).create
end end
@ -172,6 +181,23 @@ class PostCreator
protected protected
def build_post_stats
if PostCreator.track_post_stats
draft_key = @topic ? "topic_#{@topic.id}" : "new_topic"
sequence = DraftSequence.current(@user, draft_key)
revisions = Draft.where(sequence: sequence,
user_id: @user.id,
draft_key: draft_key).pluck(:revisions).first || 0
@post.build_post_stat(
drafts_saved: revisions,
typing_duration_msecs: @opts[:typing_duration_msecs] || 0,
composer_open_duration_msecs: @opts[:composer_open_duration_msecs] || 0
)
end
end
def trigger_after_events(post) def trigger_after_events(post)
DiscourseEvent.trigger(:topic_created, post.topic, @opts, @user) unless @opts[:topic_id] DiscourseEvent.trigger(:topic_created, post.topic, @opts, @user) unless @opts[:topic_id]
DiscourseEvent.trigger(:post_created, post, @opts, @user) DiscourseEvent.trigger(:post_created, post, @opts, @user)

View file

@ -213,6 +213,21 @@ describe PostCreator do
}.to_not change { topic.excerpt } }.to_not change { topic.excerpt }
end end
it 'creates post stats' do
Draft.set(user, 'new_topic', 0, "test")
Draft.set(user, 'new_topic', 0, "test1")
begin
PostCreator.track_post_stats = true
post = creator.create
expect(post.post_stat.typing_duration_msecs).to eq(0)
expect(post.post_stat.drafts_saved).to eq(2)
ensure
PostCreator.track_post_stats = false
end
end
describe "topic's auto close" do describe "topic's auto close" do
it "doesn't update topic's auto close when it's not based on last post" do it "doesn't update topic's auto close when it's not based on last post" do

View file

@ -109,6 +109,12 @@ describe Draft do
expect(Draft.get(p.user, p.topic.draft_key, s)).to eq nil expect(Draft.get(p.user, p.topic.draft_key, s)).to eq nil
end end
it 'increases the sequence number when a post is revised' it 'increases revision each time you set' do
u = User.first
Draft.set(u, 'new_topic', 0, 'hello')
Draft.set(u, 'new_topic', 0, 'goodbye')
expect(Draft.find_draft(u, 'new_topic').revisions).to eq(2)
end
end end
end end