Init version of dashboard stats.

This commit is contained in:
Martin Edenhofer 2015-09-07 00:42:11 +02:00
parent f527a058c6
commit aee48e22c2
14 changed files with 729 additions and 157 deletions

View file

@ -0,0 +1,106 @@
class App.DashboardStats extends App.Controller
constructor: ->
super
stats_store = App.StatsStore.first()
if stats_store
@render(stats_store.data)
else
@render()
# bind to rebuild view event
@bind('dashboard_stats_rebuild', @render)
render: (data = {}) ->
if !data.StatsTicketWaitingTime
data.StatsTicketWaitingTime =
handling_time: 0
average: 0
if !data.StatsTicketEscalation
data.StatsTicketEscalation =
state: 'supergood'
own: 0
total: 0
if !data.StatsTicketChannelDistribution
data.StatsTicketChannelDistribution =
channels:
1:
inbound: 0
outbound: 0
inbound_in_percent: 0
outbound_in_percent: 0
2:
inbound: 0
outbound: 0
inbound_in_percent: 0
outbound_in_percent: 0
3:
inbound: 0
outbound: 0
inbound_in_percent: 0
outbound_in_percent: 0
if !data.StatsTicketLoadMeasure
data.StatsTicketLoadMeasure =
state: 'supergood'
percent: 0
own: 0
total: 0
average: 0
if !data.StatsTicketInProcess
data.StatsTicketInProcess =
state: 'supergood'
percent: 0
average_percent: 0
if !data.StatsTicketReopen
data.StatsTicketReopen =
state: 'supergood'
percent: 0
average_percent: 0
@html App.view('dashboard/stats')(data)
if data.StatsTicketWaitingTime
@renderWidgetClockFace data.StatsTicketWaitingTime.handling_time
renderWidgetClockFace: (time) =>
canvas = @el.find 'canvas'
ctx = canvas.get(0).getContext '2d'
radius = 26
@el.find('.time.stat-widget .stat-amount').text time
canvas.attr 'width', 2 * radius
canvas.attr 'height', 2 * radius
time = 60 if time > 60
handlingTimeColors =
5: '#38AE6A' # supergood
10: '#A9AC41' # good
15: '#FAAB00' # ok
20: '#F6820B' # bad
25: '#F35910' # superbad
for handlingTime, timeColor of handlingTimeColors
if time <= handlingTime
backgroundColor = timeColor
break
# 30% background
ctx.globalAlpha = 0.3
ctx.fillStyle = backgroundColor
ctx.beginPath()
ctx.arc radius, radius, radius, 0, Math.PI * 2, true
ctx.closePath()
ctx.fill()
# 100% pie piece
ctx.globalAlpha = 1
ctx.beginPath()
ctx.moveTo radius, radius
arcsector = Math.PI * 2 * time/60
ctx.arc radius, radius, radius, -Math.PI/2, arcsector - Math.PI/2, false
ctx.lineTo radius, radius
ctx.closePath()
ctx.fill()

View file

@ -7,7 +7,7 @@ class App.Dashboard extends App.Controller
super
if @isRole('Customer')
@navigate '#'
@navigate '#', true
return
# render page
@ -25,62 +25,21 @@ class App.Dashboard extends App.Controller
isAdmin: @isRole('Admin')
)
new App.DashboardStats(
el: @$('.stat-widgets')
)
new App.DashboardActivityStream(
el: @$('.sidebar')
limit: 25
)
@renderWidgetClockFace 25
clues: (e) =>
e.preventDefault()
new App.FirstStepsClues(
el: @el
)
renderWidgetClockFace: (time) =>
canvas = @el.find 'canvas'
ctx = canvas.get(0).getContext '2d'
radius = 26
@el.find('.time.stat-widget .stat-amount').text time
canvas.attr 'width', 2 * radius
canvas.attr 'height', 2 * radius
time = 60 if time > 60
handlingTimeColors =
5: '#38AE6A' # supergood
10: '#A9AC41' # good
15: '#FAAB00' # ok
20: '#F6820B' # bad
25: '#F35910' # superbad
for handlingTime, timeColor of handlingTimeColors
if time <= handlingTime
backgroundColor = timeColor
break
# 30% background
ctx.globalAlpha = 0.3
ctx.fillStyle = backgroundColor
ctx.beginPath()
ctx.arc radius, radius, radius, 0, Math.PI * 2, true
ctx.closePath()
ctx.fill()
# 100% pie piece
ctx.globalAlpha = 1
ctx.beginPath()
ctx.moveTo radius, radius
arcsector = Math.PI * 2 * time/60
ctx.arc radius, radius, radius, -Math.PI/2, arcsector - Math.PI/2, false
ctx.lineTo radius, radius
ctx.closePath()
ctx.fill()
active: (state) =>
@activeState = state

