portera 0.1.3

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.
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.3 / 2012-02-21
4
+
5
+ - Draft simple presenter
6
+
7
+ ## 0.1.2 / 2012-02-21
8
+
9
+ - Cache availability results in `Participant#available_for?`
10
+
11
+ ## 0.1.1 / 2012-02-20
12
+
13
+ - Re-release under Portera top-level namespace
14
+
15
+ ## 0.1.0 / 2012-02-20
16
+
17
+ - Initial release
18
+ - Note coalescing timeslot ranges does not quite work
19
+ - Note `Schedule.view` has not been implemented yet
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source :rubygems
2
+
3
+ gemspec
data/README.rdoc ADDED
@@ -0,0 +1,104 @@
1
+ = portera
2
+
3
+ == DESCRIPTION:
4
+
5
+ An application for determining the best time to conduct meetings.
6
+
7
+ Adapted from Gordon Thiesfeld's {rmu-scheduler}[https://github.com/vertiginous/rmu-scheduler]
8
+
9
+
10
+ == SYNOPSIS:
11
+
12
+ ==== Basic usage
13
+
14
+ schedule = Portera::Event.new do
15
+
16
+ week_of Date.civil(2010,11,21)
17
+ duration 90
18
+
19
+ end
20
+
21
+ schedule.participants <<
22
+ Portera::Participant.new('Malcolm Reynolds').available do
23
+ on(:monday, :from => "15:00", :to => "18:00" )
24
+ on(:wednesday, :from => "20:00", :to => "23:00" )
25
+ on(:friday, :from => "16:00", :to => "19:00" )
26
+ end
27
+
28
+ schedule.participants <<
29
+ Portera::Participant.new('Hoban Washburne').available do
30
+ on(:monday, :from => "15:00", :to => "18:00" )
31
+ on(:wednesday, :from => "20:00", :to => "23:00" )
32
+ on(:thursday, :from => "15:00", :to => "18:30" )
33
+ end
34
+
35
+ schedule.participants <<
36
+ Portera::Participant.new('Jayne Cobb').available do
37
+ weekdays(:from => "15:00", :to => "18:00" )
38
+ end
39
+
40
+ schedule.participants <<
41
+ Portera::Participant.new('Zoe Washburne').available do
42
+ on([:monday, :tuesday, :thursday], :from => "15:00", :to => "18:00" )
43
+ end
44
+
45
+ schedule.participants <<
46
+ Portera::Participant.new('Inara Serra').available do
47
+ on([:monday, :tuesday, :thursday])
48
+ end
49
+
50
+ puts Portera.view(:simple, schedule)
51
+
52
+ ==== Output
53
+
54
+ Monday 15:00-18:00 +00:00
55
+ Hoban Washburne
56
+ Inara Serra
57
+ Jayne Cobb
58
+ Malcolm Reynolds
59
+ Zoe Washburne
60
+ Thursday 15:00-18:00 +00:00
61
+ Hoban Washburne
62
+ Inara Serra
63
+ Jayne Cobb
64
+ Zoe Washburne
65
+ Tuesday 15:00-18:00 +00:00
66
+ Inara Serra
67
+ Jayne Cobb
68
+ Zoe Washburne
69
+
70
+
71
+ == REQUIREMENTS:
72
+
73
+ * {tempr}[https://github.com/ericgj/tempr]
74
+ * {tilt}[https://github.com/rtomayko/tilt]
75
+ * {erubis}[http://www.kuwata-lab.com/erubis/]
76
+
77
+ == INSTALL:
78
+
79
+ * git clone
80
+
81
+ == LICENSE:
82
+
83
+ (The MIT License)
84
+
85
+ Copyright (c) 2012 Eric Gjertsen
86
+
87
+ Permission is hereby granted, free of charge, to any person obtaining
88
+ a copy of this software and associated documentation files (the
89
+ 'Software'), to deal in the Software without restriction, including
90
+ without limitation the rights to use, copy, modify, merge, publish,
91
+ distribute, sublicense, and/or sell copies of the Software, and to
92
+ permit persons to whom the Software is furnished to do so, subject to
93
+ the following conditions:
94
+
95
+ The above copyright notice and this permission notice shall be
96
+ included in all copies or substantial portions of the Software.
97
+
98
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
99
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
100
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
101
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
102
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
103
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
104
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
File without changes
@@ -0,0 +1,145 @@
1
+ require 'set'
2
+
3
+ module Portera
4
+
5
+ # Event model - see README for usage
6
+ class Event
7
+ attr_accessor :name, :duration, :range
8
+ def participants; @participants ||= []; end
9
+
10
+ # Define events using a block passed to the constructor, e.g.
11
+ #
12
+ # Event.new do
13
+ # duration 60
14
+ # week_of Date.civil(2012,2,13)
15
+ # end
16
+ def initialize(name=nil,&proc)
17
+ self.name = name
18
+ Builder.new(self).instance_eval(&proc)
19
+ end
20
+
21
+ # iterate over each timeslot in range (week),
22
+ # gathering participant availability for each slot
23
+ # parameters:
24
+ # [+interval+]: duration of timeslots in minutes (default 15)
25
+ # [+all+]: return all timeslots even if no participants available (default false)
26
+ def availability(params={})
27
+ interval = params.fetch(:interval,15)
28
+ keep_all = params.fetch(:all,false)
29
+ iterate(interval).each_with_object(TimeslotEnum.new) do |slot, accum|
30
+ avails = []
31
+ self.participants.each do |person|
32
+ if person.available_for?(self, slot)
33
+ avails << person
34
+ end
35
+ end
36
+ if !avails.empty? || keep_all
37
+ accum << Timeslot.new(slot, ::Set.new(avails.sort_by(&:name)))
38
+ end
39
+ end
40
+ end
41
+
42
+ def coalesced(params={})
43
+ availability(params).coalesced
44
+ end
45
+
46
+ private
47
+
48
+ def iterate(i)
49
+ self.range.dup.extend(Tempr::DateTimeRange).each_minute(i,0,self.duration)
50
+ end
51
+
52
+ # Internal builder class for events
53
+ class Builder
54
+
55
+ def initialize(event)
56
+ @event = event
57
+ end
58
+
59
+ # duration in minutes of the proposed event
60
+ def duration(minutes)
61
+ @event.duration = minutes
62
+ end
63
+
64
+ # range of possible dates for the proposed event
65
+ # for a weekly event, use `week_of` instead of explicit date range
66
+ def range(dates)
67
+ @event.range = dates
68
+ end
69
+
70
+ # first date of week for the proposed event
71
+ def week_of(date)
72
+ range date...(date+7)
73
+ end
74
+
75
+ end
76
+
77
+ end
78
+
79
+ class Timeslot < Struct.new(:range, :participants)
80
+
81
+ # temporary, TODO use views/presenters instead
82
+ def to_s
83
+ ["#{self.range}",
84
+ self.participants.empty? ? nil : " #{self.participants.to_a.join("\n ")}"
85
+ ].compact.join("\n")
86
+ end
87
+
88
+ end
89
+
90
+ class TimeslotEnum
91
+ include Enumerable
92
+
93
+ def <<(timeslot)
94
+ timeslots << timeslot
95
+ self
96
+ end
97
+
98
+ def each(&b)
99
+ timeslots.each(&b)
100
+ end
101
+
102
+ # join intersecting timeslots with identical participants
103
+ # note that this returns a new TimeslotEnum, so that you can call
104
+ # [+enum.best+] all timeslots sorted by highest participation
105
+ # [+enum.coalesced.best+] joined timeslots sorted by highest participation
106
+ #
107
+ # Note assumes timeslots are appended in ascending order by range.begin
108
+ # TODO could use some refactoring
109
+ def coalesced
110
+ accum = self.class.new
111
+ inject(nil) do |last_slot, this_slot|
112
+ if last_slot
113
+ rng = this_slot.range
114
+ last_rng = last_slot.range
115
+ if (rng.intersects?(last_rng) || rng.succeeds?(last_rng)) &&
116
+ (this_slot.participants == last_slot.participants)
117
+ Timeslot.new(last_rng.begin...rng.end,
118
+ this_slot.participants)
119
+ else
120
+ accum << last_slot
121
+ this_slot
122
+ end
123
+ else
124
+ this_slot
125
+ end
126
+ end
127
+ accum
128
+ end
129
+
130
+ # sort timeslot availability by
131
+ # 1. most participants available
132
+ # 2. timeslot date/time
133
+ def best
134
+ sort do |a, b|
135
+ comp = b.participants.count <=> a.participants.count
136
+ comp.zero? ? (a.range.begin <=> b.range.begin) : comp
137
+ end
138
+ end
139
+
140
+ private
141
+ def timeslots; @timeslots ||= []; end
142
+
143
+ end
144
+
145
+ end
@@ -0,0 +1,157 @@
1
+
2
+ module Portera
3
+
4
+ # Participant model - for use with event(s). See README for usage.
5
+ class Participant < Struct.new(:name, :email)
6
+
7
+ # List of availability rules
8
+ def availables; @availables ||= []; end
9
+
10
+ # Define availability rule passing a block, e.g.
11
+ #
12
+ # participant.available('+09:00') do
13
+ # weekdays :from => '11:00am', :to => '3:00pm'
14
+ # on [0,6] :from => '5:00pm', :to => '6:30pm'
15
+ # end
16
+ def available(utc_offset=nil, &sched_proc)
17
+ Builder.new(self.availables,utc_offset).instance_eval(&sched_proc)
18
+ self
19
+ end
20
+
21
+ # For given event range,
22
+ # evaluate if the given timeslot fits within any of the participant's available times
23
+ #
24
+ # Note that range-availability arrays are cached here per event
25
+ # Possibly caching should be moved down into Tempr, e.g.
26
+ # avail.for_range(event.range).all #=> caches within Tempr::SubRangeIterator
27
+ def available_for?(event, slot)
28
+ availables.any? do |avail|
29
+ cache[event.range] ||= avail.for_range(event.range).to_a
30
+ cache[event.range].any? {|free| free.subsume?(slot)}
31
+ end
32
+ end
33
+
34
+ # Display participant as valid email `name <address>`
35
+ def to_s
36
+ [self.name ? "#{self.name}" : nil,
37
+ self.email ? "<#{self.email}>" : nil
38
+ ].compact.join(" ")
39
+ end
40
+
41
+ private
42
+
43
+ def cache; @cache ||= {}; end
44
+
45
+ # Internal builder for participant availability
46
+ class Builder
47
+
48
+ Days = { :sunday => 0,
49
+ :monday => 1,
50
+ :tuesday => 2,
51
+ :wednesday => 3,
52
+ :thursday => 4,
53
+ :friday => 5,
54
+ :saturday => 6,
55
+ :sun => 0,
56
+ :mon => 1,
57
+ :tue => 2,
58
+ :wed => 3,
59
+ :thu => 4,
60
+ :fri => 5,
61
+ :sat => 6,
62
+ :Sunday => 0,
63
+ :Monday => 1,
64
+ :Tuesday => 2,
65
+ :Wednesday => 3,
66
+ :Thursday => 4,
67
+ :Friday => 5,
68
+ :Saturday => 6,
69
+ :Sun => 0,
70
+ :Mon => 1,
71
+ :Tue => 2,
72
+ :Wed => 3,
73
+ :Thu => 4,
74
+ :Fri => 5,
75
+ :Sat => 6
76
+ }
77
+
78
+ def initialize(collect,utc_offset=nil)
79
+ @collect = collect
80
+ @utc_offset = utc_offset || 0
81
+ end
82
+
83
+ # Example:
84
+ # on [2,3,5], :from => '9:00am', :to => '9:30am', :utc_offset => '-05:00'
85
+ #
86
+ # Note that if not specified, timezone is
87
+ # 1. the offset passed into the constructor, or
88
+ # 2. UTC, otherwise -- _not the process-local timezone_!
89
+ def on(days, time={})
90
+ @collect << Availability.new( Array(days).map {|d| to_weekday(d)},
91
+ time[:from],
92
+ time[:to],
93
+ time[:utc_offset] || @utc_offset
94
+ )
95
+ end
96
+
97
+ # sugar for `on( [1,2,3,4,5], time)`
98
+ def weekdays(time={})
99
+ on( [1,2,3,4,5], time)
100
+ end
101
+
102
+ # sugar for `on( [], time)`
103
+ def any_day(time={})
104
+ on( [], time)
105
+ end
106
+
107
+ private
108
+
109
+ def to_weekday(day)
110
+ Days[day] || day
111
+ end
112
+
113
+ end
114
+
115
+ # This wrapper stores the parameters for participant availability rules
116
+ #
117
+ # [+days+] array of wday numbers, or empty array for all days
118
+ # [+from+] start time (parseable time string)
119
+ # [+to+] end time (parseable time string)
120
+ # [+utc_offset+] offset string or seconds from utc, if not specified assumes process-local offset
121
+ #
122
+ # An iterator is returned when you call `for_range`
123
+ class Availability < Struct.new(:days, :from, :to, :utc_offset)
124
+
125
+ def initialize(*args)
126
+ super
127
+ self.days ||= []
128
+ end
129
+
130
+ def for_range(range)
131
+ apply_time_range(for_day_range(range))
132
+ end
133
+
134
+ def for_day_range(range)
135
+ r = range.extend(Tempr::DateTimeRange)
136
+ if self.days.empty?
137
+ r.each_days_of_week
138
+ else
139
+ r.each_days_of_week(*self.days)
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def apply_time_range(expr)
146
+ if self.from && self.to
147
+ expr.between_times(self.from, self.to, self.utc_offset)
148
+ else
149
+ expr
150
+ end
151
+ end
152
+
153
+ end
154
+
155
+ end
156
+
157
+ end
@@ -0,0 +1,88 @@
1
+ require 'erubis'
2
+ require 'tilt'
3
+
4
+ module Portera
5
+
6
+ #TODO: delegation to event; extract base class
7
+ module Presenters
8
+
9
+ class Simple
10
+ include Enumerable
11
+
12
+ attr_accessor :template, :view_path
13
+ def template; @template ||= 'simple'; end
14
+ def view_path
15
+ @view_path ||= File.expand_path('../views', File.dirname(__FILE__))
16
+ end
17
+
18
+ def initialize(e)
19
+ self.event = e
20
+ end
21
+
22
+ def name
23
+ event.name
24
+ end
25
+
26
+ def description
27
+ present_event(event)
28
+ end
29
+
30
+ def best
31
+ event.coalesced.best
32
+ end
33
+
34
+ def each
35
+ best.each do |timeslot|
36
+ yield present_time_range(timeslot.range),
37
+ timeslot.participants.map {|p| present_participant(p)}
38
+ end
39
+ end
40
+
41
+ # shortcut for typical case
42
+ def minimum_participants(min=2)
43
+ select { |timeslot, participants|
44
+ participants.count >= min
45
+ }
46
+ end
47
+
48
+ def render
49
+ Tilt.new(Dir.glob(File.join(view_path, template + '.*')).first)
50
+ .render(self)
51
+ end
52
+
53
+ private
54
+ attr_accessor :event
55
+
56
+ def present_event(e)
57
+ "best times #{present_date_range(e.range)} (#{e.duration} mins)"
58
+ end
59
+
60
+ def present_date_range(r)
61
+ if r.end - r.begin == 7
62
+ "week of #{present_date(r.begin)}"
63
+ else
64
+ "between #{present_date(r.begin)} and #{present_date(r.end-1)}"
65
+ end
66
+ end
67
+
68
+ def present_time_range(r)
69
+ "#{present_time(r.begin)} - #{present_time(r.end)}"
70
+ end
71
+
72
+ def present_date(d)
73
+ d.strftime("%a %-d %b")
74
+ end
75
+
76
+ def present_time(t)
77
+ t.getutc.strftime("%l:%M%P %Z")
78
+ end
79
+
80
+ def present_participant(p)
81
+ p.to_s
82
+ end
83
+
84
+ end
85
+
86
+ end
87
+
88
+ end
@@ -0,0 +1,15 @@
1
+ module Portera
2
+
3
+ def self.view(type, *args)
4
+ Presenters.get(type).new(*args).render
5
+ end
6
+
7
+ module Presenters
8
+
9
+ def self.get(type)
10
+ self.const_get(type.to_s.capitalize)
11
+ end
12
+
13
+ end
14
+
15
+ end
@@ -0,0 +1,8 @@
1
+ module Portera
2
+ module Version
3
+ MAJOR = 0
4
+ MINOR = 1
5
+ TINY = 3
6
+ STRING = "#{MAJOR}.#{MINOR}.#{TINY}"
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ <%= name %>
2
+ <%= description %>
3
+ ------------------------------
4
+ <% minimum_participants(2).each do |slot, participants| -%>
5
+ <%= slot %>
6
+ <% participants.each do |p| -%>
7
+ <%= p %>
8
+ <% end -%>
9
+ <% end -%>
data/lib/portera.rb ADDED
@@ -0,0 +1,13 @@
1
+ require 'tempr'
2
+
3
+ %w[ portera/version
4
+ portera/event
5
+ portera/participant
6
+ portera/presenters
7
+ ].each do |f|
8
+ require File.expand_path(f, File.dirname(__FILE__))
9
+ end
10
+
11
+ Dir[File.expand_path('portera/presenters/*', File.dirname(__FILE__))].each do |f|
12
+ require f
13
+ end
@@ -0,0 +1,65 @@
1
+ require File.expand_path('test_helper',File.dirname(__FILE__))
2
+
3
+ describe 'simple schedule' do
4
+
5
+ let(:schedule) do
6
+ schedule = Portera::Event.new("Meeting on Frobartz project") do
7
+ week_of Date.civil(2010,11,21)
8
+ duration 90
9
+ end
10
+ end
11
+
12
+ let(:malcolm) do
13
+ Portera::Participant.new('Malcolm Reynolds').available do
14
+ on(:Mon, :from => "15:00", :to => "18:00" )
15
+ on(:Wed, :from => "20:00", :to => "23:00" )
16
+ on(:Fri, :from => "16:00", :to => "19:00" )
17
+ end
18
+ end
19
+
20
+ let(:hoban) do
21
+ Portera::Participant.new('Hoban Washburne').available do
22
+ on(:monday, :from => "15:00", :to => "18:00" )
23
+ on(:wednesday, :from => "20:00", :to => "23:00" )
24
+ on(:thursday, :from => "15:00", :to => "18:30" )
25
+ end
26
+ end
27
+
28
+ let(:jayne) do
29
+ Portera::Participant.new('Jayne Cobb').available do
30
+ weekdays(:from => "15:00", :to => "18:00" )
31
+ end
32
+ end
33
+
34
+ let(:zoe) do
35
+ Portera::Participant.new('Zoe Washburne').available do
36
+ on([1, 2, 4], :from => "15:00", :to => "18:00" )
37
+ end
38
+ end
39
+
40
+ let(:inara) do
41
+ Portera::Participant.new('Inara Serra').available do
42
+ on([:Monday, :tue, :Thu])
43
+ end
44
+ end
45
+
46
+ before do
47
+ schedule.participants << malcolm << hoban << jayne << zoe << inara
48
+ end
49
+
50
+ it 'should return best timeslots' do
51
+ puts
52
+ puts schedule.availability.best
53
+ end
54
+
55
+ it 'should return best with coalesced timeslots' do
56
+ puts
57
+ puts schedule.coalesced.best
58
+ end
59
+
60
+ it 'should display simple report' do
61
+ puts
62
+ puts Portera.view(:simple, schedule)
63
+ end
64
+
65
+ end
@@ -0,0 +1,5 @@
1
+ require File.expand_path('../lib/portera', File.dirname(__FILE__))
2
+
3
+ gem 'minitest'
4
+ require 'minitest/spec'
5
+ MiniTest::Unit.autorun
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portera
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Eric Gjertsen
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-02-24 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: tempr
16
+ requirement: &10133680 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: 0.1.4
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *10133680
25
+ - !ruby/object:Gem::Dependency
26
+ name: erubis
27
+ requirement: &10133300 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *10133300
36
+ - !ruby/object:Gem::Dependency
37
+ name: tilt
38
+ requirement: &10132840 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *10132840
47
+ - !ruby/object:Gem::Dependency
48
+ name: minitest
49
+ requirement: &10132420 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: *10132420
58
+ - !ruby/object:Gem::Dependency
59
+ name: rake
60
+ requirement: &10132000 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ type: :development
67
+ prerelease: false
68
+ version_requirements: *10132000
69
+ description: ''
70
+ email:
71
+ - ericgj72@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - CHANGELOG.markdown
77
+ - Gemfile
78
+ - README.rdoc
79
+ - Rakefile
80
+ - lib/portera.rb
81
+ - lib/portera/event.rb
82
+ - lib/portera/participant.rb
83
+ - lib/portera/presenters.rb
84
+ - lib/portera/presenters/simple.rb
85
+ - lib/portera/version.rb
86
+ - lib/portera/views/simple.erb
87
+ - test/acceptance.rb
88
+ - test/test_helper.rb
89
+ homepage: http://github.com/ericgj/portera
90
+ licenses: []
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ! '>='
99
+ - !ruby/object:Gem::Version
100
+ version: 1.9.2
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ none: false
103
+ requirements:
104
+ - - ! '>='
105
+ - !ruby/object:Gem::Version
106
+ version: 1.3.6
107
+ requirements: []
108
+ rubyforge_project: portera
109
+ rubygems_version: 1.8.10
110
+ signing_key:
111
+ specification_version: 3
112
+ summary: Group event coordination
113
+ test_files: []