rate-card 0.1.5 → 0.1.6

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: 21c45d1eea2e85a47a9f268250611d10548eb3b812d4d5dd27716506c9802e3e
4
- data.tar.gz: c209d3d667be368253081ded2e7aab44f9138cefebeb086d27257f137b92538d
3
+ metadata.gz: 26756dd90159acde8bf444c7b7a4de2ee99c4b7b05c87a01a3f4946b3e920bb8
4
+ data.tar.gz: 0a2f73702f26ef311b8c729951f21e72b9641893b566de5928dbb5ba2aacd5b0
5
5
  SHA512:
6
- metadata.gz: 0ae5c80051382299425a11519bf0f8fa8132906f58cbf34c971480cb28989857fb12aff2f7099a1709187109a1e6bb530c778a2b5da3e6c1309bbede94cee8d6
7
- data.tar.gz: b90f381a9eb2f54c2b69a5653ca1c71e909210787040fe7e67e5872fd759efda0eca6a7a5b66e8df58fb9d9b615d718bac6aaeb062befad1e61d62cebb643463
6
+ metadata.gz: 1c891d9491881bad2b00b8c5b143ef3118699c6607e12449f653129d9d94e5a716f61b655f26988f0a53a01eaada736bf3e5d12afce72fcd685479d025179b1c
7
+ data.tar.gz: 06aec0434e85290036bfae0f3a672c597770d5519ecf89330751b16e53f44017bf5fdd271ed9e74e6e978653c57d0c358dbc9a87066eb024a1bfd42dffe1b37e
@@ -106,6 +106,75 @@ module RateCard
106
106
  # mode sweeps surcharge types instead of zones — see RunSpec#address_for.
107
107
  UPS_RURAL = { edas: UPS_EDAS_EXCEPTION, rdas: UPS_RDAS_EXCEPTION }.freeze
108
108
 
109
+ # UNVERIFIED — real public post-office addresses, one per origin ZIP3
110
+ # that Usps::IntlGroup1ZoneCalculator (ehub app/services/usps/
111
+ # intl_group1_zone_calculator.rb, backed by
112
+ # files/usps_intl_canada_zones.json.gz) maps to each zone 1-8.
113
+ #
114
+ # USPS's Canada rate is origin-driven, not destination-driven like the
115
+ # domestic charts above: the zone comes from the FROM zip's first 3
116
+ # digits, and the destination only needs to be a real address in
117
+ # Canada (see the spec fixtures in usps/rate_calculator_spec.rb, which
118
+ # call rate(from_zip, 'CA', ...) - no street-level destination enters
119
+ # the calculation at all). That inverts RunSpec#address_for's usual
120
+ # "vary destination, fix origin" shape for this one carrier/country
121
+ # pair - see #origin_for.
122
+ #
123
+ # Addresses are real (each is a public USPS post office and its ZIP3
124
+ # was confirmed against the actual origin table, not just "a nearby
125
+ # city"), but UNVERIFIED means the ZIP3 -> zone mapping itself hasn't
126
+ # been re-derived from files/usps_intl_canada_zones.json.gz by a
127
+ # second person - do this before trusting a rate card built from it.
128
+ USPS_CANADA_ORIGINS = {
129
+ 1 => { address1: '1040 Waverly Ave', city: 'Holtsville', state: 'NY', postal_code: '00501', country: 'US' },
130
+ 2 => { address1: '600 Suffield St', city: 'Agawam', state: 'MA', postal_code: '01001', country: 'US' },
131
+ 3 => { address1: '462 Washington St', city: 'Woburn', state: 'MA', postal_code: '01801', country: 'US' },
132
+ 4 => { address1: '73 Hammond St Ste 9998', city: 'Bangor', state: 'ME', postal_code: '04401', country: 'US' },
133
+ 5 => { address1: '83 Broad St', city: 'Charleston', state: 'SC', postal_code: '29401', country: 'US' },
134
+ 6 => { address1: '50 Carr 459', city: 'Aguadilla', state: 'PR', postal_code: '00603', country: 'US' },
135
+ 7 => { address1: '709 W 9th St', city: 'Juneau', state: 'AK', postal_code: '99801', country: 'US' },
136
+ 8 => { address1: '99-040 Kauhale St', city: 'Aiea', state: 'HI', postal_code: '96701', country: 'US' }
137
+ }.freeze
138
+
139
+ # UNVERIFIED — the fixed Canadian destination for every USPS_CANADA_ORIGINS
140
+ # zone. Ported from INTERNATIONAL_UNVERIFIED zone 0 below. Safe to reuse
141
+ # across all 8 zones only because USPS's Canada rate doesn't depend on
142
+ # destination specifics - see USPS_CANADA_ORIGINS above. Do not reuse
143
+ # this reasoning for any other carrier without checking that carrier's
144
+ # own calculator the same way.
145
+ USPS_CANADA_DESTINATION = { address1: '6990 Victoria Dr', city: 'Vancouver', state: 'BC',
146
+ postal_code: 'V5P 3Y8', country: 'CA' }.freeze
147
+
148
+ # UNVERIFIED — ported as-is from ../rate_table_builder/constants/address_constants.rb
149
+ # (INTL_ADDRESSES, originally commented "# International?"). Not
150
+ # confirmed against any carrier's actual international zone chart, and
151
+ # not carrier-specific: the source file left international: nil for
152
+ # USPS, UPS, and FedEx alike, so these zone numbers are not known to
153
+ # correspond to DHL Express, DHL eCommerce, or USPS Priority Mail
154
+ # International zone boundaries. Do not wire this into BY_CARRIER or
155
+ # for_carrier until each entry is hand-verified against a real
156
+ # carrier zone chart, the way the domestic addresses above are.
157
+ INTERNATIONAL_UNVERIFIED = [
158
+ { zone: 0, address1: '6990 Victoria Dr', city: 'Vancouver', state: 'Vancouver', postal_code: 'V5P 3Y8', country: 'CA' },
159
+ { zone: 1, address1: '1249 Metcalfe St', city: 'Montreal', state: 'Montreal', postal_code: 'H3B 2V5', country: 'CA' },
160
+ { zone: 2, address1: '10416 80 Ave NW', city: 'Edmonton', state: 'Edmonton', postal_code: 'T6E 5T7', country: 'CA' },
161
+ { zone: 3, address1: '4910 52 St', city: 'Yellowknife', state: 'Yellowknife', postal_code: 'X1A 1T3', country: 'CA' },
162
+ { zone: 4, address1: '179 Shaftesbury Ave', city: 'London', state: 'London', postal_code: 'WC2H 8JR', country: 'GB' },
163
+ { zone: 5, address1: 'R Sao Joaquim, 381 - Liberdade', city: 'Sao Paulo', state: 'Sao Paulo', postal_code: '01508-001', country: 'BR' },
164
+ { zone: 6, address1: '10 Bligh St', city: 'Sydney', state: 'Sydney', postal_code: '2000', country: 'AU' },
165
+ { zone: 7, address1: 'Rudi-Dutschke-Strasse 26', city: 'Berlin', state: 'Berlin', postal_code: '10969', country: 'DE' },
166
+ { zone: 8, address1: '107 Rue de Rivoli', city: 'Paris', state: 'Paris', postal_code: '75001', country: 'FR' },
167
+ { zone: 9, address1: 'Colima 150, Roma Nte.', city: 'Mexico City', state: 'Mexico City', postal_code: '06700', country: 'MX' },
168
+ { zone: 10, address1: '18 Merrion Row', city: 'Dublin', state: 'Dublin', postal_code: 'D02 A316', country: 'IE' },
169
+ { zone: 11, address1: '3 Chome-4 Kagurazaka', city: 'Tokyo', state: 'Tokyo', postal_code: '162-0825', country: 'JP' },
170
+ { zone: 12, address1: '90 Wellesley Street West', city: 'Auckland', state: 'Auckland', postal_code: '1010', country: 'NZ' },
171
+ { zone: 13, address1: 'Munsterhof 12,', city: 'Zurich', state: 'Zurich', postal_code: '8001', country: 'CH' },
172
+ { zone: 14, address1: 'Daniel Stalpertstraat 103', city: 'Amsterdam', state: 'Amsterdam', postal_code: '1072', country: 'NL' },
173
+ { zone: 15, address1: '404 Crescent Business Park Link Road Andheri', city: 'Mumbai', state: 'Maharashtra', postal_code: '400072', country: 'IN' },
174
+ { zone: 16, address1: 'C. del Prado, 16', city: 'Madrid', state: 'Madrid', postal_code: '28014', country: 'ES' },
175
+ { zone: 17, address1: 'Piazza Pasquale Paoli 15', city: 'Roma', state: 'Roma', postal_code: '00186', country: 'IT' }
176
+ ].freeze
177
+
109
178
  module_function
110
179
 
111
180
  # Raises rather than falling back. The old USPS default meant asking for
@@ -7,7 +7,8 @@ module RateCard
7
7
  module Carriers
8
8
  OTHER = 'Other'
9
9
 
10
- DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'OSM', 'Amazon', OTHER].freeze
10
+ DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'OSM', 'CDL', 'GLS', 'SpeedX',
11
+ 'UniUni', 'Veho', 'Dragonfly', 'Amazon', OTHER].freeze
11
12
 
12
13
  # Every /services entry carries a lowercase carrier_code. Anything not
13
14
  # listed here keeps its own code, upcased: an unrecognised carrier is
@@ -27,7 +28,10 @@ module RateCard
27
28
  # silently inherit the wrong zone chart.
28
29
  CARRIER_CODES = {
29
30
  'usps' => 'USPS', 'ups' => 'UPS', 'fedex' => 'FedEx',
30
- 'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'osm' => 'OSM', 'amazon' => 'Amazon'
31
+ 'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'osm' => 'OSM',
32
+ 'cdl' => 'CDL', 'gls' => 'GLS', 'speedx' => 'SpeedX',
33
+ 'uniuni' => 'UniUni', 'veho' => 'Veho', 'dragonfly' => 'Dragonfly',
34
+ 'amazon' => 'Amazon'
31
35
  }.freeze
32
36
 
33
37
  # Carriers with a rural/DAS surcharge test address in Constants::Addresses.
@@ -35,6 +39,32 @@ module RateCard
35
39
  # chart and no rural chart at all.
36
40
  RURAL_AWARE = %w[USPS UPS].freeze
37
41
 
42
+ # Carriers with no entry in Addresses::BY_CARRIER — their zone chart would
43
+ # be fetched live per run via Providers::EHub::Client#fetch_zone_addresses
44
+ # instead of read from a hand-verified constant. See
45
+ # 2026-09-03-dynamic-zone-addresses-design.md. OLX is deliberately not
46
+ # here — no live OLX rating API exists in ehub.
47
+ #
48
+ # Disabled for now: the zone_addresses endpoint does not exist yet in
49
+ # ehub-api, so these carriers report as unsupported (like any other
50
+ # carrier with no chart) rather than attempting a call that always 404s.
51
+ # Restore this list once the endpoint ships.
52
+ LIVE_CHART = [].freeze
53
+
54
+ # Carriers with an international destination in Constants::Addresses
55
+ # (USPS_CANADA_ORIGINS/USPS_CANADA_DESTINATION today). Kept explicit
56
+ # rather than inferring from chart presence, same reasoning as
57
+ # RURAL_AWARE — a carrier can support domestic long before any
58
+ # international country is verified for it.
59
+ #
60
+ # Empty for now: USPS_CANADA_ORIGINS' ZIP3 -> zone mapping is still
61
+ # UNVERIFIED (see Constants::Addresses), so the Destination prompt is
62
+ # hidden from the wizard until a second person re-derives it against
63
+ # files/usps_intl_canada_zones.json.gz. Add 'USPS' back once that's
64
+ # done — country_field, zones_field, and RunSpec#origin_for are
65
+ # already wired and need no other change.
66
+ INTERNATIONAL_AWARE = [].freeze
67
+
38
68
  module_function
39
69
 
40
70
  # OTHER is only for a malformed entry with no code at all, so a service is
@@ -53,6 +83,14 @@ module RateCard
53
83
  def rural_aware?(carrier)
54
84
  RURAL_AWARE.include?(carrier.to_s)
55
85
  end
86
+
87
+ def live_chart?(carrier)
88
+ LIVE_CHART.include?(carrier.to_s)
89
+ end
90
+
91
+ def international_aware?(carrier)
92
+ INTERNATIONAL_AWARE.include?(carrier.to_s)
93
+ end
56
94
  end
57
95
  end
58
96
  end
@@ -71,7 +71,8 @@ module RateCard
71
71
  end
72
72
 
73
73
  def fetch_cell(client, weight, zone, retry_sleeper)
74
- payload = provider.build_payload(spec: spec, weight: weight, address: spec.address_for(zone))
74
+ payload = provider.build_payload(spec: spec, weight: weight, address: spec.address_for(zone),
75
+ origin: spec.origin_for(zone))
75
76
  result = fetch_with_transient_retry(client, payload, retry_sleeper)
76
77
  record_response(result, weight, zone)
77
78
  @mutex.synchronize { @succeeded += 1 }
@@ -16,10 +16,15 @@ module RateCard
16
16
  # full of blank cells instead of one clear error — do not do that.
17
17
  class Client
18
18
  BASE_URL = 'https://api.ehub.com'
19
+ # Zone-addresses defaults to the local server that has the fix in
20
+ # progress, while rates/services stay on production. Override with
21
+ # RATE_CARD_ZONE_ADDRESSES_URL (e.g. back to BASE_URL once the fix ships).
22
+ ZONE_ADDRESSES_BASE_URL = ENV.fetch('RATE_CARD_ZONE_ADDRESSES_URL', 'http://localhost:9000')
19
23
  # No trailing slash: that is the documented path, and a trailing slash would
20
24
  # rely on a redirect — which can drop the POST body.
21
25
  RATES_PATH = '/api/v2/rates'
22
26
  SERVICES_PATH = '/api/v2/services'
27
+ ZONE_ADDRESSES_PATH = '/api/v2/services/%<service_id>s/zone_addresses'
23
28
  # 'ecommerce' services are storefront integrations, not shippable rates.
