Last active
December 24, 2015 06:09
-
-
Save ptzn/6754776 to your computer and use it in GitHub Desktop.
Generate HTML report from CSV file generated by TimeTracker.app
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# | |
# Generate HTML report from CSV generated by TimeTracker.app | |
# Options: | |
# -i, --input - path to csv file | |
# -s, --start-time - report start time | |
# -e, --end-time - report end time (today by default) | |
# -p, --project - project name to include into report (all by default) | |
# -d, --delimiter - CSV delimiter ("," by default) | |
# -o, --out - directory to write report | |
# Examples: | |
# ./report -s 2013-09-01 -e 2013-09-28 -p "BrandRep" -i ./times.csv | |
# ./report -s 2013-09-01 -e 2013-09-28 -p "BrandRep" -i ./times.csv -o /tmp | |
require 'time' | |
require 'optparse' | |
require 'optparse/time' | |
class Period | |
TIME_FORMATS = { | |
long: '%A, %B %d, %Y, %H:%M:%S', | |
short: '%Y%m%d', | |
default: '%m/%d/%Y, %H:%M:%S' | |
} | |
attr_reader :start, :end | |
def initialize(period_start, period_end) | |
@start, @end = if period_start.is_a?(String) && period_end.is_a?(String) | |
[Time.parse(period_start), Time.parse(period_end)] | |
else | |
[period_start, period_end] | |
end | |
end | |
def between?(period) | |
self.start.between?(period.start, period.end) | |
end | |
def duration | |
self.end - self.start | |
end | |
def start_s(format = :default) | |
self.start.strftime(TIME_FORMATS[format]) | |
end | |
def end_s(format = :default) | |
self.end.strftime(TIME_FORMATS[format]) | |
end | |
end | |
class Task < Struct.new(:project, :name, :period); end | |
class CSVParser | |
class << self | |
def parse(filename, delimiter, project, period) | |
filtered = [] | |
# drop to skip CSV headers line | |
File.open(filename, 'r').each_line.drop(1).each do |line| | |
task = line_to_task(line, delimiter) | |
next if (project && project != task.project) || !task.period.between?(period) | |
filtered << task | |
end | |
filtered | |
end | |
private | |
def line_to_task(line, delimiter) | |
values = line.split("\"#{delimiter}\"") | |
values[0].gsub!(/^"/, '') | |
values[-1].gsub!(/"$/, '') | |
Task.new(values[0], values[1], Period.new(values[3], values[4])) | |
end | |
end | |
end | |
class Report < Struct.new(:data, :period, :out_dir) | |
NAME = 'PUT YOUR NAME HERE' | |
def generate! | |
File.open(filename, 'wb') do |f| | |
f << header | |
f << summary | |
f << details | |
f << footer | |
end | |
end | |
private | |
def filename | |
project_name = projects.size > 1 ? '' : "_#{projects.first}" | |
"#{out_dir}/report#{project_name}_#{period.start_s(:short)}-#{period.end_s(:short)}.html" | |
end | |
def format_duration(duration_in_seconds) | |
duration_in_minutes = (duration_in_seconds / 60).to_i | |
seconds = duration_in_seconds % 60 | |
minutes = duration_in_minutes % 60 | |
hours = (duration_in_minutes / 60).to_i | |
text = "%02d:%02d" % [minutes, seconds] | |
text = ('%02d:' % hours) + text if hours > 0 | |
text | |
end | |
def periods | |
@periods ||= data.flatten.map(&:period).sort_by(&:start) | |
end | |
def total_duration(periods) | |
periods.map(&:duration).inject(:+) | |
end | |
def grouped_by_project | |
@grouped_by_project ||= data.group_by(&:project) | |
end | |
def projects | |
@projects ||= grouped_by_project.keys | |
end | |
def project_periods(project) | |
grouped_by_project[project].map(&:period).sort_by(&:start) | |
end | |
def project_tasks(project) | |
grouped_by_project[project].group_by(&:name) | |
end | |
def header | |
<<HTML | |
<html><head><title>Report for #{NAME}.</title> | |
<style> | |
h1, h2 { color: #008000; } | |
table { border: 2px solid #87CEFA; } | |
td { background: #F0F8FF; } | |
h3 { color: #000080; } | |
.header { | |
background: #DFEFFF; | |
font-weight: bold; | |
text-align: center; | |
} | |
.projectdata { | |
vertical-align: top; | |
text-align: right; | |
} | |
.projectname, .taskname { vertical-align: top; } | |
.perioddata, .taskdata { | |
font-size: x-small; | |
vertical-align: top; | |
text-align: right; | |
} | |
</style></head><body><h2>Report for #{NAME}.</h2> | |
HTML | |
end | |
def summary | |
<<HTML | |
<h3>Summary</h3><table> | |
<tr><td class="header">First date:</td><td>#{periods.first.start_s(:long)}</td></tr> | |
<tr><td class="header">Last date:</td><td>#{periods.last.end_s(:long)}</td></tr> | |
<tr><td class="header">Total time:</td><td>#{format_duration(total_duration(periods))}</td></tr></table> | |
HTML | |
end | |
def details | |
result = <<HTML | |
<h3>Detailed</h3><table> | |
<tr> | |
<td class="header">Project</td> | |
<td class="header">Begin</td> | |
<td class="header">End</td> | |
<td class="header">Duration</td> | |
</tr> | |
HTML | |
projects.each.with_index do |project, project_index| | |
project_number = project_index + 1 | |
project_periods = project_periods(project) | |
result << <<HTML | |
<tr> | |
<td class="projectname">#{project_number}. #{project}</td> | |
<td class="projectdata">#{project_periods.first.start_s}</td> | |
<td class="projectdata">#{project_periods.last.end_s}</td> | |
<td class="projectdata">#{format_duration(total_duration(project_periods))}</td> | |
</tr> | |
HTML | |
tasks = project_tasks(project) | |
tasks.each.with_index do |(name, tasks), task_index| | |
task_number = task_index + 1 | |
task_periods = tasks.map(&:period).sort_by(&:start) | |
result << "<tr><td class='taskname'>#{project_number}.#{task_number}. #{name}</td>" | |
result << "<td class='taskdata'>#{task_periods.first.start_s}" | |
result << "<p class='perioddata'>" | |
result << task_periods.map(&:start_s).join('<br/>') | |
result << "</p></td>" | |
result << "<td class='taskdata'>#{task_periods.last.end_s}<p class='perioddata'>" | |
result << task_periods.map(&:end_s).join('<br/>') | |
result << "</p></td>" | |
result << "<td class='taskdata'>#{format_duration(total_duration(task_periods))}<p class='perioddata'>" | |
result << task_periods.map { |period| format_duration(period.duration) }.join('<br/>') | |
result << "</p></td></tr>" | |
end | |
end | |
result << "</table>" | |
result | |
end | |
def footer | |
'</body></html>' | |
end | |
end | |
options = { | |
delimiter: ',', | |
end_time: Date.today.to_time | |
} | |
OptionParser.new do |opts| | |
opts.on("-i", "--input Path", "Path to CSV file") do |csv_file| | |
options[:csv_file] = csv_file | |
end | |
opts.on("-o", "--out Path", "Path to directory for generated report") do |out_dir| | |
options[:out_dir] = out_dir | |
end | |
opts.on("-s", "--start-time Time", Time, "Report start time") do |time| | |
options[:start_time] = time | |
end | |
opts.on("-e", "--end-time [Time]", Time, "Report end time") do |time| | |
options[:end_time] = time | |
end | |
opts.on("-p", "--project [Project]", "Project name") do |project| | |
options[:project] = project | |
end | |
opts.on("-d", "--delimiter [Delimiter]", "Columns delimiter, comma by default") do |delimiter| | |
options[:delimiter] = delimiter | |
end | |
end.parse! | |
report_period = Period.new(options[:start_time], options[:end_time]) | |
tasks = CSVParser.parse(options[:csv_file], options[:delimiter], options[:project], report_period) | |
Report.new(tasks, report_period, options[:out_dir]).generate! | |
exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment