Here ya go! The board ID is hardcoded and it makes assumptions about the way you store your beeminder token, but you can adjust those as desired, of course. Wrote it against Ruby 3, dunno if i’m using any lang-version-specific features. Holler if you’ve got any questions!
#!/usr/bin/env ruby
require 'json'
require 'net/http'
require 'optparse'
TRELLO_CACHE="/tmp/slow-cleaning-check-trello"
BEE_CACHE="/tmp/slow-cleaning-check-bee"
options = {
before: Time.now,
after: Time.new(2022, 06, 14),
frequencies: %w(Fortnightly Monthly Seasonally Semiannually Yearly),
}
OptionParser.new do |opts|
opts.banner = "Usage: #{$0} [options]"
opts.on("-h", "--help", "Prints this help and exits") do
puts opts
exit
end
opts.on("-T", "--fetch-trello", "Delete cached data from trello and refetch") do
File.delete(TRELLO_CACHE) if File.exist? TRELLO_CACHE
end
opts.on("-B", "--fetch-beeminder", "Delete cached data from beeminder and refetch") do
File.delete(BEE_CACHE) if File.exist? BEE_CACHE
end
opts.on("-q", "--quiet", "Suppress some output") do
options[:quiet] = true
end
opts.on('-iTASK', '--inspect TASK', "Get deep details about a specific task") do |t|
options[:details] = t
end
opts.on('-bDATE', '--before DATE', "Latest date to consider (default: now)") do |d|
options[:before] = Time.new(d)
end
opts.on('-aDATE', '--after DATE', "Earliest date to consider (default: 2022-06-14)") do |d|
options[:after] = Time.new(d)
end
opts.on('-fFREQ', '--frequencies FREQ', "Comma-separated list of frequencies to check") do |f|
options[:frequencies] = f.split(',').map{|f| f.strip}
end
opts.on('-s', '--summary', 'Print a stats summary of required counts') do
options[:summary] = true
end
end.parse!
class Array
def median
return Float::INFINITY if self.length == 0
sorted = self.sort
mid = (sorted.length - 1) / 2.0
(sorted[mid.floor] + sorted[mid.ceil]) / 2.0
end
end
class String
def red; "\e[31m#{self}\e[0m" end
def green; "\e[32m#{self}\e[0m" end
end
########################
# slurp data from trello
def trello(path)
"https://api.trello.com/1/boards/GMfBhGCV#{path}"
end
def json(url)
JSON.parse(Net::HTTP.get_response(URI.parse(url)).body)
end
unless File.exist? TRELLO_CACHE then
puts "Fetching data from Trello..." unless options[:quiet]
File.open(TRELLO_CACHE, 'wb') { |f| f.write(Marshal.dump({
lists: json(trello '/lists'),
cards: json(trello '/cards')
}))}
end
trello_data = Marshal.load(File.binread(TRELLO_CACHE))
lists = trello_data[:lists]
.select{|list| %w(Fortnightly Monthly Seasonally Semiannually Yearly Inactive).include? list["name"] }
.map{|list| [list["id"], list["name"]]}
.to_h
@cards = trello_data[:cards]
.select{|card| lists.has_key? card["idList"]}
.map do |card|
{ name: card["name"],
task: [card["name"], card["desc"]].join(' '),
frequency: lists[card["idList"]]
}
end
@tasks = @cards.map{|card| card[:task]}
# slurp data from trello
########################
########################
# slurp data from beeminder
unless File.exist? BEE_CACHE then
puts "Fetching data from Beeminder..." unless options[:quiet]
File.open(BEE_CACHE, 'wb') { |f| f.write(Marshal.dump(
JSON.parse(`beemapi datapoints slow-cleaning`)
)) }
end
@datapoints = Marshal.load(File.binread(BEE_CACHE)).map do |point|
{ when: Time.at(point["timestamp"].to_i),
regex: Regexp.new(point["comment"].strip.gsub('; ', '|'), Regexp::IGNORECASE),
value: point["value"].to_f,
text: point["comment"],
}
end.reject do |point|
point[:regex].to_s =~ /RECOMMIT|IGNORE_METRICS/
end.filter do |point|
options[:after] <= point[:when] && point[:when] <= options[:before]
end
# slurp data from beeminder
########################
########################
# validate beedata
validation_failures = @datapoints.each_with_object({}) do |point, hash|
key = point[:regex].to_s
unless hash.has_key? key
hash[key] = {
tasks: @tasks.filter{|task| task =~ point[:regex] },
points: []
}
end
hash[key][:points].push point if point[:value] != hash[key][:tasks].length
end.reject do |_key, entry|
entry[:points].empty?
end
unless validation_failures.nil?
validation_failures.entries.each do |regex, entry|
fails = entry[:points].map{|point| "(#{point[:when].strftime('%F')}, #{point[:value]})" }.join(', ')
tasks = entry[:tasks]
puts "[#{entry[:points].length}] Point #{regex} matches #{tasks.length} tasks. #{fails}"
if tasks.length > 1
tasks.each{|t| puts " -> #{t.gsub(/\n/, '\\n')}"}
end
end
end
# validate beedata
########################
def daygap(freq)
case freq
when 'Fortnightly' then 14.0
when 'Monthly' then 30.436875
when 'Seasonally' then 91.310625
when 'Semiannually' then 182.62125
when 'Yearly'then 365.2425
else raise "bogus frequency '#{freq}'"
end
end
def details(card)
points = @datapoints
.filter{|p| card[:task] =~ p[:regex]}
.sort_by{|p| p[:when]}
deltas = points
.map{|p| p[:when]}
.each_cons(2)
.map {|(a, b)| (b - a) / 60 / 60 / 24}
daygap = daygap(card[:frequency])
average = if deltas.length > 0 then
(deltas.sum / deltas.length)
else
Float::INFINITY
end
avg_percent = (average - daygap) / daygap
median = deltas.median
med_percent = (median - daygap) / daygap
{points:, deltas:, daygap:, average:, median:, avg_percent:, med_percent:}
end
def print_table(headers, rows)
cols = headers.map{|(col, _)| col}
table = headers.each_with_object({}) do |(col, label), hash|
hash[col] = { label:, width: [rows.map{|r| r[col].to_s.size}.max, label.size].max }
end
print_row = lambda do |row|
cells = cols.map do |c|
cell = row[c].to_s.ljust(table[c][:width])
cell = cell.send(row[:color]) unless row[:color].nil?
cell
end
puts "| #{cells.join(" | ")} |"
end
print_row.call headers
puts "+-#{cols.map{|c| '-' * table[c][:width]}.join('-+-')}-+"
rows.each{|row| print_row.call row}
end
########################
# summary
if options[:summary] then
headers = {
freq: 'Frequency',
count: '#',
cumulative: 'Total',
pweek: 'Tasks per week',
pday: 'Tasks per day'
}
rows = options[:frequencies].each_with_object([]) do |freq, r|
if r.empty?
last = 0
else
prev_per_this = daygap(freq) / daygap(r.last[:freq])
last = r.last[:cumulative] * prev_per_this
end
count = @cards.filter{|c| c[:frequency] == freq}.length
cumulative = (last + count).round(2)
pday = (cumulative / daygap(freq)).round(2)
pweek = (pday * 7).round(2)
r.push({ freq:, count:, cumulative:, pweek:, pday:, })
end
print_table(headers, rows)
exit
end
# summary
########################
########################
# inspect
if options[:details] then
card = @cards.filter{|c| c[:name] == options[:details]}
if card.length != 1
puts "Error: specified task does not match exactly one card"
exit 1
end
card = card[0]
puts "# #{card[:name]}"
puts "List: #{card[:frequency]}"
expected = daygap(card[:frequency])
puts "Expected: #{expected}"
details(card) => {points:, deltas:, median:, med_percent:}
percent = ''
unless med_percent == Float::INFINITY
direction = if med_percent.positive? then 'above' else 'below' end
percent = " (#{(med_percent.abs * 100).round}% #{direction} expected)"
end
puts "Median: #{median}#{percent}"
puts "Completed: #{points.length} times"
puts "Matching datapoints:"
points.reverse.zip(deltas.reverse).each do |point, delta|
puts " - #{delta&.round(2).to_s.ljust(6)} :: #{point[:when].strftime('%F')} #{point[:value]} #{point[:text]}"
end
exit
end
# inspect
########################
########################
# stats
options[:frequencies].each do |freq|
daygap = daygap(freq)
puts "# #{freq} (Expected daygap: #{daygap})"
print_table(
{task: 'Task', times: '✓', med: 'Median (%err)', avg: 'Avg (%err)'},
@cards.select{|c| c[:frequency] == freq}.map do |card|
task = card[:name].gsub(/\n/, '\\n')
details(card) => {points:, deltas:, average:, median:, avg_percent:, med_percent:}
times = points.length
raw_percent = [avg_percent, med_percent].min
avg = average.round(1).to_s.gsub(/Infinity/, '—')
avg += " (#{(avg_percent * 100).round(1)}%)" if average < Float::INFINITY
med = median.round(1).to_s.gsub(/Infinity/, '—')
med += " (#{(med_percent * 100).round(1)}%)" if median < Float::INFINITY
color = if med_percent.abs < 0.2
'green'
elsif (med_percent < Float::INFINITY && 0.5 < med_percent.abs) || times == 0
'red'
end
row = { task:, avg:, med:, times:, raw_percent:, color: }
end.sort_by{|c| c[:raw_percent]}
)
puts
end
# stats
########################