hcp 3.0.0 → 3.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 796d536211d1039f154af0ea0340de4e814d9cf86c9009e60af68670dbc16cda
4
- data.tar.gz: cd18b17cc8e162a750c998f5d554835d6982d75a7b417c4d60dc26dcfda9106a
3
+ metadata.gz: 3c2323d80d7ca73d0e62b6cef443bd2e40b803495df3ed0ad0d05fac2b5eb186
4
+ data.tar.gz: d908b99bcf637800fbc1bf0bc17bb777d06338e1ca51ae999981405ae717a59b
5
5
  SHA512:
6
- metadata.gz: 1a6302bcfa3722c31caae3cc45d2bfb7d30c60895869c6a03fcc8d7851db99be8ad7f1b460d704f51a268e11fe2a78fef2e351186f9617f312d0ae759d0043e2
7
- data.tar.gz: 65d2d60980d7386121a6f411f591398741fda04298a42d39102666b530ebd5239c4cfbe4297ea98d9d99721c2719505674440360551269352a52e0f2d69fceb3
6
+ metadata.gz: a93a05b7b4b3b0785661ab9104ac0a0ec88a4d18e43331fd49414c4ad8388e379806a01425250d5e862c2015b76101747c0a4d2e139d67b9ee139a7871d078fe
7
+ data.tar.gz: '0759b4af994c2ef83c40abc10b7730b9e49350d3ceb8a74fa7f9be43cf373094b0056bfd35644b69a77bb04328176d9bd3994cadba1dabcc4f01abbf1054ff7f'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [3.1.0] - 2026-09-16
4
+
5
+ - [Feature] `account.visits` covers the time blocked out around the work, which Housecall Pro
6
+ files as events: a hold, a day off, an hour that is simply not free. It occupies a pro
7
+ exactly as a stop does, and Jobber has answered with it all along, so a schedule read through
8
+ this gem now says the same thing as one read through `jbr`. Each reads as a `Hcp::Occurrence`
9
+ standing on no job and no lead, and a caller that wants what it had before asks `for_jobs`.
10
+
11
+ - [Feature] An event that repeats is stored by Housecall Pro once, as the hour it first takes
12
+ and the iCalendar rule it repeats by, so the hours after that are worked out here -- in the
13
+ time zone the event is kept in, so an hour that holds at ten in the morning still holds at
14
+ ten once the clocks have gone back rather than sliding to nine. `ice_cube` walks the rule;
15
+ it is a new runtime dependency, and the reason not to hand-roll one is that rule, DST and
16
+ month-end clamping together are where a hand-rolled walk quietly goes wrong.
17
+
18
+ - [Feature] An hour answers to the event's ID and the moment it starts -- `evt_1@2026-09-18T14:15:00Z`
19
+ -- there being one ID for the whole rule and nothing else to tell two of its hours apart.
20
+
21
+ - [Note] Housecall Pro narrows events by nothing. `/events` accepts `scheduled_start_min`,
22
+ `scheduled_start_max` and `employee_ids[]` and ignores all three, answering with the same
23
+ total every time, so a week is read by sweeping every page at 200 a page and keeping what the
24
+ week holds. On an account with 2,877 events that is 15 requests, which a nightly walk can
25
+ afford and a caller that only wants the stops of work should not pay: `for_jobs` and
26
+ `for_leads` now leave the sweep unasked.
27
+
28
+ - [Note] An event carries an address but no ID for it, and leaves it empty on all but a
29
+ handful, so `visit.location` is nil on blocked-out time -- as it is in Jobber, where an event
30
+ carries no property either.
31
+
32
+ ## [3.0.1] - 2026-09-15
33
+
34
+ - [Fix] `visit.anytime?` answers false on an estimate's slot rather than nil. Housecall Pro
35
+ books an estimate for an hour and has no anytime to answer with, so the slot named none --
36
+ and a caller storing what a visit reads had a nil where it expected a yes or a no.
37
+
3
38
  ## [3.0.0] - 2026-09-15
4
39
 
5
40
  - [Breaking change] The vocabulary is `company` 2.1: a `Company::Selection` takes its rule as a
data/README.md CHANGED
@@ -96,13 +96,24 @@ account.visits.upcoming(2.weeks).each do |visit|
96
96
  visit.id, visit.starts_at, visit.ends_at, visit.anytime?