View file

@ -33,117 +33,7 @@
</div>
<div class="tab-content stat-widgets three-columns horizontal">
<div class="column">
<div class="time stat-widget vertical">
<h3>∅ Waiting time today</h3>
<div class="stat-graphic">
<div class="stat-stopwatch centered">
<svg class="stat-icon stopwatch-icon"><use xlink:href="#icon-stopwatch" /></svg>
<canvas class="stat-dial"></canvas>
<div class="stat-amount"></div>
</div>
</div>
<div class="stat-label">My handling time: 25 minutes</div>
<div class="stat-detail">Average: 13%</div>
</div>
</div>
<div class="column">
<div class="mood stat-widget vertical">
<h3>Mood</h3>
<div class="stat-graphic">
<svg class="stat-icon mood-icon"><use xlink:href="#icon-mood-supergood" /></svg>
</div>
<div class="stat-label">3% of my tickets escalated.</div>
<div class="stat-detail">Average: 17%</div>
</div>
</div>
<div class="column">
<div class="channel-distribution stat-widget vertical centered">
<h3>Channel Distribution</h3>
<div class="stat-graphic">
<div class="stats-row email-channel">
<svg class="stat-channel-icon"><use xlink:href="#icon-email" /></svg>
<div class="stat-bars">
<div class="stat-bar primary" style="height: 80%"></div>
<div class="stat-bar secondary" style="height: 100%"></div>
</div>
<div class="stat-label">34%</div>
</div>
<div class="stats-row received-calls-channel">
<svg class="stat-channel-icon"><use xlink:href="#icon-received-calls" /></svg>
<div class="stat-bars">
<div class="stat-bar primary" style="height: 53%"></div>
<div class="stat-bar secondary" style="height: 47%"></div>
</div>
<div class="stat-label">26%</div>
</div>
<div class="stats-row outbound-calls-channel">
<svg class="stat-channel-icon"><use xlink:href="#icon-outbound-calls" /></svg>
<div class="stat-bars">
<div class="stat-bar primary" style="height: 46%"></div>
<div class="stat-bar secondary" style="height: 53%"></div>
</div>
<div class="stat-label">24%</div>
</div>
<div class="stats-row facebook-channel">
<svg class="stat-channel-icon"><use xlink:href="#icon-facebook" /></svg>
<div class="stat-bars">
<div class="stat-bar primary" style="height: 24%"></div>
<div class="stat-bar secondary" style="height: 18%"></div>
</div>
<div class="stat-label">12%</div>
</div>
<div class="stats-row twitter-channel">
<svg class="stat-channel-icon"><use xlink:href="#icon-twitter" /></svg>
<div class="stat-bars">
<div class="stat-bar primary" style="height: 13%"></div>
<div class="stat-bar secondary" style="height: 16%"></div>
</div>
<div class="stat-label">4%</div>
</div>
</div>
</div>
</div>
<div class="column">
<div class="status stat-widget vertical">
<h3>Status</h3>
<div class="stat-graphic">
<div class="stat-tickets vertical reverse end">
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
</div>
<svg class="total-tickets"><use xlink:href="#icon-total-tickets" /></svg>
</div>
<div class="stat-label">Tickets of my Group: 78 of 234</div>
<div class="stat-detail">Average: 22%</div>
</div>
</div>
<div class="column">
<div class="in-process stat-widget vertical">
<h3>Tickets in process</h3>
<div class="stat-graphic">
<svg class="in-process-icon state-color supergood-state"><use xlink:href="#icon-in-process" /></svg>
</div>
<div class="stat-label">74% are currently in process</div>
<div class="stat-detail">Average: 62%</div>
</div>
</div>
<div class="column">
<div class="reopening stat-widget vertical">
<h3>Reopening rate</h3>
<div class="stat-graphic">
<svg class="reopening-icon state-color ok-state"><use xlink:href="#icon-reopening" /></svg>
</div>
<div class="stat-label">7% are being reopened</div>
<div class="stat-detail">Average: 6%</div>
</div>
</div>
</div>
<div class="tab-content stat-widgets three-columns horizontal"></div>
</div>
<div class="sidebar optional">

View file

@ -0,0 +1,77 @@
<div class="column">
<div class="time stat-widget vertical">
<h3><%- @T('∅ Waiting time today') %></h3>
<div class="stat-graphic">
<div class="stat-stopwatch centered">
<%- @Icon('stopwatch', 'stat-icon stopwatch-icon') %>
<canvas class="stat-dial"></canvas>
<div class="stat-amount"></div>
</div>
</div>
<div class="stat-label"><%- @T('My handling time: %s minutes', @StatsTicketWaitingTime.handling_time) %></div>
<div class="stat-detail"><%- @T('Average: %s minutes', @StatsTicketWaitingTime.average) %></div>
</div>
</div>
<div class="column">
<div class="mood stat-widget vertical">
<h3><%- @T('Mood') %></h3>
<div class="stat-graphic">
<%- @Icon("mood-#{@StatsTicketEscalation.state}", 'stat-icon mood-icon') %>
</div>
<div class="stat-label"><%- @StatsTicketEscalation.own %> of my tickets escalated.</div>
<div class="stat-detail"><%- @T('Total: %s', @StatsTicketEscalation.total) %></div>
</div>
</div>
<div class="column">
<div class="channel-distribution stat-widget vertical centered">
<h3><%- @T('Channel Distribution') %></h3>
<div class="stat-graphic">
<% for channel_name, channel of @StatsTicketChannelDistribution.channels: %>
<div class="stats-row email-channel">
<%- @Icon(channel.icon, 'stat-channel-icon') %>
<div class="stat-bars">
<div class="stat-bar primary" style="height: <%- channel.inbound_in_percent %>%" title="<%- @T('Inbound') %>: <%- channel.inbound_in_percent %>% (<%- channel.inbound %>)"></div>
<div class="stat-bar secondary" style="height: <%- channel.outbound_in_percent %>%" title="<%- @T('Outbound') %>: <%- channel.outbound_in_percent %>% (<%- channel.outbound %>)"></div>
</div>
<div class="stat-label"></div>
</div>
<% end %>
</div>
</div>
</div>
<div class="column">
<div class="status stat-widget vertical">
<h3><%- @T('Assigned') %></h3>
<div class="stat-graphic">
<div class="stat-tickets vertical reverse end">
<% stack_counter = parseInt(@StatsTicketLoadMeasure.percent*0.16) %>
<% for count in [1..stack_counter]: %>
<%- @Icon('one-ticket', "one-ticket state-color #{@StatsTicketLoadMeasure.state}-state") %>
<% end %>
</div>
<%- @Icon('total-tickets', 'total-tickets') %>
</div>
<div class="stat-label"><%- @T('Tickets assigned to me: %s of %s', @StatsTicketLoadMeasure.own, @StatsTicketLoadMeasure.total) %></div>
<div class="stat-detail"><%- @T('Average: %s', @StatsTicketLoadMeasure.average) %></div>
</div>
</div>
<div class="column">
<div class="in-process stat-widget vertical">
<h3><%- @T('Tickets in process') %></h3>
<div class="stat-graphic">
<%- @Icon('in-process', "in-process-icon state-color #{@StatsTicketInProcess.state}-state") %>
</div>
<div class="stat-label"><%- @T('%s% are currently in process', @StatsTicketInProcess.percent) %></div>
<div class="stat-detail"><%- @T('Average: %s%', @StatsTicketInProcess.average_percent) %></div>
</div>
</div>
<div class="column">
<div class="reopening stat-widget vertical">
<h3><%- @T('Reopening rate') %></h3>
<div class="stat-graphic">
<%- @Icon('reopening', "reopening-icon state-color #{@StatsTicketReopen.state}-state") %>
</div>
<div class="stat-label"><%- @T('%s% are being reopened', @StatsTicketReopen.percent) %></div>
<div class="stat-detail"><%- @T('Average: %s%', @StatsTicketReopen.average_percent) %></div>
</div>
</div>