24
29
  SERVICES_CATEGORY = 'shipping'
25
30
 
@@ -42,7 +47,8 @@ module RateCard
42
47
  @token = token
43
48
  @stubs = stubs
44
49
  @sleeper = sleeper
45
- @connection = build_connection
50
+ @connection = build_connection(BASE_URL)
51
+ @zone_addresses_connection = ZONE_ADDRESSES_BASE_URL == BASE_URL ? @connection : build_connection(ZONE_ADDRESSES_BASE_URL)
46
52
  end
47
53
 
48
54
  # Returns the parsed response body as a Hash.
@@ -54,7 +60,18 @@ module RateCard
54
60
  # Discovery uses this rather than a rate call: it costs nothing, and it lists
55
61
  # services that would not have quoted at the single probe weight and zone.
56
62
  def fetch_services
57
- request('service list call') { get(SERVICES_PATH, category: SERVICES_CATEGORY) }
63
+ request('service list call') { get(SERVICES_PATH, { category: SERVICES_CATEGORY }) }
64
+ end
65
+
66
+ # Returns the parsed { "zones" => { "1" => {address1:, city:, ...}, ... } }
67
+ # body for one service's live zone chart. from_postal_code is only
68
+ # meaningful for a fixed-origin carrier (e.g. GLS) — omitted params
69
+ # means the server resolves the origin from account configuration.
70
+ def fetch_zone_addresses(service_id, from_postal_code: nil)
71
+ params = from_postal_code ? { from_postal_code: from_postal_code } : {}
72
+ request('zone addresses call') do
73
+ get(format(ZONE_ADDRESSES_PATH, service_id: service_id), params, connection: @zone_addresses_connection)
74
+ end
58
75
  end
59
76
 
60
77
  private
@@ -89,8 +106,8 @@ module RateCard
89
106
  raise RequestFailed, "rate call failed: #{e.message}"
90
107
  end
91
108
 
92
- def get(path, params)
93
- response = @connection.get(path, params)
109
+ def get(path, params, connection: @connection)
110
+ response = connection.get(path, params)
94
111
  [response.status, parse(response.body)]
95
112
  rescue Faraday::Error => e
96
113
  raise RequestFailed, "service list call failed: #{e.message}"
@@ -109,8 +126,8 @@ module RateCard
109
126
  message ? " (#{message})" : ''
110
127
  end
111
128
 
112
- def build_connection
113
- Faraday.new(url: BASE_URL) do |f|
129
+ def build_connection(url)
130
+ Faraday.new(url: url) do |f|
114
131
  f.headers['Authorization'] = "Bearer #{@token}"
115
132
  f.headers['Content-Type'] = 'application/json'
116
133
  f.options.timeout = 30
@@ -35,8 +35,8 @@ module RateCard
35
35
  ServiceCatalog.from_response(body)
36
36
  end
37
37
 
38
- def build_payload(spec:, weight:, address:)
39
- Shipment.new(spec: spec, weight: weight, address: address).payload
38
+ def build_payload(spec:, weight:, address:, origin: nil)
39
+ Shipment.new(spec: spec, weight: weight, address: address, origin: origin).payload
40
40
  end
41
41
 
42
42
  # Normalizes one /rates response into what Grid needs to record a cell:
@@ -14,16 +14,21 @@ module RateCard
14
14
  ITEM_VALUE = 18.99
15
15
  HS_TARIFF_CODE = '1704.90.3000'
16
16
 
17
- def initialize(spec:, weight:, address:)
17
+ # origin: nil for every carrier except USPS->Canada, where the zone is
18
+ # a property of the origin rather than the destination — see
19
+ # RunSpec#origin_for. Falls back to the usual fixed shipment origin
20
+ # when nil, so every other carrier is unaffected.
21
+ def initialize(spec:, weight:, address:, origin: nil)
18
22
  @spec = spec
19
23
  @weight = weight
20
24
  @address = address
25
+ @origin = origin
21
26
  end
22
27
 
23
28
  def payload
24
29
  {
25
30
  shipment: {
26
- from_location: Constants::Addresses::ORIGIN,
31
+ from_location: origin || Constants::Addresses::ORIGIN,
27
32
  to_location: to_location,
28
33
  parcels: [parcel]
29
34
  }
@@ -32,7 +37,7 @@ module RateCard
32
37
 
33
38
  private
34
39
 
35
- attr_reader :spec, :weight, :address
40
+ attr_reader :spec, :weight, :address, :origin
36
41
 
37
42
  def to_location
38
43
  {
@@ -17,6 +17,8 @@ module RateCard
17
17
  :token, :customer_name, :customer_id, :carrier, :services, :zones,
18
18
  :weight_unit, :weights, :package_type, :rate_keys, :output_base,
19
19
  :show_table, :started_at, :rate_mode, :cubic_tiers, :rural,
20
+ :zone_chart, # nil for the 5 static-chart carriers, populated for the live ones
21
+ :country, # nil for domestic; e.g. 'CA' for USPS->Canada — see #origin_for
20
22
  keyword_init: true
21
23
  )
22
24
  # Our rate-key name => the field it arrives as in service_rates. Getting
@@ -54,6 +56,13 @@ module RateCard
54
56
  !!rural
55
57
  end
56
58
 
59
+ # true only for USPS->Canada today. nil (domestic, or any other carrier)
60
+ # is falsy here — every existing caller keeps seeing plain domestic
61
+ # behavior.
62
+ def international?
63
+ !!country
64
+ end
65
+
57
66
  # [1,2,3,5] => "1-3,5". Runs of three or more collapse; a pair stays listed,
58
67
  # since "4,5" is no longer than "4-5" and reads as what the user typed.
59
68
  #
@@ -124,13 +133,34 @@ module RateCard
124
133
  # disambiguates from a real zone number without needing to check carrier
125
134
  # or rural separately. USPS rural mode keeps integer zones, just against
126
135
  # the rural chart instead of the normal one.
136
+ # USPS->Canada is destination-fixed, origin-varies — the opposite of
137
+ # every other chart here (see #origin_for) — so this returns the one
138
+ # Canadian destination for every zone rather than looking one up per
139
+ # zone.
127
140
  def address_for(zone)
128
141
  return Constants::Addresses::UPS_RURAL[zone] if zone.is_a?(Symbol)
129
142
  return Constants::Addresses::USPS_RURAL_DAS[zone] if carrier == 'USPS' && rural?
143
+ return Constants::Addresses::USPS_CANADA_DESTINATION if usps_canada?
144
+ return zone_chart[zone] if zone_chart
130
145
 
131
146
  Constants::Addresses.for_carrier(carrier)[zone]
132
147
  end
133
148
 
149
+ # nil for every carrier/zone except USPS->Canada, where the zone is a
150
+ # property of the origin ZIP3 (Usps::IntlGroup1ZoneCalculator), not the
151
+ # destination — see Constants::Addresses::USPS_CANADA_ORIGINS. Shipment
152
+ # falls back to the usual fixed Constants::Addresses::ORIGIN when this
153
+ # is nil, so every other carrier is unaffected.
154
+ def origin_for(zone)
155
+ return nil unless usps_canada?
156
+
157
+ Constants::Addresses::USPS_CANADA_ORIGINS[zone]
158
+ end
159
+
160
+ def usps_canada?
161
+ carrier == 'USPS' && country == 'CA'
162
+ end
163
+
134
164
  def rate_key_label(key)
135
165
  RATE_KEY_LABELS.fetch(key, key.to_s.tr('_', ' '))
136
166
  end
@@ -168,6 +198,7 @@ module RateCard
168
198
  # run_dir. USPS rural mode sweeps real zones as usual, so it just gets
169
199
  # the generic suffix.
170
200
  def rural_suffix
201
+ return "_#{country}" if international?
171
202
  return '' unless rural?
172
203
 
173
204
  surcharge = zones.find { |zone| zone.is_a?(Symbol) }
@@ -188,9 +219,9 @@ module RateCard
188
219
  end
189
220
 
190
221
  zones.each do |zone|
191
- next if address_for(zone)
222
+ next if address_for(zone) && (!usps_canada? || origin_for(zone))
192
223
 
193
- raise ArgumentError, "no address for zone #{zone} on carrier #{carrier}"
224
+ raise ArgumentError, "no address for zone #{zone} on carrier #{carrier}#{" (#{country})" if country}"
194
225
  end
195
226
 
196
227
  self
@@ -24,8 +24,8 @@ module RateCard
24
24
 
25
25
  # No :token stage — see TokenPrompt for why it cannot live in the loop.
26
26
  STAGES = %i[
27
- loading rate_mode carrier rural rural_surcharges services zones unit weights
28
- package_type rate_keys confirm fetching
27
+ loading rate_mode carrier rural rural_surcharges services country gls_origin zones unit weights
28
+ package_type rate_keys add_another confirm fetching
29
29
  ].freeze
30
30
 
31
31
  # #spec/#grid hold the most recently finished pass, for every caller that
@@ -49,7 +49,7 @@ module RateCard
49
49
  @field = nil
50
50
  @cancelled = false
51
51
  @error = nil
52
- @spinner = Bubbles::Spinner.new
52
+ @spinner = Bubbles::Spinner.new(style: Lipgloss::Style.new.foreground(Theme::ACCENT))
53
53
  @progress = Bubbles::Progress.new(width: 32)
54
54
  @completed = 0
55
55
  @failed = 0
@@ -58,6 +58,11 @@ module RateCard
58
58
  @failure_sparkline.style = Lipgloss::Style.new.foreground(Theme::WARNING)
59
59
  @log = []
60
60
  @results = []
61
+ @queued_specs = []
62
+ @queued_scenarios = []
63
+ @scenario = 1
64
+ @fresh_scenario_start = false
65
+ @log << { stage: :scenario_header, line: scenario_header_line(@scenario) }
61
66
  end
62
67
 
63
68
  def cancelled? = @cancelled
@@ -86,8 +91,9 @@ module RateCard
86
91
  transcript = @log.filter_map { |entry| entry[:line] }
87
92
  sections = []
88
93
  sections << transcript.join("\n") unless transcript.empty?
94
+ sections << Theme.divider unless transcript.empty?
89
95
  sections << stage_view
90
- "#{sections.compact.join("\n\n")}\n"
96
+ "#{Theme.panel(sections.compact.join("\n\n"))}\n#{Theme.footer(footer_text)}\n"
91
97
  end
92
98
 
93
99
  private
@@ -114,6 +120,7 @@ module RateCard
114
120
  value = @field.value
115
121
  # The recap's own Back row, which is the esc key by another name.
116
122
  return retreat if @stage == :confirm && value == :back
123
+ return add_another_chosen(value) if @stage == :add_another
117
124
 
118
125
  record(@stage, value)
119
126
  advance
@@ -135,6 +142,7 @@ module RateCard
135
142
  def record(stage, value)
136
143
  @answers[stage] = value
137
144
  @log << { stage: stage, line: answered_line(stage, value) }
145
+ @fresh_scenario_start = false
138
146
  end
139
147
 
140
148
  def advance
@@ -148,6 +156,8 @@ module RateCard
148
156
  break unless @field.nil?
149
157
  end
150
158
  nil
159
+ rescue RequestFailed, UnsupportedCarrier => e
160
+ fail_with(e).last
151
161
  end
152
162
 
153
163
  # Steps back to the nearest earlier stage that actually asked something,
@@ -155,6 +165,9 @@ module RateCard
155
165
  # revisited seeds the field, and every answer after it is forgotten —
156
166
  # they were given against a choice that may be about to change.
157
167
  def retreat
168
+ return confirm_back if @stage == :confirm
169
+ return cancel_fresh_scenario if @fresh_scenario_start
170
+
158
171
  index = STAGES.index(@stage)
159
172
  loop do
160
173
  index -= 1
@@ -171,6 +184,80 @@ module RateCard
171
184
  end
172
185
  end
173
186
 
187
+ # Esc from confirm must not re-run the wizard's forget-forward logic -
188
+ # the scenario just chosen at add_another is already queued and its
189
+ # answers already cleared (#confirm_or_queue), so there is nothing left
190
+ # to forget. It simply reopens the add_another question.
191
+ def confirm_back
192
+ @stage = :add_another
193
+ @field = field_for(:add_another)
194
+ nil
195
+ end
196
+
197
+ # Esc on the very first field of a scenario just started via "Yes, add
198
+ # another" — nothing has been answered yet for it, so there is nothing
199
+ # to walk back through. Treated as if "No" had been chosen instead: the
200
+ # new scenario is abandoned and the queue (already holding the prior
201
+ # scenario, committed by #confirm_or_queue) goes straight to confirm.
202
+ def cancel_fresh_scenario
203
+ @fresh_scenario_start = false
204
+ @log.pop if @log.last && @log.last[:stage] == :scenario_header
205
+ @scenario -= 1
206
+ advance_to_confirm
207
+ end
208
+
209
+ # add_another is now asked as soon as every field is filled in, before
210
+ # the run is ever confirmed. Either choice queues the just-answered
211
+ # scenario. Yes clears @answers and jumps back to the wizard's first
212
+ # real question for a fresh scenario; No moves on to the confirm
213
+ # screen, which then asks to run the whole queue.
214
+ def add_another_chosen(value)
215
+ confirm_or_queue
216
+ return advance_to_confirm if value == :no
217
+
218
+ @scenario += 1
219
+ @log << { stage: :scenario_header, line: scenario_header_line(@scenario) }
220
+ @fresh_scenario_start = true
221
+
222
+ @stage = STAGES.first
223
+ loop do
224
+ @stage = STAGES[STAGES.index(@stage) + 1]
225
+ @field = field_for(@stage)
226
+ break unless @field.nil?
227
+ end
228
+ nil
229
+ rescue RequestFailed, UnsupportedCarrier => e
230
+ fail_with(e).last
231
+ end
232
+
233
+ def advance_to_confirm
234
+ @stage = :confirm
235
+ @field = field_for(:confirm)
236
+ nil
237
+ end
238
+
239
+ def scenario_header_line(number)
240
+ "\n#{Theme.title("Scenario #{number}")}"
241
+ end
242
+
243
+ # Commits the just-confirmed scenario's specs (today's build_specs,
244
+ # unchanged) to the queue and clears @answers so the next pass through
245
+ # the wizard, or #start_fetch, starts from a blank slate. Also grouped
246
+ # under @queued_scenarios, one entry per wizard pass, so the confirm
247
+ # recap can show each scenario as its own block rather than one flat
248
+ # list of specs.
249
+ def confirm_or_queue
250
+ specs = build_specs
251
+ @queued_specs.concat(specs)
252
+ @queued_scenarios << specs
253
+ @log << { stage: :add_another, line: queued_summary_line(specs) }
254
+ @answers = {}
255
+ end
256
+
257
+ def queued_summary_line(specs)
258
+ " #{Theme.ok(Theme::TICK)} queued: #{Theme.bold(specs.first.carrier)} — #{specs.sum(&:call_count)} calls"
259
+ end
260
+
174
261
  def forget_from(stage)
175
262
  dropped = STAGES[STAGES.index(stage)..]
176
263
  dropped.each { |name| @answers.delete(name) }
@@ -184,22 +271,40 @@ module RateCard
184
271
  when :rural then rural_field
185
272
  when :rural_surcharges then rural_surcharges_field
186
273
  when :services then services_field
274
+ when :country then country_field
275
+ when :gls_origin then gls_origin_field
187
276
  when :zones then zones_field
188
277
  when :unit then unit_field
189
278
  when :weights then weights_field
190
279
  when :package_type then package_type_field
191
280
  when :rate_keys then rate_keys_field
281
+ when :add_another then add_another_field
192
282
  when :confirm then confirm_field
193
283
  end
194
284
  end
195
285
 
196
286
  # --------------------------------------------------------------- fields
197
287
 
288
+ # Reached as soon as every field for the current scenario is filled in.
289
+ # Yes clears @answers and jumps back to the top of the wizard for a
290
+ # fresh scenario; No moves on to the confirm screen for the whole queue.
291
+ def add_another_field
292
+ Fields::Select.new(
293
+ label: 'Add another scenario?',
294
+ choices: [['Yes, add another', :yes],
295
+ ["No, continue to run #{@queued_specs.length + 1} scenario(s)", :no]],
296
+ selected: 0
297
+ )
298
+ end
299
+
198
300
  # The last gate before production is touched. It opens on Back, not on
199
301
  # Run: the field before it is also confirmed with enter, so a held-down
200
302
  # return key must not be able to start 128 production calls by itself.
303
+ # The label names the whole queue once there is more than one scenario
304
+ # in it, so "Run" is never ambiguous about how many cards it fires off.
201
305
  def confirm_field
202
- Fields::Select.new(label: 'Run this rate card?',
306
+ label = @queued_scenarios.length > 1 ? "Run all #{@queued_scenarios.length} scenarios?" : 'Run this rate card?'
307
+ Fields::Select.new(label: label,
203
308
  choices: [['Run', :run], ['Back', :back]], selected: 1)
204
309
  end
205
310
 
@@ -210,7 +315,7 @@ module RateCard
210
315
  def rate_mode_field
211
316
  return nil unless @services.any? { |service| service.carrier == 'USPS' }
212
317
 
213
- choices = [['Weight', :weight], ['Cubic dimensions', :cubic]]
318
+ choices = [['Weight', :weight], ['Cubic', :cubic]]
214
319
  Fields::Select.new(label: 'Rate by', choices: choices,
215
320
  selected: choices.index { |_, mode| mode == @answers[:rate_mode] } || 0)
216
321
  end
@@ -229,7 +334,9 @@ module RateCard
229
334
  def selectable_carriers
230
335
  carriers = Service.group_by_carrier(@services)
231
336
  .keys
232
- .select { |carrier| Constants::Addresses.supported?(carrier) }
337
+ .select do |carrier|
338
+ Constants::Addresses.supported?(carrier) || Constants::Carriers.live_chart?(carrier)
339
+ end
233
340
  return carriers & ['USPS'] if @answers[:rate_mode] == :cubic
234
341
 
235
342
  carriers
@@ -252,8 +359,8 @@ module RateCard
252
359
  def rural_field
253
360
  return nil unless Constants::Carriers.rural_aware?(carrier)
254
361
 
255
- choices = [['Normal', false], ['Rural (DAS)', true]]
256
- Fields::Select.new(label: 'Rural / DAS', choices: choices,
362
+ choices = [['Standard', false], ['Rural/DAS', true]]
363
+ Fields::Select.new(label: 'Address Type', choices: choices,
257
364
  selected: choices.index { |_, v| v == @answers[:rural] } || 0)
258
365
  end
259
366
 
@@ -274,10 +381,23 @@ module RateCard
274
381
  )
275
382
  end
276
383
 
384
+ # Only offered for international-aware carriers (USPS today, via
385
+ # USPS_CANADA_ORIGINS/USPS_CANADA_DESTINATION), and dropped whenever
386
+ # rural mode is on - USPS rural DAS is its own fixed domestic chart,
387
+ # not something international mode should combine with.
388
+ def country_field
389
+ return nil unless Constants::Carriers.international_aware?(carrier)
390
+ return nil if @answers[:rural]
391
+
392
+ choices = [['Domestic', nil], ['Canada', 'CA']]
393
+ Fields::Select.new(label: 'Destination', choices: choices,
394
+ selected: choices.index { |_, v| v == @answers[:country] } || 0)
395
+ end
396
+
277
397
  def zones_field
278
398
  return nil if ups_rural?
279
399
 
280
- available = usps_rural? ? Constants::Addresses::USPS_RURAL_DAS.keys.sort : Constants::Addresses.available_zones(carrier)
400
+ available = live_zones_for(carrier)
281
401
  full = "#{available.first}-#{available.last}"
282
402
  answered = @answers[:zones]
283
403
 
@@ -301,6 +421,64 @@ module RateCard
301
421
  carrier == 'UPS' && @answers[:rural] == true
302
422
  end
303
423
 
424
+ # Only asked for GLS: a fixed-origin carrier whose live zone chart is
425
+ # computed from the shipment's origin postal code rather than an
426
+ # account-configured hub, same reasoning as USPS/UPS/FedEx's shipment
427
+ # origin — but that origin only feeds fetch_zone_addresses' lookup, it
428
+ # is not itself a shipment field.
429
+ def gls_origin_field
430
+ return nil unless carrier == 'GLS'
431
+
432
+ Fields::Text.new(
433
+ label: 'GLS origin postal code',
434
+ default: @answers[:gls_origin] || Constants::Addresses::ORIGIN[:postal_code],
435
+ hint: 'GLS zone is computed from this origin, not your account',
436
+ parse: lambda { |raw|
437
+ raise ArgumentError, 'enter a postal code' if raw.strip.empty?
438
+
439
+ raw.strip
440
+ }
441
+ )
442
+ end
443
+
444
+ def live_zones_for(carrier)
445
+ return Constants::Addresses::USPS_CANADA_ORIGINS.keys.sort if carrier == 'USPS' && @answers[:country] == 'CA'
446
+ return Constants::Addresses.available_zones(carrier) unless Constants::Carriers.live_chart?(carrier)
447
+
448
+ live_chart(carrier).keys.sort
449
+ end
450
+
451
+ # Memoized per (carrier, run) — the endpoint gets hit once even though
452
+ # zones_field, and later address_for per selected zone, both need the chart.
453
+ def live_chart(carrier)
454
+ @live_charts ||= {}
455
+ @live_charts[carrier] ||= fetch_live_chart(carrier)
456
+ end
457
+
458
+ def fetch_live_chart(carrier)
459
+ service = @services.find { |s| s.carrier == carrier }
460
+ raise UnsupportedCarrier, "no service_id found for #{carrier}" unless service
461
+
462
+ client = @client_factory.call(@token)
463
+ body = client.fetch_zone_addresses(service.id, from_postal_code: gls_origin_for(carrier))
464
+ chart = (body['zones'] || {}).transform_keys(&:to_i)
465
+ .transform_values { |address| symbolize(address) }
466
+ if chart.empty?
467
+ raise UnsupportedCarrier,
468
+ "eHub found no real address for any zone for #{carrier} on this account yet"
469
+ end
470
+
471
+ chart
472
+ end
473
+
474
+ def gls_origin_for(carrier)
475
+ carrier == 'GLS' ? (@answers[:gls_origin] || Constants::Addresses::ORIGIN[:postal_code]) : nil
476
+ end
477
+
478
+ def symbolize(hash)
479
+ hash.to_h { |k, v| [k.to_sym, v] }
480
+ end
481
+
304
482
  # Weight is fixed per cubic tier, so asking for a display unit is
305
483
  # meaningless in cubic mode — decided as :oz (unused) and skipped.
306
484
  def unit_field
@@ -400,7 +578,9 @@ module RateCard
400
578
  end
401
579
 
402
580
  def with_supported_carrier(services)
403
- services.select { |service| Constants::Addresses.supported?(service.carrier) }
581
+ services.select do |service|
582
+ Constants::Addresses.supported?(service.carrier) || Constants::Carriers.live_chart?(service.carrier)
583
+ end
404
584
  end
405
585
 
406
586
  def unsupported_message(services)
@@ -425,7 +605,7 @@ module RateCard
425
605
  # grid:} in #results before the next pass starts. Every other carrier
426
606
  # and mode has exactly one pass, same as before this existed.
427
607
  def start_fetch
428
- @pending_specs = build_specs
608
+ @pending_specs = @queued_specs.dup
429
609
  # Before the first call, so a bad path is not discovered after 128 of them.
430
610
  begin
431
611
  @pending_specs.each { |spec| CsvWriter.ensure_writable!(spec.output_base) }
@@ -522,8 +702,10 @@ module RateCard
522
702
  cubic_tiers: cubic ? @answers[:weights] : [],
523
703
  rate_mode: @answers[:rate_mode] || :weight,
524
704
  rural: @answers[:rural],
705
+ country: @answers[:country],
525
706
  package_type: @answers[:package_type],
526
707
  rate_keys: @answers[:rate_keys],
708
+ zone_chart: Constants::Carriers.live_chart?(carrier) ? live_chart(carrier) : nil,
527
709
  output_base: @output_base,
528
710
  show_table: true,
529
711
  started_at: Time.now
@@ -545,42 +727,78 @@ module RateCard
545
727
  end
546
728
  end
547
729
 
548
- # Everything the run will do, gathered in one block. The answers are also
549
- # in the transcript above, but they arrived one at a time over eight
550
- # screens; this is the only place they can be read against each other.
551
- #
552
- # Built from real RunSpecs (build_specs) rather than raw @answers, so a
553
- # UPS rural run with both surcharge types checked recaps as the two
554
- # separate cards it will actually produce, one line each.
730
+ # Loading and fetching have no field to ask a keybinding of the only
731
+ # live key on either screen is Ctrl-C so they fall back to that.
732
+ QUIT_BINDING = [Bubbles::Key.binding(keys: ['ctrl+c'], help: ['ctrl+c', 'quit'])].freeze
733
+
734
+ def footer_text
735
+ @field&.keymap_hint || Theme.help_view(QUIT_BINDING)
736
+ end
737
+
738
+ # Everything every queued scenario will do, gathered in one block. By
739
+ # the time confirm is reached, add_another has already queued the
740
+ # current scenario and cleared @answers (#confirm_or_queue), so this
741
+ # reads from @queued_scenarios/@queued_specs rather than @answers -
742
+ # unlike the single-scenario recap this replaces, it has to summarize
743
+ # everything already queued, not just what was just answered.
555
744
  def recap_view
556
- specs = build_specs
557
- carrier_line = " #{Theme.bold(carrier)}#{rural_recap} · #{@answers[:services].map(&:name).join(', ')}"
745
+ scenarios = @queued_scenarios.map { |specs| scenario_recap_block(specs) }.join("\n\n")
746
+ Lipgloss.join_vertical(Lipgloss::LEFT, scenarios, '', summary_block)
747
+ end
748
+
749
+ # The production warning and call total, pulled into their own box so
750
+ # the one fact that actually gates the run — this is about to hit
751
+ # production N times — reads as a distinct decision, not another line
752
+ # buried under the scenario details on its left.
753
+ def summary_block
754
+ calls = @queued_specs.sum(&:call_count)
755
+ cards = @queued_specs.length > 1 ? "#{@queued_specs.length} separate cards" : '1 card'
756
+ lines = [
757
+ Theme.bold('Summary'),
758
+ '',
759
+ "#{Theme.bold(calls.to_s)} calls",
760
+ cards,
761
+ '',
762
+ Theme.danger("#{Theme::ALERT} production")
763
+ ]
764
+ Lipgloss::Style.new
765
+ .border(Lipgloss::ROUNDED_BORDER)
766
+ .border_foreground(Theme::DANGER)
767
+ .padding(0, 2)
768
+ .render(lines.join("\n"))
769
+ end
770
+
771
+ # One block per queued wizard pass. Almost always one spec; more than
772
+ # one only for UPS rural mode with several surcharge types checked, in
773
+ # which case every spec in the block shares carrier/services/package/
774
+ # rate_keys and differs only in zones (see #build_specs).
775
+ def scenario_recap_block(specs)
776
+ first = specs.first
558
777
  [
559
- carrier_line,
560
- *specs.map { |spec| " zones #{spec.zone_summary} · #{rows_recap} · #{@answers[:package_type]}" },
561
- " columns: #{@answers[:rate_keys].map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')}",
562
- " #{Theme.bold(specs.sum(&:call_count).to_s)} rate calls against #{Theme.danger('production')}" \
563
- "#{specs.length > 1 ? " (#{specs.length} separate cards)" : ''}"
778
+ " #{Theme.bold(first.carrier)}#{spec_rural_recap(first)} · #{first.services.map(&:name).join(', ')}",
779
+ *specs.map { |spec| " zones #{spec.zone_summary} · #{spec_rows_recap(spec)} · #{first.package_type}" },
780
+ " columns: #{first.rate_keys.map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')}"
564
781
  ].join("\n")
565
782
  end
566
783
 
567
- def rural_recap
568
- return '' unless @answers[:rural]
784
+ def spec_rural_recap(spec)
785
+ return '' unless spec.rural
569
786
 
570
- " (#{rural_label(@answers[:rural])})"
787
+ " (#{rural_label(spec.rural)})"
571
788
  end
572
789
 
573
- def rows_recap
574
- if @answers[:rate_mode] == :cubic
575
- "cubic tiers #{RunSpec.compact_range(@answers[:weights])}"
790
+ def spec_rows_recap(spec)
791
+ if spec.rate_mode == :cubic
792
+ "cubic tiers #{RunSpec.compact_range(spec.cubic_tiers)}"
576
793
  else
577
- "weights #{RunSpec.compact_range(@answers[:weights])} #{@answers[:unit]}"
794
+ "weights #{RunSpec.compact_range(spec.weights)} #{spec.weight_unit}"
578
795
  end
579
796
  end
580
797
 
581
798
  def fetch_view
582
799
  total = @spec.call_count
583
800
  percent = total.zero? ? 0.0 : @completed.to_f / total
801
+ @progress.full_color = progress_color(percent)
584
802
  title = @total_passes > 1 ? "fetching rates (pass #{@pass_index}/#{@total_passes})" : 'fetching rates'
585
803
  line = " #{@progress.view_as(percent)} #{@completed}/#{total}"
586
804
  line += " #{Theme.warning("#{Theme::ALERT} #{@failed} failed")}" if @failed.positive?
@@ -589,6 +807,16 @@ module RateCard
589
807
  view
590
808
  end
591
809
 
810
+ # Warning early, accent through the middle stretch, ok once the run is
811
+ # nearly done — so a glance at the bar's colour alone tells you roughly
812
+ # how far along it is, without reading the fraction next to it.
813
+ def progress_color(percent)
814
+ return Theme::OK if percent >= 0.75
815
+ return Theme::ACCENT if percent >= 0.34
816
+
817
+ Theme::WARNING
818
+ end
819
+
592
820
  # Only drawn once a failure has happened: a spark line of zeroes for a
593
821
  # clean run would be noise, not signal. It stays once shown even if
594
822
  # later ticks are all clean — the failure already happened.
@@ -602,7 +830,8 @@ module RateCard
602
830
  # what is already visible rather than the first chance to check it.
603
831
  def answered_line(stage, value)
604
832
  label = { rate_mode: 'rate by', carrier: 'carrier', rural: 'rural / DAS',
605
- rural_surcharges: 'surcharge types', services: 'services', zones: 'zones',
833
+ rural_surcharges: 'surcharge types', services: 'services',
834
+ country: 'destination', gls_origin: 'GLS origin', zones: 'zones',
606
835
  unit: 'unit', weights: 'weights', package_type: 'package',
607
836
  rate_keys: 'columns' }[stage]
608
837
  label = 'cubic tiers' if stage == :weights && @answers[:rate_mode] == :cubic
@@ -618,6 +847,7 @@ module RateCard
618
847
  when :weights then RunSpec.compact_range(value)
619
848
  when :rural then rural_label(value)
620
849
  when :rural_surcharges then value.map { |v| v.to_s.upcase }.join(', ')
850
+ when :country then value || 'Domestic'
621
851
  when :rate_keys then value.map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')
622
852
  else value.to_s
623
853
  end
@@ -15,6 +15,13 @@ module RateCard
15
15
  class MultiSelect
16
16
  WINDOW = 8
17
17
 
18
+ HELP_BINDINGS = [
19
+ Bubbles::Key.binding(keys: [' '], help: %w[space toggle]),
20
+ Bubbles::Key.binding(keys: ['a'], help: %w[a all]),
21
+ Bubbles::Key.binding(keys: ['enter'], help: ['enter', 'confirm']),
22
+ Bubbles::Key.binding(keys: ['esc'], help: ['esc', 'back'])
23
+ ].freeze
24
+
18
25
  attr_reader :label, :error
19
26
 
20
27
  # choices: Array<[display String, value]>
@@ -30,6 +37,13 @@ module RateCard
30
37
 
31
38
  def done? = @done
32
39
 
40
+ # Pulled out of #view so App can draw it in a persistent footer bar
41
+ # instead of repeating it under every single field.
42
+ def keymap_hint
43
+ counter = Theme.muted("#{@checked.length}/#{@choices.length} selected")
44
+ "#{Theme.help_view(HELP_BINDINGS)} #{counter}"
45
+ end
46
+
33
47
  def value
34
48
  @choices.each_with_index
35
49
  .select { |_choice, index| @checked.include?(index) }
@@ -56,7 +70,6 @@ module RateCard
56
70
  def view
57
71
  lines = ["#{Theme.accent(Theme::CURSOR)} #{Theme.bold(@label)}"]
58
72
  window.each { |index| lines << choice_line(index) }
59
- lines << " #{Theme.muted(hint)}"
60
73
  lines << " #{Theme.danger(Theme::CROSS)} #{Theme.danger(@error)}" if @error
61
74
  lines.join("\n")
62
75
  end
@@ -71,11 +84,6 @@ module RateCard
71
84
  " #{pointer} #{box} #{text}"
72
85
  end
73
86
 
74
- def hint
75
- counter = "#{@checked.length}/#{@choices.length} selected"
76
- "space toggle · a all · enter confirm · esc back · #{counter}"
77
- end
78
-
79
87
  # The spacebar arrives as KEY_SPACE from some terminals and as a plain
80
88
  # ' ' rune from others; KeyMessage#space? only recognises the first, so
81
89
  # relying on it alone makes the toggle key dead on half of them.
@@ -10,6 +10,12 @@ module RateCard
10
10
  class Select
11
11
  WINDOW = 8
12
12
 
13
+ HELP_BINDINGS = [
14
+ Bubbles::Key.binding(keys: %w[up down k j], help: ['↑↓', 'move']),
15
+ Bubbles::Key.binding(keys: ['enter'], help: ['enter', 'confirm']),
16
+ Bubbles::Key.binding(keys: ['esc'], help: ['esc', 'back'])
17
+ ].freeze
18
+
13
19
  attr_reader :label
14
20
 
15
21
  # choices: Array<[display String, value]>
@@ -26,6 +32,15 @@ module RateCard
26
32
  def done? = @chosen
27
33
  def value = @value
28
34
 
35
+ # Pulled out of #view so App can draw it in a persistent footer bar
36
+ # instead of repeating it under every single field.
37
+ def keymap_hint
38
+ hint = Theme.help_view(HELP_BINDINGS)
39
+ return hint if @choices.length <= WINDOW
40
+
41
+ "#{hint} #{Theme.muted("#{@cursor + 1}/#{@choices.length}")}"
42
+ end
43
+
29
44
  def update(message)
30
45
  return nil unless message.is_a?(Bubbletea::KeyMessage)
31
46
 
@@ -50,7 +65,6 @@ module RateCard
50
65
  " #{display}"
51
66
  end
52
67
  end
53
- lines << " #{Theme.muted(hint)}"
54
68
  lines.join("\n")
55
69
  end
56
70
 
@@ -68,12 +82,6 @@ module RateCard
68
82
  start = [[@cursor - (WINDOW / 2), 0].max, @choices.length - WINDOW].min
69
83
  (start...(start + WINDOW))
70
84
  end
71
-
72
- def hint
73
- parts = ['↑↓ move', 'enter confirm', 'esc back']
74
- parts << "#{@cursor + 1}/#{@choices.length}" if @choices.length > WINDOW
75
- parts.join(' · ')
76
- end
77
85
  end
78
86
  end
79
87
  end
@@ -11,6 +11,11 @@ module RateCard
11
11
  # mistyped zone list or a mangled paste of a JWT costs a keystroke rather
12
12
  # than the run — the same guarantee the tty-prompt wizard made.
13
13
  class Text
14
+ HELP_BINDINGS = [
15
+ Bubbles::Key.binding(keys: ['enter'], help: ['enter', 'confirm']),
16
+ Bubbles::Key.binding(keys: ['esc'], help: ['esc', 'back'])
17
+ ].freeze
18
+
14
19
  attr_reader :label, :error
15
20
 
16
21
  # parse: String -> value, raising ArgumentError with a message to show.
@@ -35,6 +40,10 @@ module RateCard
35
40
  def done? = !@value.nil?
36
41
  def value = @value
37
42
 
43
+ # Pulled out of #view so App can draw it in a persistent footer bar
44
+ # instead of repeating it under every single field.
45
+ def keymap_hint = Theme.help_view(HELP_BINDINGS)
46
+
38
47
  def update(message)
39
48
  if message.is_a?(Bubbletea::KeyMessage) && message.enter?
40
49
  submit
@@ -47,7 +56,7 @@ module RateCard
47
56
 
48
57
  def view
49
58
  lines = ["#{Theme.accent(Theme::CURSOR)} #{Theme.bold(@label)} #{@input.view}"]
50
- lines << " #{Theme.muted("#{@hint} · esc back")}" if @hint
59
+ lines << " #{Theme.muted(@hint)}" if @hint
51
60
  lines << " #{Theme.danger(Theme::CROSS)} #{Theme.danger(@error)}" if @error
52
61
  lines.join("\n")
53
62
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'lipgloss'
4
+ require 'bubbles'
4
5
 
5
6
  module RateCard
6
7
  module TUI
@@ -9,11 +10,12 @@ module RateCard
9
10
  # Pastel/TTY pairing this replaced, where only Pastel was gated and the
10
11
  # spinner and bar leaked escapes into a pipe.
11
12
  module Theme
12
- ACCENT = Lipgloss::AdaptiveColor.new(dark: '#00D3FF', light: '#0072B5')
13
- OK = Lipgloss::AdaptiveColor.new(dark: '#2ECC71', light: '#1E8449')
14
- WARNING = Lipgloss::AdaptiveColor.new(dark: '#F5A623', light: '#B7791F')
15
- DANGER = Lipgloss::AdaptiveColor.new(dark: '#FF5C5C', light: '#C0392B')
16
- MUTED = Lipgloss::AdaptiveColor.new(dark: '#8A8A8A', light: '#6B6B6B')
13
+ ACCENT = Lipgloss::AdaptiveColor.new(dark: '#00D3FF', light: '#0072B5')
14
+ SECONDARY = Lipgloss::AdaptiveColor.new(dark: '#B388FF', light: '#6A3FBF')
15
+ OK = Lipgloss::AdaptiveColor.new(dark: '#2ECC71', light: '#1E8449')
16
+ WARNING = Lipgloss::AdaptiveColor.new(dark: '#F5A623', light: '#B7791F')
17
+ DANGER = Lipgloss::AdaptiveColor.new(dark: '#FF5C5C', light: '#C0392B')
18
+ MUTED = Lipgloss::AdaptiveColor.new(dark: '#8A8A8A', light: '#6B6B6B')
17
19
 
18
20
  CURSOR = '❯'
19
21
  CHECKED = '◉'
@@ -23,6 +25,16 @@ module RateCard
23
25
  BULLET = '▸'
24
26
  ALERT = '⚠'
25
27
 
28
+ # The canonical Charm keybinding legend (bubbles/help) instead of a
29
+ # hand-built "a · b · c" string — it colours the key apart from its
30
+ # description and truncates to width on its own rather than wrapping
31
+ # mid-word in a narrow terminal.
32
+ HELP = Bubbles::Help.new.tap do |help|
33
+ help.key_style = Lipgloss::Style.new.foreground(ACCENT).bold(true)
34
+ help.desc_style = Lipgloss::Style.new.foreground(MUTED)
35
+ help.separator_style = Lipgloss::Style.new.foreground(MUTED)
36
+ end
37
+
26
38
  module_function
27
39
 
28
40
  def style = Lipgloss::Style.new
@@ -34,8 +46,11 @@ module RateCard
34
46
  def muted(text) = style.foreground(MUTED).render(text)
35
47
  def bold(text) = style.bold(true).render(text)
36
48
 
49
+ # Section/scenario headings use SECONDARY rather than ACCENT so they
50
+ # read as structure, not as something the cursor is sitting on — ACCENT
51
+ # is reserved for the interactive element the user's eye should track.
37
52
  def title(text)
38
- style.bold(true).foreground(ACCENT).render(text)
53
+ style.bold(true).foreground(SECONDARY).render(text)
39
54
  end
40
55
 
41
56
  # The run targets production and cannot be pointed elsewhere, so the
@@ -46,6 +61,35 @@ module RateCard
46
61
  .padding(0, 1)
47
62
  .render("#{bold('eHub Rate Card Builder')}\n#{danger('●')} production · api.ehub.com")
48
63
  end
64
+
65
+ # Frames the live wizard body (transcript + current field) as one card
66
+ # instead of loose scrolling text, so every stage reads as part of the
67
+ # same surface rather than a fresh block of terminal output.
68
+ def panel(content)
69
+ style.border(Lipgloss::ROUNDED_BORDER)
70
+ .border_foreground(MUTED)
71
+ .padding(1, 2)
72
+ .render(content)
73
+ end
74
+
75
+ # A quiet rule between the answered-questions transcript and whatever
76
+ # is being asked right now, so the eye has a fixed place to land on
77
+ # "what's next" without re-reading everything above it.
78
+ def divider(width = 44)
79
+ muted('─' * width)
80
+ end
81
+
82
+ # Renders a row of Bubbles::Key::Binding as the short help legend.
83
+ def help_view(bindings)
84
+ HELP.short_help_view(bindings)
85
+ end
86
+
87
+ # Drawn once, below the panel, instead of every field repeating its own
88
+ # keybinding line — the keys on offer rarely change shape from field to
89
+ # field, so giving them a fixed home lets the eye stop re-reading them.
90
+ def footer(text)
91
+ " #{text}"
92
+ end
49
93
  end
50
94
  end
51
95
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RateCard
4
- VERSION = '0.1.5'
4
+ VERSION = '0.1.6'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rate-card
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - eHub
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64