ascii-tracker 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: b848e61d0882fc155ab6294909494690761926e7
4
+ data.tar.gz: d22e74f6102673c75c40e3fc2e58a41c6f1b713a
5
+ SHA512:
6
+ metadata.gz: a42bcb45db21c925574b5ca04bd1fc9ca144f562f34c8fbe8379927b6dba116cbf128c2e9845f232e7f6e25eaaf8d8a13186a74d8a83c86c20970dc5949589cc
7
+ data.tar.gz: 16f78a6fd81fb919caf62ad6899eeb3597ea5d5d85f93d7618a91ae28545806e443396fbba80d0eefefc9fbb31a6247084c0ec8ba70cc406f7a2749b6d05749c
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in asciitracker.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,9 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard :rspec, all_on_start: true, cli: '--format nested --debug --color' do
5
+ watch(%r{^spec/.+_spec\.rb$})
6
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
7
+ watch('spec/spec_helper.rb') { "spec" }
8
+ watch('lib/asciitracker.rb') { "spec" }
9
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2008/13 dirk luesebrink. See LICENSE for details.
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # ASCII Timecard tracking
2
+
3
+ Text file based time tracking, no web app, no mouse clicking.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'asciitracker'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install asciitracker
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,36 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'asciitracker/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "ascii-tracker"
8
+ spec.version = AsciiTracker::VERSION
9
+ spec.authors = ["dirk lu\xCC\x88sebrink"]
10
+ spec.email = ["dirk.luesebrink@gmail.com"]
11
+ spec.description = %q{
12
+ keeping track of time in a textfile. now web app, no mouse clicking. No GUI
13
+ is as easy as "12:13-17:34", and no time tracker i know of allows you to
14
+ add interrupts like: '0:13 pause: phone call with mum'
15
+ }
16
+ spec.summary = %q{time tracking the ascii way}
17
+ spec.homepage = ""
18
+ spec.license = "MIT"
19
+
20
+ spec.files = `git ls-files`.split($/)
21
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
22
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_dependency "applix"
26
+
27
+ spec.add_development_dependency "bundler", "~> 1.3"
28
+ spec.add_development_dependency "rake"
29
+ spec.add_development_dependency 'guard-rspec'
30
+
31
+ if RUBY_PLATFORM.match /java/i
32
+ spec.add_development_dependency 'ruby-debug'
33
+ else
34
+ spec.add_development_dependency 'debugger'
35
+ end
36
+ end
data/bin/atracker.rb ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'bundler/setup'
5
+
6
+ require 'pp'
7
+ require 'ostruct'
8
+ require 'applix'
9
+ require 'asciitracker'
10
+
11
+ # example cmdline:
12
+ #
13
+ # $ ./slotter.rb --report=2013-02-01--2013-03-01.txt \
14
+ # scan ~/.timecard \
15
+ # range 2013-02-01 2013-03-01 \
16
+ # group report
17
+ #
18
+ Applix.main(ARGV, debug: false) do
19
+
20
+ class Context
21
+ attr_reader :argv, :values
22
+ def initialize argv, config
23
+ @argv = argv
24
+ @values = OpenStruct.new(config)
25
+ end
26
+
27
+ def set_default_values defaults
28
+ @values = OpenStruct.new(defaults.merge(@values.marshal_dump))
29
+ end
30
+
31
+ def forward(target)
32
+ begin
33
+ (op = @argv.shift) && target.send(op, self)
34
+ rescue ArgumentError => e
35
+ target.send(op)
36
+ end
37
+ end
38
+ end
39
+
40
+ prolog do |argv, config|
41
+ @context = Context.new(argv, config)
42
+ @app = AsciiTracker::App.new(@context)
43
+ end
44
+
45
+ handle(:any) do |*args, opts|
46
+ @context.forward(@app)
47
+ end
48
+ end
@@ -0,0 +1,189 @@
1
+ module AsciiTracker
2
+ class App
3
+ def initialize(context)
4
+ context.set_default_values :report => "report.txt"
5
+ @c = Controller.new
6
+ end
7
+
8
+ def stop
9
+ puts "Stop!"
10
+ end
11
+
12
+ def foo context
13
+ puts "Hey!"
14
+ context.forward(self)
15
+ end
16
+
17
+ def scan context
18
+ filename = context.argv.shift
19
+ puts "scanning file: #{filename}"
20
+ AsciiTracker::Parser.parse @c, IO.read(filename)
21
+ #puts "records:\n#{records.join("\n")}"
22
+ #puts "--> 2: #{@c.model}"
23
+ #puts "--> 3: #{@c.model.records}"
24
+
25
+ context.forward(self)
26
+ end
27
+
28
+ def group(context)
29
+ @groups = group_by_project
30
+ @groups.each do |project_id, records|
31
+ @groups[project_id] = {
32
+ project_id: project_id,
33
+ records: records,
34
+ total: records.inject(0.0) { |sum, rec| sum + rec.span }
35
+ }
36
+ end
37
+
38
+ # XXX: this only works when assuming full days
39
+ @holidays = @groups["holidays"].records rescue []
40
+ @freedays = @groups["feiertag"].records rescue []
41
+ @sickdays = @groups["sickdays"].records rescue []
42
+ @abbau = @groups["ueberstundenabbau"].records rescue []
43
+
44
+ context.forward(self)
45
+ end
46
+
47
+ def weekdays_in_range(first_day, last_day)
48
+ range = (first_day...last_day)
49
+ #(t..(t+7)).select { |e| [0,6].include? e.wday }.map { |e| e.to_s
50
+ puts "================#{range}"
51
+ range.reject { |e| [0,6].include? e.wday }.size
52
+ end
53
+
54
+ def report(context)
55
+ append_or_overwrite = context.values.append ? "a+" : "w"
56
+ report = File.open context.values.report, append_or_overwrite
57
+
58
+ workcount = weekdays_in_range(*@selection_range) \
59
+ - (sickcount = @sickdays.size) \
60
+ - (holicount = @holidays.size) \
61
+ - (freecount = @freedays.size) \
62
+
63
+ netto = total = @selection.inject(0.0) { |sum, rec| sum + rec.span }
64
+
65
+ %w{ishapes pause ueberstunden holidays feiertag}.each do |tag|
66
+ netto -= (pause = @groups[tag].total rescue 0)
67
+ end
68
+ #pause = @groups["pause"].total rescue 0
69
+ #netto -= pause
70
+ #abbau = @groups["ueberstundenabbau"].total rescue 0
71
+ #netto -= abbau
72
+ #netto -= @groups["holidays"].total rescue 0
73
+ #netto -= @groups["feiertag"].total rescue 0
74
+
75
+ report.puts(<<-EOT % [netto, total])
76
+ reporting period: #{@selection_range.join(" until ")}
77
+ #{@selection.size} records in #{@groups.size} groups
78
+ #{@workdays.size} days booked(#{workcount} working(weekdays), #{sickcount} sickdays, #{holicount} holidays, #{@freedays.size} freeday)
79
+ ---
80
+ %.2f netto working hours in total(%.2f brutto)
81
+ ---
82
+ freedays: #{@freedays.map {|rec| rec.date.strftime("%e.%b")}.join(", ") }
83
+ holidays: #{@holidays.map {|rec| rec.date.strftime("%e.%b")}.join(", ") }
84
+ sickdays: #{@sickdays.map {|rec| rec.date.strftime("%e.%b")}.join(", ") }
85
+ ---
86
+ EOT
87
+
88
+ @groups.each do |project_id, group|
89
+ #puts ">>>> #{project_id}:\n#{records.join("\n")}"
90
+ #total = group.records.inject(0.0) { |sum, rec| sum + rec.span }
91
+ #h1 = "#{group.total} hours #{group.project_id}"
92
+ if context.values.brief
93
+ p = [group.total, group.project_id]
94
+ report.puts("%6.2f hours #{group.project_id}" % p)
95
+ next
96
+ end
97
+
98
+ headline = group_head(group, workcount)
99
+ report.puts <<-EOT
100
+
101
+ #{headline}
102
+ #{'-' * headline.size}
103
+ EOT
104
+ group.records.each do |rec|
105
+ #report.puts ("%5.2f" % rec.span) << "\t#{rec}"
106
+ report.puts(("%s(%5.2f)" % [HHMM.new(rec.span), rec.span]) << "\t#{rec}")
107
+ end
108
+ end
109
+ report.puts <<-TXT
110
+ <<< end of reporting period: #{@selection_range.join(" until ")}
111
+
112
+ TXT
113
+ context.forward(self)
114
+ end
115
+
116
+ # grouping records by projects
117
+ def group_by_project
118
+
119
+ groups = {}
120
+ groups[:unaccounted] = @selection.dup
121
+
122
+ @c.model.projects.each do |project_id, expressions|
123
+ group = (groups[project_id] ||= [])
124
+ expressions.each do |re|
125
+ matching_records, rest = groups[:unaccounted].partition do |rec|
126
+ re.match(rec.desc)
127
+ end
128
+ group.push(*matching_records)
129
+ groups[:unaccounted] = rest
130
+ end
131
+ end
132
+ groups.delete_if { |k,v| v.nil? or v.empty? }
133
+ groups
134
+ end
135
+
136
+ def range(context)
137
+ a = Date.parse(context.argv.shift)
138
+ b = Date.parse(context.argv.shift)
139
+ puts "selected date range: #{a} #{b}"
140
+
141
+ select_in_range(a, b)
142
+ context.forward(self)
143
+ end
144
+
145
+ def before(context)
146
+ a = Date.parse(context.argv.shift)
147
+ select_in_range(Date.today - (365*10), a)
148
+ context.forward(self)
149
+ end
150
+
151
+ def after(context)
152
+ a = Date.parse(context.argv.shift)
153
+ select_in_range(a, Date.today+1)
154
+ context.forward(self)
155
+ end
156
+
157
+ def today(context)
158
+ a = Date.today
159
+ select_in_range(a, a+1)
160
+ context.forward(self)
161
+ end
162
+
163
+ def select_in_range first_day, last_day
164
+ @selection = []
165
+ @workdays = []
166
+ @selection_range = [first_day, last_day]
167
+ day = first_day
168
+ while day < last_day
169
+ dayrecs = @c.model.by_date(day)
170
+ @selection.push(*dayrecs)
171
+ @workdays.push(day) unless dayrecs.empty?
172
+ day += 1
173
+ end
174
+ puts "#{@selection.size} records in range"
175
+ end
176
+ private :select
177
+
178
+ def group_head group, work_days
179
+ per_day = "%.3f" % [group.total / work_days]
180
+ days = group.total.to_i / 8
181
+ h = group.total - (days * 8)
182
+ thours = "%.2f" % group.total
183
+ hours = "%.2f" % h
184
+ d,h,m = Record.hours_to_dhm(group.total)
185
+ "#{thours} hours | #{d}d #{h}h #{m}m | #{per_day} hours/day: #{group.project_id}"
186
+ end
187
+ end
188
+ end
189
+
@@ -0,0 +1,62 @@
1
+ module AsciiTracker
2
+ class Controller
3
+ attr_reader :model
4
+
5
+ def initialize
6
+ @model = Model.new
7
+ end
8
+
9
+ # match(:day, :slot) { |a,b| [[:day, *a], [:slot, *b]] }
10
+ # match(:day, :span) { |a,b| [[:day, *a], [:span, *b]] }
11
+ def new_day date
12
+ @day = date
13
+ @rec = @slot = nil
14
+ end
15
+
16
+ # match(:slot) { |a| [[:slot, *a]] }
17
+ def new_slot start, stop, desc = nil
18
+ #slot = Slot.new :date=>@day, :start=> start, :end=>stop, :desc=>desc
19
+ @rec = @slot = Slot.new(
20
+ :start => start, :end =>stop, :desc =>desc, :date => @day.dup
21
+ )
22
+
23
+ # updates parant records when this slot is an interruption
24
+ overlaps = @model.find_overlaps(@slot, @day)
25
+ puts "new slot(#{@slot}), overlaps: #{overlaps}"
26
+ unless overlaps.empty?
27
+ # parents are covers which are a subset of overlaps
28
+ if parent = @model.find_best_cover(@slot, @day)
29
+ parent.add_interrupt(@slot)
30
+ else
31
+ raise TimecardException, <<-EOM
32
+ #{@slot}
33
+ overlaps with:
34
+ #{overlaps.first} ...
35
+ EOM
36
+ end
37
+ end
38
+
39
+ # add record to model after interrupt calculation to save self
40
+ # interruption checks
41
+ @model.add_record(@slot, @day)
42
+ end
43
+
44
+ # match(:span) { |a| [[:span, *a]] }
45
+ def new_span span, desc = nil
46
+ @rec = Record.new :span=>span, :desc=>desc, :date => @day.dup
47
+ @slot.add_interrupt(@rec) if @slot
48
+ @model.add_record(@rec, @day)
49
+ end
50
+
51
+ # match(:desc) { |txt| [[:txt, txt]] } #@m.append_txt(txt) }
52
+ def new_txt txt = nil
53
+ @rec.desc << " #{txt}"
54
+ end
55
+
56
+ # match(/@.+/, String) { |project_id, re| [project_id[1..-1], Regexp.new(re)] }
57
+ def new_project_re project_id, re
58
+ puts "project expression: #{project_id}, #{re}"
59
+ @model.projects[project_id].push(re)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,15 @@
1
+ class Hash;def /(key);self[key];end end
2
+
3
+ class Hash
4
+ # _why's hash implant with a twist: the difference is to throw a
5
+ # NoMethodError instead returning nil when asking for a non-existing value
6
+ def method_missing(m,*a)
7
+ if m.to_s =~ /=$/
8
+ self[$`.to_sym] = a[0]
9
+ elsif a.empty?
10
+ self[m]
11
+ else
12
+ raise NoMethodError, "#{m}"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,58 @@
1
+ module AsciiTracker
2
+
3
+ class HHMM < Struct.new :hours, :minutes
4
+
5
+ include Comparable
6
+ def <=>(other)
7
+ a = (hours <=> other.hours)
8
+ a == 0 ? (minutes <=> other.minutes) : a
9
+ end
10
+
11
+ #def -(other); self.to_f - other.to_f end
12
+ #def +(other); self.to_f + other.to_f end
13
+ def -(other)
14
+ m = to_minutes - other.to_minutes
15
+ (m += 24 * 60) if m < 0
16
+ HHMM.new *(m.divmod 60)
17
+ end
18
+
19
+ def to_a; [hours, minutes] end
20
+ def to_s; "%02d:%02d" % [hours, minutes] end
21
+ def to_f; (minutes.to_f/60) + hours end
22
+ def to_minutes; (60*hours) + minutes end
23
+
24
+ # "12:30", "1:15" or "00:45" for hours and minutes
25
+ # or
26
+ # "1.5" for fractions of an hour notation
27
+ def self.parse txt
28
+ if (m = txt.match(/^(\d?\d):(\d\d)/))
29
+ HHMM.new m[1].to_i, m[2].to_i
30
+ else
31
+ minutes = ((Float(txt) * 60) + 0.5).to_i
32
+ HHMM.new(minutes / 60, minutes % 60)
33
+ end
34
+ end
35
+
36
+ # five ways to express 1 hour and 30 minutes:
37
+ # 1 arg: ["1:30"], ["1.5"] or [1.5]
38
+ # or a HHMM object to be cloned from
39
+ # 2 args: [1, 30], ["1", "30"],
40
+ def initialize *args
41
+ if 2 == args.length # [1, 30] or ["1", "30"]
42
+ super *(args.map{ |e| Integer(e) })
43
+ else
44
+ hhmm = args.first
45
+ hhmm = HHMM.parse(hhmm.to_s) unless hhmm.kind_of? HHMM
46
+ self.hours, self.minutes = hhmm.hours, hhmm.minutes
47
+ end
48
+ end
49
+ end
50
+
51
+ def HHMM *args; HHMM.new(*args); end
52
+
53
+ module Helper
54
+ def hhmm(*args)
55
+ HHMM.new(*args)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ module AsciiTracker
2
+ class Model
3
+
4
+ attr_reader :records, :projects
5
+
6
+ def initialize
7
+ @records = [ ]
8
+ @by_date = { }
9
+ @projects = Hash.new { |hash, project_id| hash[project_id] = [] }
10
+ end
11
+
12
+ def by_date(date)
13
+ (@by_date[date] ||= [])
14
+ end
15
+
16
+ def add_record rec, date = Date.today
17
+ @records.push(rec)
18
+ (@by_date[date] ||= []).push(rec)
19
+ rec
20
+ end
21
+
22
+ def find_overlaps rec, date = Date.today
23
+ by_date(date).find_all { |a| a.overlaps?(rec) rescue false }
24
+ end
25
+
26
+ def find_best_cover rec, date = Date.today
27
+ by_date(date).inject(nil) do |best, test|
28
+ if test.respond_to?(:covers?) # spans never cover
29
+ if test.covers?(rec) && (best.nil? || (best.covers? test))
30
+ best = test
31
+ end
32
+ end
33
+ best
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,86 @@
1
+ require 'asciitracker/rdparser'
2
+
3
+ module AsciiTracker
4
+
5
+ Parser = RDParser.new do
6
+
7
+ def parse model, txt
8
+ @model = model
9
+ super(txt)
10
+ @model
11
+ end
12
+
13
+ def push_line line
14
+ line.each do |rec|
15
+ puts "--> #{rec.join("|")}"
16
+ @model.send "new_#{rec.shift}", *rec
17
+ end
18
+ end
19
+
20
+ token(/\s+/)
21
+ token(/\d\d\d\d-\d\d-\d\d/) { |txt| puts "2:#{txt}"; Date.parse(txt) }
22
+ token(/([012]?\d):([0-5]\d)/) { |txt| HHMM.new(txt) }
23
+ token(/\d\d?(.\d\d?)?/) { |m| m.to_f }
24
+ token(/-/) { |m| m }
25
+ #token(/[:;#]?.*/) { |txt| txt }
26
+ token(/@\S+/) { |txt| txt }
27
+ token(/[:;#]?.*/) { |txt| txt.sub /^[:;#]\s*/, '' }
28
+ #token(/.+/) { |m| m }
29
+
30
+ start :records do
31
+ match(:records, :line) { |lol, line| push_line(line) }
32
+ match(:line) { |line| push_line(line) }
33
+ end
34
+
35
+ #date slot
36
+ #date span
37
+ # slot
38
+ # span
39
+ # desc
40
+
41
+ rule :line do
42
+ match(:project_re) { |a| [[:project_re, *a]] }
43
+ match(:day, :slot) { |a,b| [[:day, *a], [:slot, *b]] }
44
+ match(:day, :span) { |a,b| [[:day, *a], [:span, *b]] }
45
+ match(:slot) { |a| [[:slot, *a]] }
46
+ match(:span) { |a| [[:span, *a]] }
47
+ match(:desc) { |txt| [[:txt, txt]] } #@m.append_txt(txt) }
48
+ end
49
+
50
+ rule :project_re do
51
+ #match('/', /[^\/]+/, '/', String) { |_, id, _, re| [id, re] }
52
+ #match(/\/[^\/]+\/.+/) { |_, id, _, re| [id, re] }
53
+ #match(/\/[^\/]+\/.+/) { |x| [x] }
54
+ match(/@.+/, String) do |project_id, re|
55
+ [project_id[1..-1], Regexp.new(re, Regexp::IGNORECASE)]
56
+ end
57
+ end
58
+
59
+ rule :day do
60
+ match(Date) # { |date| puts "1:#{date}"; date }
61
+ end
62
+
63
+ rule :slot do
64
+ match(:hhmm,'-',:hhmm, :desc) { |t1, _, t2, desc| [t1, t2, desc] }
65
+ match(:hhmm,'-',:hhmm) { |t1, _, t2| [t1, t2, nil] }
66
+ end
67
+
68
+ rule :span do
69
+ #match(:hhmm, :desc) { |hhmm, desc| @m.new_span(hhmm.to_f, desc) }
70
+ #match(:hhmm) { |hhmm| @m.new_span(hhmm.to_f) }
71
+ match(:hhmm, :desc) { |hhmm, desc| [hhmm.to_f, desc] }
72
+ match(:hhmm) { |hhmm, desc| [hhmm.to_f, nil] }
73
+ end
74
+
75
+ rule :hhmm do
76
+ match(Float) { |x| HHMM.new(x) }
77
+ match(HHMM) { |x| x }
78
+ end
79
+
80
+ rule :desc do
81
+ match(String) { |txt| txt }
82
+ # strip optional marker
83
+ # match(String) { |txt| txt.sub /^[:;#]\s*/, '' }
84
+ end
85
+ end
86
+ end