Skip to content

Instantly share code, notes, and snippets.

@xander-miller
Last active May 19, 2016 01:50
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save xander-miller/6757706 to your computer and use it in GitHub Desktop.
Save xander-miller/6757706 to your computer and use it in GitHub Desktop.
Ntile dashing widget. A Dashing numbers widget that puts your numbers in statistical context.

#Ntile Widget# A Dashing widget that shows numbers in statistical context. This widget is also available in a GitHub Repository.

Demo Dashboard Screenshot

##Description## A Dashing widget (and an associated job) that will take any set of timestamped data, divid that data by day (or any other unit of time) and compare those units of time, and displays the current day in statistical context of previous days. It currently has three modes of operation Quartile, Quintile and Percentile (hench 'N'tile). The code is designed to take in a variety of data sources. Examples include:

  • Display number of new users your app has attracted this week compared to previous weeks.
  • Display how many customer service tickets have been closed by an individual employee today in the context of their daily performance.
  • Display the performance of a sales team by amount sold this month in the context of previous month's performance.

##Dependencies## The job uses the 'Faker' gem and 'Active Support' gem to generate example data. These gems would not be necessary in deployment. Nonetheless if you want to run the example data add the folowing lines to your gem file.

gem 'faker'
gem 'activesupport'

##Installation##

###Gist Installation### On the command line:

dashing install 6757706

Done.

Note: If you want the widget to work as in the example you'll still need to install the example dashboard and add the example data gem dependencies to your gemfile.

###Manual Installation### Alternatively, if you prefer not to use Dashing Gist Installation, on the command line, use the Dashing generate command to make an ntile job and ntile widget:

dashing g widget Ntile
dashing g job Ntile

Then replace the following files with the ones provided in this gist:

  1. Replace ntile.rb in the /jobs/ directory.
  2. Replace ntile.coffee, ntile.scss and ntile.html in the /widget/ntile/ directory.

###Install Example Dashboard### To install an example dashboard identical to the one, in the screen shot. Use the Dashing generate command to create an exsales Dashboard:

dashing g dashboard Exsales

Then replace the generated dashboard file with the one provided in this gist:

  1. Replace exsales.erb in the /dashboards/ directory.

##Usage## The job that parses the data for display is implement as an Object Class called Cooper (An antiquated trade that made buckets, the object arranges data into buckets... Hey, I think it's clever). To create a Cooper instance based on your data arranged in an array with a hash representing each tuple or event. The keys for each event hash must be symbols, and the timestamp must be a Ruby Time instance. Here is a sample of appropriate data:

[{:timestamp=>2013-08-01 09:32:13 -0400, :event_type=>"Sale", :employee=>"Daniel B.", :sale_amount=>1172},
{:timestamp=>2013-08-01 19:37:53 -0400, :event_type=>"Sale", :employee=>"Ariane C.", :sale_amount=>271},
{:timestamp=>2013-08-01 19:48:31 -0400, :event_type=>"Sale", :employee=>"David U.", :sale_amount=>107},
{:timestamp=>2013-08-01 06:02:12 -0400, :event_type=>"Sale", :employee=>"Verner D.", :sale_amount=>1494},
{:timestamp=>2013-08-01 11:42:22 -0400, :event_type=>"Sale", :employee=>"Emilie H.", :sale_amount=>233},
{:timestamp=>2013-08-01 02:06:19 -0400, :event_type=>"Sale", :employee=>"David U.", :sale_amount=>1316},
{:timestamp=>2013-08-01 03:46:49 -0400, :event_type=>"Sale", :employee=>"Verner D.", :sale_amount=>1256},
{:timestamp=>2013-08-01 06:43:34 -0400, :event_type=>"Sale", :employee=>"Serena N.", :sale_amount=>558},
{:timestamp=>2013-08-01 10:36:14 -0400, :event_type=>"Sale", :employee=>"Ariane C.", :sale_amount=>1322},
{:timestamp=>2013-08-01 21:25:51 -0400, :event_type=>"Sale", :employee=>"Brandy M.", :sale_amount=>72},
{:timestamp=>2013-08-01 06:01:37 -0400, :event_type=>"Sale", :employee=>"Aiden J.", :sale_amount=>475},
{:timestamp=>2013-08-01 21:18:28 -0400, :event_type=>"Sale", :employee=>"Daniel B.", :sale_amount=>38},
{:timestamp=>2013-08-01 07:09:59 -0400, :event_type=>"Sale", :employee=>"Daniel B.", :sale_amount=>1156},
{:timestamp=>2013-08-01 08:14:10 -0400, :event_type=>"Sale", :employee=>"Daniel B.", :sale_amount=>223},
{:timestamp=>2013-08-01 22:52:59 -0400, :event_type=>"Sale", :employee=>"Verner D.", :sale_amount=>1201},
{:timestamp=>2013-08-01 10:33:43 -0400, :event_type=>"Sale", :employee=>"Ariane C.", :sale_amount=>521},
{:timestamp=>2013-08-01 12:38:22 -0400, :event_type=>"Sale", :employee=>"Daniel B.", :sale_amount=>1103},
{:timestamp=>2013-08-01 13:34:24 -0400, :event_type=>"Sale", :employee=>"Emilie H.", :sale_amount=>576},
{:timestamp=>2013-08-01 10:08:12 -0400, :event_type=>"Sale", :employee=>"Emilie H.", :sale_amount=>796},
{:timestamp=>2013-08-01 08:24:24 -0400, :event_type=>"Sale", :employee=>"Verner D.", :sale_amount=>1135},
{:timestamp=>2013-08-01 15:13:23 -0400, :event_type=>"Sale", :employee=>"Aiden J.", :sale_amount=>1348},
{:timestamp=>2013-08-01 01:05:56 -0400, :event_type=>"Sale", :employee=>"Ariane C.", :sale_amount=>573},
{:timestamp=>2013-08-01 05:37:17 -0400, :event_type=>"Sale", :employee=>"Ariane C.", :sale_amount=>217},
{:timestamp=>2013-08-01 07:49:41 -0400, :event_type=>"Sale", :employee=>"David U.", :sale_amount=>508},
{:timestamp=>2013-08-01 02:32:02 -0400, :event_type=>"Sale", :employee=>"Daniel B.", :sale_amount=>692},
{:timestamp=>2013-08-01 06:22:43 -0400, :event_type=>"Sale", :employee=>"Verner D.", :sale_amount=>548},]

Once your data is formated the next critical step is creating an instance of Cooper for your data with the appropriate optional parameters. The optional parameters have the following function:

  • :ntile Selects the display mode default it 5 for Quintile other modes are 4 for Quartile and 100 for Percentile.
  • :timestamp_label Indicates the symbol used in the dataset for the timestamp data, default is :timestamp.
  • :bucket_shape buckets are devided by a Cooper instance based on the Ruby Time Class strftime method. The default vaule is '%b%d%y' which will create a unique label for each day. To change the periods Cooper uses just write a strftime statement that uniquily identifies those periods. Eg. '%V%G' for week based.
  • :sum_label Toggles the buckets between count and suming modes by setting it to the symbol of the tuple you want to sum. Default value is nil for count mode. Eg. If you wanted to sum the dollar amount of sales using the preceeding sample data you would set it to the symbol :sales_amount.

After the Cooper instance has been created it is just a matter of matching function calls to different elements in the scheduler in the same manner as the example widgets.

send_event('sales', { value: sales_cooper.calc_current_value, 
    rank: sales_cooper.calc_current_rank,
    suffix: sales_cooper.rank_suffix,
    mode: sales_cooper.ntile_mode,
    next_text: sales_cooper.next_text,
    next_rank_value: sales_cooper.next_rank_value })

Enjoy!

