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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +14 -3
- data/lib/hcp/collections/occurrences.rb +72 -0
- data/lib/hcp/collections/visits.rb +14 -7
- data/lib/hcp/resources/estimate.rb +4 -4
- data/lib/hcp/resources/occurrence.rb +21 -0
- data/lib/hcp/version.rb +1 -1
- data/lib/hcp.rb +9 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c2323d80d7ca73d0e62b6cef443bd2e40b803495df3ed0ad0d05fac2b5eb186
|
|
4
|
+
data.tar.gz: d908b99bcf637800fbc1bf0bc17bb777d06338e1ca51ae999981405ae717a59b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
100
|
-
visit.lead # => the Hcp::Estimate it belongs to,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
def for_jobs = with
|
|
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
|
|
42
|
-
def for_leads = with
|
|
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
|
|
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
|
-
#
|
|
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
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.
|
|
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
|