134
app/models/stats_store.rb Normal file
View file

@ -0,0 +1,134 @@
# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/
class StatsStore < ApplicationModel
store :data
=begin
count = StatsStore.count_by_search(
object: 'User',
o_id: ticket.owner_id,
key: 'ticket:reopen',
start: Time.zone.now - 7.days,
end: Time.zone.now,
)
=end
def self.count_by_search(data)
# lookups
if data[:object]
object_id = ObjectLookup.by_name( data[:object] )
end
StatsStore.where(stats_store_object_id: object_id, o_id: data[:o_id], key: data[:key])
.where('created_at < ? AND created_at > ?', data[:start], data[:end]).count
end
=begin
item = StatsStore.search(
object: 'User',
o_id: current_user.id,
key: 'dashboard',
)
=end
def self.search(data)
# lookups
if data[:object]
data[:stats_store_object_id] = ObjectLookup.by_name( data[:object] )
data.delete(:object)
end
find_by(data)
end
=begin
item = StatsStore.snyc(
object: 'User',
o_id: current_user.id,
key: 'dashboard',
data: {some data},
)
=end
def self.sync(params)
data = params[:data]
params.delete(:data)
item = search(params)
if item
item.data = data
item.save
return true
end
# lookups
if data[:object]
data[:stats_store_object_id] = ObjectLookup.by_name( data[:object] )
data.delete(:object)
end
params[:data] = data
params[:created_by_id] = 1
create(params)
end
=begin
StatsStore.add(
object: 'User',
o_id: ticket.owner_id,
key: 'ticket:reopen',
data: { ticket_id: ticket.id },
created_at: Time.zone.now,
)
=end
def self.add(data)
# lookups
if data[:object]
object_id = ObjectLookup.by_name( data[:object] )
end
# create history
record = {
stats_store_object_id: object_id,
o_id: data[:o_id],
key: data[:key],
data: data[:data],
created_at: data[:created_at],
created_by_id: data[:created_by_id],
}
StatsStore.create(record)
end
=begin
cleanup old stats store
StatsStore.cleanup
optional you can parse the max oldest stats store entries
StatsStore.cleanup(3.months)
=end
def self.cleanup(diff = 3.months)
StatsStore.where('created_at < ?', Time.zone.now - diff).delete_all
true
end
end

View file

@ -0,0 +1,43 @@
class CreateStatsStore < ActiveRecord::Migration
def up
create_table :stats_stores do |t|
t.references :stats_store_object, null: false
t.integer :o_id, null: false
t.string :key, limit: 250, null: true
t.integer :related_o_id, null: true
t.integer :related_stats_store_object_id, null: true
t.string :data, limit: 2500, null: true
t.integer :created_by_id, null: false
t.timestamps
end
add_index :stats_stores, [:o_id]
add_index :stats_stores, [:key]
add_index :stats_stores, [:stats_store_object_id]
add_index :stats_stores, [:created_by_id]
add_index :stats_stores, [:created_at]
Scheduler.create_or_update(
name: 'Generate user based stats.',
method: 'Stats.generate',
period: 11.minutes,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
Scheduler.create_or_update(
name: 'Delete old stats store entries.',
method: 'StatsStore.cleanup',
period: 31.days,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
end
def down
drop_table :stats_stores
end
end

45
lib/stats.rb Normal file
View file

@ -0,0 +1,45 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
require 'stats_store'
class Stats
=begin
generate stats for user
Stats.generate
returns
result = true # if generation was successfully
=end
def self.generate
backends = [
Stats::TicketChannelDistribution,
Stats::TicketInProcess,
Stats::TicketLoadMeasure,
Stats::TicketEscalation,
Stats::TicketReopen,
]
users = User.of_role('Agent')
users.each {|user|
data = {}
backends.each {|backend|
data[backend.to_app_model] = backend.generate(user)
}
StatsStore.sync(
object: 'User',
o_id: user.id,
key: 'dashboard',
data: data,
)
}
true
end
end

View file

@ -0,0 +1,89 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class Stats::TicketChannelDistribution
def self.generate(user)
# which time range?
time_range = 7.days
# get users groups
group_ids = user.groups.map(&:id)
# get channels
channels = [
{
sender: 'email',
icon: 'email',
},
{
sender: 'phone',
icon: 'phone',
},
{
sender: 'twitter',
icon: 'twitter',
},
{
sender: 'facebook',
icon: 'facebook',
},
]
# calcualte
result = {}
total_in = 0
total_out = 0
channels.each {|channel|
result[channel[:sender].to_sym] = {
icon: channel[:icon]
}
type_ids = []
Ticket::Article::Type.all.each {|type|
next if type.name !~ /^#{channel[:sender]}/i
type_ids.push type.id
}
sender = Ticket::Article::Sender.lookup( name: 'Customer' )
count = Ticket.where(group_id: group_ids).joins(:articles).where(
ticket_articles: { sender_id: sender, type_id: type_ids }
).where(
'ticket_articles.created_at > ?', Time.zone.now - time_range
).count
result[channel[:sender].to_sym][:inbound] = count
total_in += count
sender = Ticket::Article::Sender.lookup( name: 'Agent' )
count = Ticket.where(group_id: group_ids).joins(:articles).where(
ticket_articles: { sender_id: sender, type_id: type_ids }
).where(
'ticket_articles.created_at > ?', Time.zone.now - time_range
).count
result[channel[:sender].to_sym][:outbound] = count
total_out += count
}
# append in percent
channels.each {|channel|
count = result[channel[:sender].to_sym][:inbound]
#puts "#{channel.inspect}:in/#{result.inspect}:#{count}"
if count == 0
in_process_precent = 0
else
in_process_precent = (count * 1000) / ((total_in * 1000) / 100)
end
result[channel[:sender].to_sym][:inbound_in_percent] = in_process_precent
count = result[channel[:sender].to_sym][:outbound]
if count == 0
out_process_precent = 0
else
out_process_precent = (count * 1000) / ((total_out * 1000) / 100)
end
result[channel[:sender].to_sym][:outbound_in_percent] = out_process_precent
}
{ channels: result }
end
end

View file

@ -0,0 +1,48 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class Stats::TicketEscalation
def self.generate(user)
# get users groups
group_ids = user.groups.map(&:id)
# owned tickets
own_escalated = Ticket.where(
owner_id: user.id,
group_id: group_ids,
escalation_time: Time.zone.now,
state_id: Ticket::State.by_category('open').map(&:id)
).count
# all tickets
all_escalated = Ticket.where(
owner_id: user.id,
group_id: group_ids,
escalation_time: Time.zone.now,
state_id: Ticket::State.by_category('open').map(&:id)
).count
average = '-'
state = 'supergood'
# if in_process_precent > 80
# state = 'supergood'
# elsif in_process_precent > 60
# state = 'good'
# elsif in_process_precent > 40
# state = 'ok'
# elsif in_process_precent > 20
# state = 'bad'
# elsif in_process_precent > 5
# state = 'superbad'
# end
{
average: average,
state: state,
own: own_escalated,
total: all_escalated,
}
end
end

View file

@ -0,0 +1,45 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class Stats::TicketInProcess
def self.generate(user)
# get history entries of tickets worked on today
history_object = History::Object.lookup(name: 'Ticket')
own_ticket_ids = Ticket.select('id').where(owner_id: user.id).map(&:id)
count = History.select('DISTINCT(o_id)').where(
'histories.created_at >= ? AND histories.history_object_id = ? AND histories.created_by_id = ? AND histories.o_id IN (?)', Time.zone.now - 1.day, history_object.id, user.id, own_ticket_ids
).count
total = own_ticket_ids.count
in_process_precent = 0
state = 'supergood'
average_in_percent = '-'
if count != 0 && total != 0
in_process_precent = (count * 1000) / ((total * 1000) / 100)
if in_process_precent > 80
state = 'supergood'
elsif in_process_precent > 60
state = 'good'
elsif in_process_precent > 40
state = 'ok'
elsif in_process_precent > 20
state = 'bad'
elsif in_process_precent > 5
state = 'superbad'
end
end
{
state: state,
in_process: count,
percent: in_process_precent,
average_percent: average_in_percent,
total: total,
}
end
end

View file

@ -0,0 +1,41 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class Stats::TicketLoadMeasure
def self.generate(user)
# owned tickets
count = Ticket.where(owner_id: user.id).count
# get total open
total = Ticket.where(group_id: user.groups.map(&:id), state_id: Ticket::State.by_category('open').map(&:id) ).count
average = '-'
state = 'good'
# if in_process_precent > 80
# state = 'supergood'
# elsif in_process_precent > 60
# state = 'good'
# elsif in_process_precent > 40
# state = 'ok'
# elsif in_process_precent > 20
# state = 'bad'
# elsif in_process_precent > 5
# state = 'superbad'
# end
if count > total
total = count
end
load_measure_precent = (count * 1000) / ((total * 1000) / 100)
{
average: average,
percent: load_measure_precent,
state: state,
own: count,
total: total,
}
end
end

View file

@ -0,0 +1,51 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class Stats::TicketReopen
def self.generate(user)
count = StatsStore.count_by_search(
object: 'User',
o_id: user.id,
key: 'ticket:reopen',
start: Time.zone.now - 7.days,
end: Time.zone.now,
)
{
state: 'good',
own: count,
total: 0,
percent: 0,
average_percent: '-',
}
end
def self.log(object, o_id, changes, updated_by_id)
return if object != 'Ticket'
ticket = Ticket.lookup(id: o_id)
# check if close_time is already set / if not, ticket is not reopend
return if !ticket.close_time
return if !changes['state_id']
return if ticket.owner_id == 1
state_before = Ticket::State.lookup(id: changes['state_id'][0])
state_type_before = Ticket::StateType.lookup(id: state_before.state_type_id)
return if state_type_before.name != 'closed'
state_now = Ticket::State.lookup(id: changes['state_id'][1])
state_type_now = Ticket::StateType.lookup(id: state_now.state_type_id)
return if state_type_now.name == 'closed'
StatsStore.add(
object: 'User',
o_id: ticket.owner_id,
key: 'ticket:reopen',
data: { ticket_id: ticket.id },
created_at: Time.zone.now,
created_by_id: updated_by_id,
updated_by_id: updated_by_id,
)
end
end

View file

@ -0,0 +1,44 @@
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
class Stats::TicketResponseTime
def self.log(object, o_id)
return if object != 'Ticket'
ticket = Ticket.lookup(id: o_id)
article_created_by_id = 3
# check if response was sent by owner
return if ticket.owner_id != 1 && ticket.owner_id != article_created_by_id
# return if customer send at least
return if ticket.last_contact_customer > ticket.last_contact_agent
# TODO: only business hours
response_time_taken = ticket.last_contact_agent - ticket.last_contact_customer
(response_time_taken / 60).round
end
def self.generate(user)
items = Stats.where('action_at > ? AND action_at < ?', Time.zone.now - 7.days, Time.zone.now).where(key: 'ticket:response_time')
count_total = items.count
total = 0
count_own = 0
own = 0
items.each {|_item|
ticket = Ticket.lookup(id: data[:ticket_id])
if ticket.owner_id == user.id
count_own += 1
own += data[:time]
end
total += data[:time]
}
{
own: (own / count_own).round,
total: (total / count_total).round,
}
end
end