<div class="gridster">
<ul>
<li data-row="1" data-col="1" data-sizex="2" data-sizey="1">
<div data-id="welcome" data-view="Text" data-title="Ntile Widget Example" data-text="Ntile can take any time stamped data and put it in context. Here are some examples." data-moreinfo="Note: The example sales performance by employee is relative to that employee's previous performance."></div>
</li>
<li data-row="1" data-col="1" data-sizex="1" data-sizey="1">
<div data-id="signup" data-view="Ntile" data-title="Today's Sign Ups" data-moreinfo="" data-prefix="" style="background-color:#1E3587"></div>
</li>
<li data-row="1" data-col="1" data-sizex="1" data-sizey="1">
<div data-id="events" data-view="Ntile" data-title="Today's Events" data-moreinfo="Sales, Sign Ups & Service" data-prefix="" style="background-color:#1E3587"></div>
</li>
<li data-row="1" data-col="1" data-sizex="1" data-sizey="1">
<div data-id="sales" data-view="Ntile" data-title="Today's Sales" data-moreinfo="" data-prefix="$"></div>
</li>
<li data-row="1" data-col="1" data-sizex="1" data-sizey="1">
<div data-id="david" data-view="Ntile" data-title="Sales by David U." data-moreinfo="" data-prefix="$"></div>
</li>
<li data-row="1" data-col="1" data-sizex="1" data-sizey="1">
<div data-id="daniel" data-view="Ntile" data-title="Sales by Daniel B." data-moreinfo="" data-prefix="$"></div>
</li>
<li data-row="1" data-col="1" data-sizex="1" data-sizey="1">
<div data-id="serena" data-view="Ntile" data-title="Sales by Serena N." data-moreinfo="" data-prefix="$"></div>
</li>
</ul>
</div>
class Dashing.Ntile extends Dashing.Widget
@accessor 'value', Dashing.AnimatedValue
@accessor 'rank', Dashing.AnimatedValue
@accessor 'next_rank_value', Dashing.AnimatedValue
ready: ->
# This is fired when the widget is done being rendered
onData: (data) ->
# Handle incoming data
# You can access the html node of this widget with `@node`
# Example: $(@node).fadeOut().fadeIn() will make the node flash each time data comes in.
<h1 class="title" data-bind="title"></h1>
<p>
<span class="next-text" data-bind="next_text"></span>
<span class="next-rank" data-bind="next_rank_value | prepend prefix"></span>
</p>
<div class="rank-box">
<p><span class="rank" data-bind="rank"></span><span class="suffix" data-bind="suffix"></span></p>
<p class="mode" data-bind="mode"></p>
</div>
<p>Current Value <span class="value" data-bind="value | prepend prefix"></span></p>
<p class="more-info" data-bind="moreinfo | raw"></p>
<p class="updated-at" data-bind="updatedAtMessage"></p>
#Required to generate fake data.
require 'faker'
require 'active_support/all'
#Required to run in production
require 'time'
#Generate Dummy Data
def fake_it duration = 128
sales_staff = ["David U.","Daniel B.","Serena N."]
6.times do
sales_staff << "#{Faker::Name.first_name} #{Faker::Name.last_name.first}."
end
support_staff = ["David U.","Daniel B.","Serena N."]
2.times do
support_staff << "#{Faker::Name.first_name} #{Faker::Name.last_name.first}."
end
fake_data = []
for each_day in 0..duration
# Sign Ups
rand(5..17).times do
fake_data << {
timestamp: (Date.today - each_day).to_time + rand(1.day),
event_type: "Sign-Up"
}
end
# Sales
rand(64).times do
fake_data << {
timestamp: (Date.today - each_day).to_time + rand(1.day),
event_type: "Sale",
employee: sales_staff[rand(sales_staff.length)],
sale_amount: rand(10...1500)
}
end
# Support Ticket Closed
rand(20..56).times do
fake_data << {
timestamp: (Date.today - each_day).to_time + rand(1.day),
event_type: "Support",
employee: support_staff[rand(support_staff.length)]
}
end
end
fake_data
end
def fake_event #Generate a single fake event.
an_event = {}
sales_staff = ["David U.","Daniel B.","Serena N."]
support_staff = ["David U.","Daniel B.","Serena N."]
type = rand(1..6)
type = 2 if type > 3
case type
when 1
an_event = {
timestamp: Time.now,
event_type: "Sign-Up"
}
when 2
an_event = {
timestamp: Time.now,
event_type: "Sale",
employee: sales_staff[rand(sales_staff.length)],
sale_amount: rand(10...3000)
}
when 3
an_event = {
timestamp: Time.now,
event_type: "Support",
employee: support_staff[rand(support_staff.length)]
}
end
an_event
end
class Cooper # Maker of Barrels and Buckets and in this case sorter of buckets too.
attr_reader :current_rank, :current_value
@@coopers = []
def initialize(data_set, opt_params = {})
params = {ntile: 5, timestamp_label: :timestamp, bucket_shape: '%b%d%y', sum_label: nil}.merge!(opt_params)
@data_set = data_set
@ntile = params[:ntile]
@timestamp_label = params[:timestamp_label]
@sum_label = params[:sum_label]
@bucket_shape = params[:bucket_shape]
@buckets = {}
coop
@tally = []
tally_buckets
@current_rank = calc_current_rank
@current_value = calc_current_value
@current_bucket_label = Time.now.strftime(@bucket_shape).to_sym
@@coopers << self
end
def update_current(event)
@buckets[:current] << event
tally_buckets
end
def coop
@data_set.each do |item|
bucket_label = item[@timestamp_label].strftime(@bucket_shape).to_sym
@buckets.key?(bucket_label) ? @buckets[bucket_label] << item : @buckets[bucket_label] = [item]
end
if @buckets[@current_bucket_label]
@buckets[:current] = @buckets.delete(@current_bucket_label.to_sym)
else
@buckets[:current] = []
end
@buckets
end
def tally_buckets()
@tally = []
@buckets.each do |key,bucket|
if @sum_label
bucket_sum = 0
bucket.each {|event| bucket_sum += event[@sum_label]}
@tally << [key,bucket_sum]
else
@tally << [key,bucket.count]
end
end
@tally.sort! {|a,b| a[1] <=> b[1]}
@tally.each_index do |index|
@tally[index] = [@tally[index],(index.to_f/(@tally.length.to_f/@ntile)).to_i + 1].flatten!
end
end
def calc_current_rank
rank = 0
@tally.each {|tuple| rank = tuple[2] if tuple[0] == :current}
@current_rank = rank
rank
end
def calc_current_value
value = 0
@tally.each do |tuple|
if tuple[0] == :current
value = tuple[1]
end
end
@current_value = value
value
end
def next_rank_value
rank_up = 0
calc_current_rank
@tally.each do |tuple|
rank_up = tuple[1] + 1 if (rank_up == 0) and (@current_rank < tuple[2])
end
rank_up = @current_value if rank_up == 0
rank_up
end
def next_text
return_str = ""
if @tally[-1][0] == :current
return_str = "Best Ever! "
elsif @tally[-1][2] == @current_rank
return_str = "Beat the Record "
else
return_str = "Next Rank at "
end
return_str
end
def ntile_mode
mode = ""
if @ntile == 5
mode = "Qunitile"
elsif @ntile == 4
mode = "Quartile"
elsif @ntile == 100
mode = "Percentile"
end
mode
end
def rank_suffix
Cooper.calc_rank_suffix(calc_current_rank)
end
def current_bucket_current?
return_value = false
test_label = Time.now.strftime(@bucket_shape).to_sym
return_value = true if @current_bucket_label == test_label
return_value
end
private
def self.calc_rank_suffix(rank)
suffix = ""
if (rank.to_s[-1] == "1") and (rank != 12)
suffix = "st"
elsif (rank.to_s[-1] == "2") and (rank != 12)
suffix = "nd"
elsif (rank.to_s[-1] == "3") and (rank != 13)
suffix = "rd"
else
suffix = "th"
end
suffix
end
end
#Generate and then separate out data sets needed for each widget instance.
fake_data = fake_it
signup_data = [].replace(fake_data)
signup_data.keep_if {|item| item[:event_type] == "Sign-Up"}
sales_data = [].replace(fake_data)
sales_data.keep_if {|event| event[:event_type] == "Sale"}
david_sales_data = [].replace(sales_data)
david_sales_data.keep_if {|event| event[:employee] == "David U."}
serena_sales_data = [].replace(sales_data)
serena_sales_data.keep_if {|event| event[:employee] == "Serena N."}
daniel_sales_data = [].replace(sales_data)
daniel_sales_data.keep_if {|event| event[:employee] == "Daniel B."}
# Generate separate widgets.
events_cooper = Cooper.new(fake_data, {ntile: 100})
signup_cooper = Cooper.new(signup_data)
sales_cooper = Cooper.new(sales_data, {ntile: 100, sum_label: :sale_amount})
david_sales_cooper = Cooper.new(david_sales_data, {ntile: 100, sum_label: :sale_amount})
serena_sales_cooper = Cooper.new(serena_sales_data, {ntile: 100, sum_label: :sale_amount})
daniel_sales_cooper = Cooper.new(daniel_sales_data, {ntile: 100, sum_label: :sale_amount})
# :first_in sets how long it takes before the job is first run. In this case, it is run immediately
SCHEDULER.every '3s', :first_in => 0 do |job|
an_event = fake_event
events_cooper.update_current(an_event)
# Route a newly generated event to the appropriate Cooper instances.
if an_event[:event_type] == "Sign-Up"
signup_cooper.update_current(an_event)
elsif an_event[:event_type] == "Sale"
sales_cooper.update_current(an_event)
case an_event[:employee]
when "David U."
david_sales_cooper.update_current(an_event)
when "Daniel B."
daniel_sales_cooper.update_current(an_event)
when "Serena N."
serena_sales_cooper.update_current(an_event)
end
end
# Checks to see if dashboard has run into a new bucket period and then rebuilds buckets if it has.
events_cooper = Cooper.new(fake_data, {ntile: 100}) unless events_cooper.current_bucket_current?
signup_cooper = Cooper.new(signup_data) unless signup_cooper.current_bucket_current?
sales_cooper = Cooper.new(sales_data, {ntile: 100, sum_label: :sale_amount}) unless sales_cooper.current_bucket_current?
david_sales_cooper = Cooper.new(david_sales_data, {ntile: 100, sum_label: :sale_amount}) unless david_sales_cooper.current_bucket_current?
serena_sales_cooper = Cooper.new(serena_sales_data, {ntile: 100, sum_label: :sale_amount}) unless serena_sales_cooper.current_bucket_current?
daniel_sales_cooper = Cooper.new(daniel_sales_data, {ntile: 100, sum_label: :sale_amount}) unless daniel_sales_cooper.current_bucket_current?
# Send Updates to be displayed
send_event('events', { value: events_cooper.calc_current_value,
rank: events_cooper.calc_current_rank,
suffix: events_cooper.rank_suffix,
mode: events_cooper.ntile_mode,
next_text: events_cooper.next_text,
next_rank_value: events_cooper.next_rank_value })
send_event('signup', { value: signup_cooper.calc_current_value,
rank: signup_cooper.calc_current_rank,
suffix: signup_cooper.rank_suffix,
mode: signup_cooper.ntile_mode,
next_text: signup_cooper.next_text,
next_rank_value: signup_cooper.next_rank_value })
send_event('sales', { value: sales_cooper.calc_current_value,
rank: sales_cooper.calc_current_rank,
suffix: sales_cooper.rank_suffix,
mode: sales_cooper.ntile_mode,
next_text: sales_cooper.next_text,
next_rank_value: sales_cooper.next_rank_value })
send_event('david', { value: david_sales_cooper.calc_current_value,
rank: david_sales_cooper.calc_current_rank,
suffix: david_sales_cooper.rank_suffix,
mode: david_sales_cooper.ntile_mode,
next_text: david_sales_cooper.next_text,
next_rank_value: david_sales_cooper.next_rank_value })
send_event('daniel', { value: daniel_sales_cooper.calc_current_value,
rank: daniel_sales_cooper.calc_current_rank,
suffix: daniel_sales_cooper.rank_suffix,
mode: daniel_sales_cooper.ntile_mode,
next_text: daniel_sales_cooper.next_text,
next_rank_value: daniel_sales_cooper.next_rank_value })
send_event('serena', { value: serena_sales_cooper.calc_current_value,
rank: serena_sales_cooper.calc_current_rank,
suffix: serena_sales_cooper.rank_suffix,
mode: serena_sales_cooper.ntile_mode,
next_text: serena_sales_cooper.next_text,
next_rank_value: serena_sales_cooper.next_rank_value })
end
// ----------------------------------------------------------------------------
// Sass declarations
// ----------------------------------------------------------------------------
$background-color: #7BDB3B;
$value-color: #fff;
$title-color: rgba(255, 255, 255, 0.7);
$moreinfo-color: rgba(255, 255, 255, 0.7);
// ----------------------------------------------------------------------------
// Widget-number styles
// ----------------------------------------------------------------------------
.widget-ntile {
background-color: $background-color;
.title {
color: $title-color;
}
.value {
color: $value-color;
}
.next-rank {
font-weight: 500;
font-size: 24px;
color: $value-color;
}
.updated-at {
color: rgba(0, 0, 0, 0.3);
}
.rank {
text-transform: uppercase;
font-size: 76px;
font-weight: 700;
padding-bottom: 0px;
margin-bottom: 0px;
line-height:70%
}
.suffix {
text-transform: uppercase;
font-size: 36px;
font-weight: 700;
padding-bottom: 0px;
margin-bottom: 0px;
line-height:70%
}
.rank-box {
display: block;
padding-bottom: 30px;
padding-top: 30px;
}
.mode {
font-size: 15px;
line-height:70%
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment