ascii-tracker 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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