97
97
  visit.description # => what the job is called, or nil: an estimate has no words of its own
98
98
  visit.location # => where the stop is, whatever it was booked for
99
- visit.job # => the Hcp::Job the stop belongs to, or nil where an estimate does
100
- visit.lead # => the Hcp::Estimate it belongs to, or nil where a job does
99
+ visit.job # => the Hcp::Job the stop belongs to, nil on an estimate or an event
100
+ visit.lead # => the Hcp::Estimate it belongs to, nil on a job or an event
101
101
  visit.technicians # => the Hcp::Technicians the stop is booked for
102
102
  end
103
103
  ```
104
104
 
105
- The two cost a list each, so a caller that wants one kind asks for it and spends one request:
105
+ Time blocked out around the work counts as booked too, and Housecall Pro files it as an event:
106
+ a hold, a day off, an hour that is simply not free. An event that repeats is stored once, as the
107
+ hour it first takes and the iCalendar rule it repeats by, so the hours after that are worked out
108
+ here rather than read -- in the time zone the event is kept in, so an hour that holds at ten in
109
+ the morning still holds at ten once the clocks have gone back. Each one reads as a visit
110
+ standing on no job and no lead, and answers to the event's ID and the moment it starts, there
111
+ being one ID for the whole rule.
112
+
113
+ Housecall Pro narrows events by nothing -- it accepts a window and a crew on `/events` and
114
+ ignores both -- so a week of them is read by sweeping every page and keeping what the week
115
+ holds. That is the one expensive part of the list, and the one way to be spared it is to ask
116
+ for the stops of work alone:
106
117
 
107
118
  ```ruby
108
119
  account.visits.upcoming(2.weeks).for_jobs # => only the appointments, one request
@@ -0,0 +1,72 @@
1
+ module Hcp
2
+ # The blocked-out time of one location: the hours Housecall Pro files as events rather than as
3
+ # work. It narrows events by nothing -- neither a window nor a crew, both of which it accepts
4
+ # and ignores -- so the only way to read a week of them is to sweep every page and keep what
5
+ # the week holds. An event that repeats is stored once, as the hour it first takes and the
6
+ # iCalendar rule it repeats by, so every hour after that is worked out here rather than read.
7
+ class Occurrences < Company::Collection
8
+ # How far an open end reaches. A rule that repeats forever has no last hour to walk to, so a
9
+ # caller who named no end gets a year of them, which is a schedule rather than an eternity.
10
+ HORIZON = 1.year
11
+
12
+ # @param client [Client] how to reach Housecall Pro as the location.
13
+ # @param from [Time, nil] the moment the window opens, or nothing for from now on.
14
+ # @param to [Time, nil] the moment the window closes, or nothing for a year of it.
15
+ def initialize(client:, from: nil, to: nil)
16
+ @client = client
17
+ @from = from
18
+ @to = to
19
+ end
20
+
21
+ # @param from [Time, nil] the moment the window opens, or nothing for from now on.
22
+ # @param to [Time, nil] the moment the window closes, or nothing for a year of it.
23
+ # @return [Occurrences] the same list, narrowed to the hours taken between the two.
24
+ def between(from, to) = self.class.new(client: @client, from: from, to: to)
25
+
26
+ # Nothing is read until the walk starts, and a page only once the one before it runs out.
27
+ # @yield [Occurrence] each stretch blocked out in the window, event by event.
28
+ def each
29
+ events { |event| hours_of(event).each { |node| yield Occurrence.new node: node } }
30
+ end
31
+
32
+ private
33
+
34
+ def events
35
+ (1..).each do |page|
36
+ body = @client.get 'events', page: page, page_size: Jobs::PAGE
37
+ body.fetch('events').each { |node| yield node }
38
+ break if page >= body.fetch('total_pages')
39
+ end
40
+ end
41
+
42
+ def hours_of(event)
43
+ schedule = event.fetch 'schedule'
44
+ first = Time.iso8601 schedule.fetch('start_time')
45
+ length = Time.iso8601(schedule.fetch('end_time')) - first
46
+ starts(event['recurrence_rule'], first, schedule['time_zone']).map do |moment|
47
+ event.merge 'id' => "#{event['id']}@#{moment.utc.iso8601}",
48
+ 'start_time' => moment.utc.iso8601, 'end_time' => (moment + length).utc.iso8601
49
+ end
50
+ end
51
+
52
+ # An hour is repeated in the time zone the event is kept in rather than in UTC, so a rule
53
+ # that says ten in the morning still says ten once the clocks have gone back.
54
+ def starts(rule, first, zone)
55
+ return [ first ].select { |moment| window.cover? moment } if rule.blank?
56
+
57
+ repeated rule, first.in_time_zone(zone || 'UTC')
58
+ end
59
+
60
+ def repeated(rule, first)
61
+ schedule = IceCube::Schedule.new first
62
+ schedule.add_recurrence_rule IceCube::Rule.from_ical(rule)
63
+ schedule.occurrences_between opens, closes
64
+ end
65
+
66
+ def window = opens..closes
67
+
68
+ def opens = @from || Time.now
69
+
70
+ def closes = @to || opens + HORIZON
71
+ end
72
+ end
@@ -9,13 +9,16 @@ module Hcp
9
9
  # @param technician [Company::Technician, nil] whose work to ask for, or nothing for all.
10
10
  # @param jobs [Boolean] whether to read the stops of jobs.
11
11
  # @param leads [Boolean] whether to read the stops of estimates.
12
- def initialize(client:, from: nil, to: nil, technician: nil, jobs: true, leads: true)
12
+ # @param events [Boolean] whether to read the hours blocked out around them.
13
+ def initialize(client:, from: nil, to: nil, technician: nil, jobs: true, leads: true,
14
+ events: true)
13
15
  @client = client
14
16
  @from = from
15
17
  @to = to
16
18
  @technician = technician
17
19
  @jobs = jobs
18
20
  @leads = leads
21
+ @events = events
19
22
  end
20
23
 
21
24
  # @param from [Time, nil] the moment the window opens, or nothing for every visit there was.
@@ -35,27 +38,31 @@ module Hcp
35
38
  end
36
39
  end
37
40
 
38
- # @return [Visits] the same list, read off the jobs alone: one request rather than two.
39
- def for_jobs = with leads: false
41
+ # @return [Visits] the same list, read off the jobs alone: one request rather than many.
42
+ def for_jobs = with(leads: false, events: false)
40
43
 
41
- # @return [Visits] the same list, read off the estimates alone: one request rather than two.
42
- def for_leads = with jobs: false
44
+ # @return [Visits] the same list, read off the estimates alone: one request rather than many.
45
+ def for_leads = with(jobs: false, events: false)
43
46
 
44
47
  # Work booked across the window carries every stop in it, so each list is read once and
45
48
  # what was called off keeps its stops to itself.
46
- # @yield [Visit] each visit in the window, the jobs' stops before the estimates'.
49
+ # @yield [Company::Visit] each visit in the window, the work's stops before the hours
50
+ # blocked out around them.
47
51
  def each(&)
48
52
  jobs.each { |job| stops job, & } if @jobs
49
53
  estimates.each { |estimate| stops estimate, & } if @leads
54
+ occurrences.each(&) if @events
50
55
  end
51
56
 
52
57
  private
53
58
 
54
59
  def with(**changed)
55
60
  self.class.new(**{ client: @client, from: @from, to: @to, technician: @technician,
56
- jobs: @jobs, leads: @leads }.merge(changed))
61
+ jobs: @jobs, leads: @leads, events: @events, }.merge(changed))
57
62
  end
58
63
 
64
+ def occurrences = Occurrences.new(client: @client, from: @from, to: @to)
65
+
59
66
  def stops(work)
60
67
  return if work.canceled?
61
68
 
@@ -20,8 +20,8 @@ module Hcp
20
20
  # @return [nil] nothing, so a stop of one goes undescribed.
21
21
  def description = nil
22
22
 
23
- # Housecall Pro files no ID on the slot, there being only ever the one, so it answers to
24
- # the estimate's own.
23
+ # Housecall Pro files no ID on the slot, there being only ever the one, so it answers to the
24
+ # estimate's own, and books it for an hour rather than for any time in a day.
25
25
  # @return [Array<Visit>] the one slot the estimate is booked for, empty where it has none.
26
26
  def visits
27
27
  booked = @node.dig :schedule, :scheduled_start
@@ -36,8 +36,8 @@ module Hcp
36
36
  private
37
37
 
38
38
  def slot
39
- { id: id, start_time: @node.dig(:schedule, :scheduled_start),
40
- end_time: @node.dig(:schedule, :scheduled_end) }
39
+ { id: id, anytime: false, start_time: @node.dig(:schedule, :scheduled_start),
40
+ end_time: @node.dig(:schedule, :scheduled_end), }
41
41
  end
42
42
  end
43
43
  end
@@ -0,0 +1,21 @@
1
+ module Hcp
2
+ # One stretch of blocked-out time: what a Housecall Pro event takes, once where the event
3
+ # happens once and once for every hour its rule lands on where it repeats. Housecall Pro files
4
+ # one ID for the whole rule rather than one per hour, so an hour answers to the event's ID and
5
+ # the moment it starts, which is the one thing telling two hours of the same event apart.
6
+ class Occurrence < Company::Visit
7
+ # The node keys Housecall Pro spells otherwise than the vocabulary.
8
+ def self.keys
9
+ { description: :name, anytime: :all_day, starts_at: :start_time, ends_at: :end_time }
10
+ end
11
+
12
+ # Housecall Pro files an address on an event but no ID for it, and leaves it empty on all
13
+ # but a handful, so there is nothing to file a place under: blocked-out time stands nowhere,
14
+ # as it does in Jobber, where an event carries no property either.
15
+ # @return [nil] nothing, so an hour blocked out is booked at no address.
16
+ def location = nil
17
+
18
+ # @return [Array<Technician>] whoever the hour is blocked out for.
19
+ def technicians = records Technician, :assigned_employees
20
+ end
21
+ end
data/lib/hcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # The Housecall Pro API, answered in the vocabulary of the company gem.
2
2
  module Hcp
3
3
  # The version of this gem, as RubyGems knows it.
4
- VERSION = '3.0.0'
4
+ VERSION = '3.1.0'
5
5
  end
data/lib/hcp.rb CHANGED
@@ -7,8 +7,15 @@ require 'time'
7
7
  # Housecall Pro holds nothing for arrives as readily empty as null, and a query is written the
8
8
  # way `to_query` writes one.
9
9
  require 'active_support/core_ext/enumerable'
10
+ require 'active_support/core_ext/integer/time'
11
+ require 'active_support/core_ext/numeric/time'
10
12
  require 'active_support/core_ext/object/blank'
11
13
  require 'active_support/core_ext/object/to_query'
14
+ require 'active_support/core_ext/time/zones'
15
+
16
+ # An event repeats by an iCalendar rule, which is walked rather than parsed here: an hour that
17
+ # holds at ten in the morning either side of the clocks going back is not worth rewriting.
18
+ require 'ice_cube'
12
19
 
13
20
  # The vocabulary the account answers in.
14
21
  require 'company'
@@ -31,10 +38,12 @@ require 'hcp/resources/location'
31
38
  require 'hcp/resources/line'
32
39
  require 'hcp/resources/quote'
33
40
  require 'hcp/resources/visit'
41
+ require 'hcp/resources/occurrence'
34
42
  require 'hcp/resources/job'
35
43
  require 'hcp/resources/estimate'
36
44
  require 'hcp/collections/jobs'
37
45
  require 'hcp/collections/estimates'
46
+ require 'hcp/collections/occurrences'
38
47
  require 'hcp/collections/visits'
39
48
  require 'hcp/collections/technicians'
40
49
  require 'hcp/account'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ice_cube
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: minitest
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -140,6 +154,7 @@ files:
140
154
  - lib/hcp/collections/estimates.rb
141
155
  - lib/hcp/collections/jobs.rb
142
156
  - lib/hcp/collections/leads.rb
157
+ - lib/hcp/collections/occurrences.rb
143
158
  - lib/hcp/collections/technicians.rb
144
159
  - lib/hcp/collections/visits.rb
145
160
  - lib/hcp/error.rb
@@ -152,6 +167,7 @@ files:
152
167
  - lib/hcp/resources/lead.rb
153
168
  - lib/hcp/resources/line.rb
154
169
  - lib/hcp/resources/location.rb
170
+ - lib/hcp/resources/occurrence.rb
155
171
  - lib/hcp/resources/quote.rb
156
172
  - lib/hcp/resources/technician.rb
157
173
  - lib/hcp/resources/visit.rb