Skip to content

Instantly share code, notes, and snippets.

@ptzn
Last active December 24, 2015 06:09
Show Gist options
  • Save ptzn/6754776 to your computer and use it in GitHub Desktop.
Save ptzn/6754776 to your computer and use it in GitHub Desktop.
Generate HTML report from CSV file generated by TimeTracker.app
#!/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