rate-card 0.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.
@@ -0,0 +1,521 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bubbletea'
4
+ require 'bubbles'
5
+ require 'ntcharts'
6
+
7
+ module RateCard
8
+ module TUI
9
+ # The interactive session as one Elm-architecture model: catalogue lookup,
10
+ # the wizard answers, the confirm, and the fetch. The token is read before
11
+ # this starts — see TokenPrompt.
12
+ #
13
+ # This replaces Wizard. The prompts no longer block — there is one event
14
+ # loop and one #update, and each answered field advances @stage — so the
15
+ # flow that used to read top-to-bottom now reads as the STAGES list plus
16
+ # #field_for. That is the cost of the architecture; what it buys is a run
17
+ # that can show progress and failures live, and a recap you can go back and
18
+ # amend without losing the token.
19
+ #
20
+ # It produces #spec and #grid for the caller to report on after the loop
21
+ # exits. Nothing here writes files or prints tables.
22
+ class App
23
+ include Bubbletea::Model
24
+
25
+ # No :token stage — see TokenPrompt for why it cannot live in the loop.
26
+ STAGES = %i[
27
+ loading rate_mode carrier services zones unit weights package_type
28
+ rate_keys confirm fetching
29
+ ].freeze
30
+
31
+ attr_reader :spec, :grid, :error, :notifier
32
+
33
+ # notifier is set by the caller to the Bubbletea::Runner, whose #send is
34
+ # the only way a worker thread can get a message onto the event loop.
35
+ attr_writer :notifier
36
+
37
+ def initialize(token:, output_base:,
38
+ client_factory: ->(tok) { Client.new(token: tok) })
39
+ @token = token
40
+ @output_base = Pathname.new(output_base)
41
+ @client_factory = client_factory
42
+
43
+ @stage = :loading
44
+ @answers = {}
45
+ @field = nil
46
+ @cancelled = false
47
+ @error = nil
48
+ @spinner = Bubbles::Spinner.new
49
+ @progress = Bubbles::Progress.new(width: 32)
50
+ @completed = 0
51
+ @failed = 0
52
+ @failure_history = []
53
+ @failure_sparkline = Ntcharts::Sparkline.new(32, 1)
54
+ @failure_sparkline.style = Lipgloss::Style.new.foreground(Theme::WARNING)
55
+ @log = []
56
+ end
57
+
58
+ def cancelled? = @cancelled
59
+
60
+ def init
61
+ [self, start_loading]
62
+ end
63
+
64
+ def update(message)
65
+ return [self, Bubbletea.quit] if quit_key?(message)
66
+
67
+ case message
68
+ when ServicesLoaded then services_loaded(message.services)
69
+ when LoadFailed then return fail_with(message.error)
70
+ when ProgressAdvanced then progress_advanced(message)
71
+ when FetchFinished then return finish(message.grid)
72
+ when FetchFailed then return fail_with(message.error)
73
+ else
74
+ return [self, spinner_or_field(message)]
75
+ end
76
+
77
+ [self, nil]
78
+ end
79
+
80
+ def view
81
+ transcript = @log.filter_map { |entry| entry[:line] }
82
+ sections = []
83
+ sections << transcript.join("\n") unless transcript.empty?
84
+ sections << stage_view
85
+ "#{sections.compact.join("\n\n")}\n"
86
+ end
87
+
88
+ private
89
+
90
+ # ---------------------------------------------------------------- input
91
+
92
+ # Ctrl-C during the fetch still quits: the run is abandoned, and the
93
+ # caller sees a nil grid rather than a partial one presented as complete.
94
+ def quit_key?(message)
95
+ return false unless message.is_a?(Bubbletea::KeyMessage)
96
+ return false unless message.to_s == 'ctrl+c'
97
+
98
+ @cancelled = true
99
+ end
100
+
101
+ def spinner_or_field(message)
102
+ return advance_spinner(message) if @stage == :loading || @stage == :fetching
103
+ return nil if @field.nil?
104
+ return retreat if back_key?(message)
105
+
106
+ command = @field.update(message)
107
+ return command unless @field.done?
108
+
109
+ value = @field.value
110
+ # The recap's own Back row, which is the esc key by another name.
111
+ return retreat if @stage == :confirm && value == :back
112
+
113
+ record(@stage, value)
114
+ advance
115
+ end
116
+
117
+ # Esc, at any field including the confirm. The fields never see it, so
118
+ # none of them has to know about the wizard it sits in.
119
+ def back_key?(message)
120
+ message.is_a?(Bubbletea::KeyMessage) && message.esc?
121
+ end
122
+
123
+ def advance_spinner(message)
124
+ @spinner, command = @spinner.update(message)
125
+ command
126
+ end
127
+
128
+ # -------------------------------------------------------------- staging
129
+
130
+ def record(stage, value)
131
+ @answers[stage] = value
132
+ @log << { stage: stage, line: answered_line(stage, value) }
133
+ end
134
+
135
+ def advance
136
+ loop do
137
+ @stage = STAGES[STAGES.index(@stage) + 1]
138
+ return start_loading if @stage == :loading
139
+ return start_fetch if @stage == :fetching
140
+
141
+ @field = field_for(@stage)
142
+ # A stage with exactly one possible answer is decided, not asked.
143
+ break unless @field.nil?
144
+ end
145
+ nil
146
+ end
147
+
148
+ # Steps back to the nearest earlier stage that actually asked something,
149
+ # skipping the ones that were decided rather than asked. The answer being
150
+ # revisited seeds the field, and every answer after it is forgotten —
151
+ # they were given against a choice that may be about to change.
152
+ def retreat
153
+ index = STAGES.index(@stage)
154
+ loop do
155
+ index -= 1
156
+ stage = STAGES[index]
157
+ return nil if index.negative? || stage.nil? || stage == :loading
158
+
159
+ field = field_for(stage)
160
+ next if field.nil?
161
+
162
+ forget_from(stage)
163
+ @stage = stage
164
+ @field = field
165
+ return nil
166
+ end
167
+ end
168
+
169
+ def forget_from(stage)
170
+ dropped = STAGES[STAGES.index(stage)..]
171
+ dropped.each { |name| @answers.delete(name) }
172
+ @log.reject! { |entry| dropped.include?(entry[:stage]) }
173
+ end
174
+
175
+ def field_for(stage)
176
+ case stage
177
+ when :rate_mode then rate_mode_field
178
+ when :carrier then carrier_field
179
+ when :services then services_field
180
+ when :zones then zones_field
181
+ when :unit then unit_field
182
+ when :weights then weights_field
183
+ when :package_type then package_type_field
184
+ when :rate_keys then rate_keys_field
185
+ when :confirm then confirm_field
186
+ end
187
+ end
188
+
189
+ # --------------------------------------------------------------- fields
190
+
191
+ # The last gate before production is touched. It opens on Back, not on
192
+ # Run: the field before it is also confirmed with enter, so a held-down
193
+ # return key must not be able to start 128 production calls by itself.
194
+ def confirm_field
195
+ Fields::Select.new(label: 'Run this rate card?',
196
+ choices: [['Run', :run], ['Back', :back]], selected: 1)
197
+ end
198
+
199
+ # Only asked when the token has at least one USPS service — cubic
200
+ # pricing is USPS-only, so a token with none has nothing to offer here
201
+ # and the question is decided as :weight and skipped, same as every
202
+ # other single-answer stage.
203
+ def rate_mode_field
204
+ return nil unless @services.any? { |service| service.carrier == 'USPS' }
205
+
206
+ choices = [['Weight', :weight], ['Cubic dimensions', :cubic]]
207
+ Fields::Select.new(label: 'Rate by', choices: choices,
208
+ selected: choices.index { |_, mode| mode == @answers[:rate_mode] } || 0)
209
+ end
210
+
211
+ def carrier_field
212
+ carriers = selectable_carriers
213
+ return nil if carriers.length <= 1
214
+
215
+ Fields::Select.new(label: 'Carrier', choices: carriers.map { |c| [c, c] },
216
+ selected: carriers.index(@answers[:carrier]) || 0)
217
+ end
218
+
219
+ # Only carriers we hold a zone chart for. A service whose carrier has no
220
+ # chart is dropped from the menu rather than offered and then refused
221
+ # mid-run by Addresses.for_carrier.
222
+ def selectable_carriers
223
+ carriers = ServiceCatalog.group_by_carrier(@services)
224
+ .keys
225
+ .select { |carrier| Constants::Addresses.supported?(carrier) }
226
+ return carriers & ['USPS'] if @answers[:rate_mode] == :cubic
227
+
228
+ carriers
229
+ end
230
+
231
+ def services_field
232
+ choices = @services.select { |service| service.carrier == carrier }
233
+ Fields::MultiSelect.new(
234
+ label: 'Services',
235
+ choices: choices.map { |service| [service.label, service] },
236
+ checked: checked_indexes(choices, @answers[:services])
237
+ )
238
+ end
239
+
240
+ def zones_field
241
+ available = Constants::Addresses.available_zones(carrier)
242
+ full = "#{available.first}-#{available.last}"
243
+ answered = @answers[:zones]
244
+
245
+ Fields::Text.new(
246
+ label: 'Zones', default: answered ? RunSpec.compact_range(answered) : full,
247
+ hint: "available: #{full}",
248
+ parse: lambda { |raw|
249
+ zones = Input.parse_range(raw) & available
250
+ raise ArgumentError, "no valid zones in that input (available: #{full})" if zones.empty?
251
+
252
+ zones
253
+ }
254
+ )
255
+ end
256
+
257
+ # Weight is fixed per cubic tier, so asking for a display unit is
258
+ # meaningless in cubic mode — decided as :oz (unused) and skipped.
259
+ def unit_field
260
+ return nil if @answers[:rate_mode] == :cubic
261
+
262
+ choices = [['oz', :oz], ['lbs', :lbs]]
263
+ Fields::Select.new(label: 'Weight unit', choices: choices,
264
+ selected: choices.index { |_, unit| unit == @answers[:unit] } || 0)
265
+ end
266
+
267
+ def weights_field
268
+ return cubic_tiers_field if @answers[:rate_mode] == :cubic
269
+
270
+ answered = @answers[:weights]
271
+
272
+ Fields::Text.new(
273
+ label: 'Weight range',
274
+ default: answered ? RunSpec.compact_range(answered) : Input::DEFAULT_WEIGHT_RANGE,
275
+ hint: 'a range like 1-16, or a list like 1,4,8',
276
+ parse: lambda { |raw|
277
+ weights = Input.parse_range(raw).reject(&:zero?)
278
+ raise ArgumentError, 'enter a range like 1-16 or a list like 1,4,8' if weights.empty?
279
+
280
+ weights
281
+ }
282
+ )
283
+ end
284
+
285
+ # Stored under the same @answers[:weights] key the weight-range text
286
+ # field uses (an array of ids rather than an array of weights) so
287
+ # #advance, #retreat and #build_spec do not need a third answer slot.
288
+ def cubic_tiers_field
289
+ choices = Constants::CubicTiers.choices
290
+ answered = @answers[:weights]
291
+ Fields::MultiSelect.new(
292
+ label: 'Cubic tiers',
293
+ choices: choices,
294
+ checked: answered ? checked_indexes(choices.map(&:last), answered) : (0...choices.length)
295
+ )
296
+ end
297
+
298
+ # The union of what the selected services accept, so a contract type like
299
+ # fedex_pak is offered and a type no selected service accepts is not.
300
+ def package_type_field
301
+ choices = @answers[:services].flat_map(&:package_types).uniq.sort
302
+ choices = Input::PACKAGE_TYPES if choices.empty?
303
+ if choices.length == 1
304
+ @answers[:package_type] = choices.first
305
+ return nil
306
+ end
307
+
308
+ Fields::Select.new(label: 'Package type', choices: choices.map { |t| [t, t] },
309
+ selected: choices.index(@answers[:package_type]) || 0)
310
+ end
311
+
312
+ def rate_keys_field
313
+ keys = RunSpec::RATE_KEY_FIELDS.keys
314
+ Fields::MultiSelect.new(
315
+ label: 'Rate columns',
316
+ choices: keys.map { |key| [RunSpec::RATE_KEY_LABELS.fetch(key), key] },
317
+ checked: @answers[:rate_keys] ? checked_indexes(keys, @answers[:rate_keys]) : (0...keys.length)
318
+ )
319
+ end
320
+
321
+ # Which rows a re-entered multi-select opens with ticked. Values that are
322
+ # no longer on offer — a service dropped by a carrier change — are simply
323
+ # not found, and so are not carried forward.
324
+ def checked_indexes(choices, answered)
325
+ return [] if answered.nil?
326
+
327
+ answered.filter_map { |value| choices.index(value) }
328
+ end
329
+
330
+ def carrier
331
+ @answers[:carrier] || selectable_carriers.first
332
+ end
333
+
334
+ # ------------------------------------------------------------ catalogue
335
+
336
+ # A Proc command: Bubbletea runs it in a Thread and feeds the returned
337
+ # message back through #update, which is how the lookup happens without
338
+ # blocking the loop that draws the spinner.
339
+ def start_loading
340
+ client = @client_factory.call(@token)
341
+ lambda do
342
+ services = ServiceCatalog.from_response(client.fetch_services)
343
+ if services.empty?
344
+ LoadFailed.new(NoServices.new('the API returned no services for this token'))
345
+ elsif (rateable = with_supported_carrier(services)).empty?
346
+ LoadFailed.new(UnsupportedCarrier.new(unsupported_message(services)))
347
+ else
348
+ ServicesLoaded.new(rateable)
349
+ end
350
+ rescue StandardError => e
351
+ LoadFailed.new(e)
352
+ end
353
+ end
354
+
355
+ def with_supported_carrier(services)
356
+ services.select { |service| Constants::Addresses.supported?(service.carrier) }
357
+ end
358
+
359
+ def unsupported_message(services)
360
+ offered = services.map(&:carrier).uniq.sort.join(', ')
361
+ "this token's services are all on carriers with no curated zone chart " \
362
+ "(#{offered}) — rate cards are only available for " \
363
+ "#{Constants::Addresses.supported_carriers.join(', ')}"
364
+ end
365
+
366
+ def services_loaded(services)
367
+ @services = services
368
+ @log << { stage: :loading, line: " #{Theme.ok(Theme::TICK)} #{Theme.bold(identity[:name])} " \
369
+ "(#{identity[:customer_id]}) · #{services.length} services found" }
370
+ advance
371
+ end
372
+
373
+ # ---------------------------------------------------------------- fetch
374
+
375
+ def start_fetch
376
+ @spec = build_spec
377
+ # Before the first call, so a bad path is not discovered after 128 of them.
378
+ begin
379
+ CsvWriter.ensure_writable!(@spec.output_base)
380
+ rescue OutputNotWritable => e
381
+ return fail_with(e).last
382
+ end
383
+
384
+ client = @client_factory.call(@token)
385
+ notifier = @notifier
386
+ spec = @spec
387
+
388
+ lambda do
389
+ completed = 0
390
+ failed = 0
391
+ mutex = Mutex.new
392
+ grid = Grid.build(spec: spec, client: client, on_progress: lambda {
393
+ mutex.synchronize { completed += 1 }
394
+ notifier&.send(ProgressAdvanced.new(completed: completed, failed: failed))
395
+ })
396
+ FetchFinished.new(grid)
397
+ rescue StandardError => e
398
+ FetchFailed.new(e)
399
+ end
400
+ end
401
+
402
+ def progress_advanced(message)
403
+ @completed = message.completed
404
+ @failed = message.failed
405
+ @failure_history << message.failed
406
+ @failure_sparkline.push(message.failed)
407
+ end
408
+
409
+ def finish(grid)
410
+ @grid = grid
411
+ [self, Bubbletea.quit]
412
+ end
413
+
414
+ def fail_with(error)
415
+ @error = error
416
+ [self, Bubbletea.quit]
417
+ end
418
+
419
+ def build_spec
420
+ cubic = @answers[:rate_mode] == :cubic
421
+ RunSpec.new(
422
+ token: @token,
423
+ customer_name: identity[:name],
424
+ customer_id: identity[:customer_id],
425
+ carrier: carrier,
426
+ services: @answers[:services],
427
+ zones: @answers[:zones],
428
+ weight_unit: @answers[:unit] || :oz,
429
+ weights: cubic ? [] : @answers[:weights],
430
+ cubic_tiers: cubic ? @answers[:weights] : [],
431
+ rate_mode: @answers[:rate_mode] || :weight,
432
+ package_type: @answers[:package_type],
433
+ rate_keys: @answers[:rate_keys],
434
+ output_base: @output_base,
435
+ show_table: true,
436
+ started_at: Time.now
437
+ ).validate!
438
+ end
439
+
440
+ def identity
441
+ @identity ||= Token.decode(@token)
442
+ end
443
+
444
+ # ----------------------------------------------------------------- view
445
+
446
+ def stage_view
447
+ case @stage
448
+ when :loading then " #{@spinner.view} Loading available services"
449
+ when :fetching then fetch_view
450
+ when :confirm then "#{recap_view}\n\n#{@field.view}"
451
+ else @field&.view
452
+ end
453
+ end
454
+
455
+ # Everything the run will do, gathered in one block. The answers are also
456
+ # in the transcript above, but they arrived one at a time over eight
457
+ # screens; this is the only place they can be read against each other.
458
+ def recap_view
459
+ [
460
+ " #{Theme.bold(carrier)} · #{@answers[:services].map(&:name).join(', ')}",
461
+ " zones #{RunSpec.compact_range(@answers[:zones])} · #{rows_recap} · #{@answers[:package_type]}",
462
+ " columns: #{@answers[:rate_keys].map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')}",
463
+ " #{Theme.bold(call_count.to_s)} rate calls against #{Theme.danger('production')}"
464
+ ].join("\n")
465
+ end
466
+
467
+ def rows_recap
468
+ if @answers[:rate_mode] == :cubic
469
+ "cubic tiers #{RunSpec.compact_range(@answers[:weights])}"
470
+ else
471
+ "weights #{RunSpec.compact_range(@answers[:weights])} #{@answers[:unit]}"
472
+ end
473
+ end
474
+
475
+ def call_count
476
+ @answers[:weights].length * @answers[:zones].length
477
+ end
478
+
479
+ def fetch_view
480
+ total = @spec.call_count
481
+ percent = total.zero? ? 0.0 : @completed.to_f / total
482
+ line = " #{@progress.view_as(percent)} #{@completed}/#{total}"
483
+ line += " #{Theme.warning("#{Theme::ALERT} #{@failed} failed")}" if @failed.positive?
484
+ view = "#{Theme.bold(' fetching rates')}\n#{line}"
485
+ view += "\n#{failure_sparkline_view}" if @failed.positive?
486
+ view
487
+ end
488
+
489
+ # Only drawn once a failure has happened: a spark line of zeroes for a
490
+ # clean run would be noise, not signal. It stays once shown even if
491
+ # later ticks are all clean — the failure already happened.
492
+ def failure_sparkline_view
493
+ @failure_sparkline.draw_braille
494
+ " #{@failure_sparkline.view}"
495
+ end
496
+
497
+ # One line per answered stage, kept above the current field so the
498
+ # transcript of the run stays on screen — the recap is then a summary of
499
+ # what is already visible rather than the first chance to check it.
500
+ def answered_line(stage, value)
501
+ label = { rate_mode: 'rate by', carrier: 'carrier', services: 'services', zones: 'zones',
502
+ unit: 'unit', weights: 'weights', package_type: 'package',
503
+ rate_keys: 'columns' }[stage]
504
+ label = 'cubic tiers' if stage == :weights && @answers[:rate_mode] == :cubic
505
+ return nil if label.nil?
506
+
507
+ " #{Theme.ok(Theme::TICK)} #{label}: #{Theme.bold(describe(stage, value))}"
508
+ end
509
+
510
+ def describe(stage, value)
511
+ case stage
512
+ when :services then value.map(&:name).join(', ')
513
+ when :zones then RunSpec.compact_range(value)
514
+ when :weights then RunSpec.compact_range(value)
515
+ when :rate_keys then value.map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')
516
+ else value.to_s
517
+ end
518
+ end
519
+ end
520
+ end
521
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module RateCard
6
+ module TUI
7
+ module Fields
8
+ # Pick one or more. There is no multi-select in bubbles — List is
9
+ # single-select — so this is the one widget the migration had to build,
10
+ # and the wizard needs it twice (services, rate columns).
11
+ #
12
+ # Enter with nothing ticked is refused rather than accepted as "none":
13
+ # every caller here has a min of 1, and a silent empty answer would fail
14
+ # much later in RunSpec#validate!.
15
+ class MultiSelect
16
+ WINDOW = 8
17
+
18
+ attr_reader :label, :error
19
+
20
+ # choices: Array<[display String, value]>
21
+ # checked: indexes ticked on entry.
22
+ def initialize(label:, choices:, checked: [])
23
+ @label = label
24
+ @choices = choices
25
+ @checked = checked.to_a.to_set
26
+ @cursor = 0
27
+ @error = nil
28
+ @done = false
29
+ end
30
+
31
+ def done? = @done
32
+
33
+ def value
34
+ @choices.each_with_index
35
+ .select { |_choice, index| @checked.include?(index) }
36
+ .map { |choice, _index| choice.last }
37
+ end
38
+
39
+ def update(message)
40
+ return nil unless message.is_a?(Bubbletea::KeyMessage)
41
+
42
+ if message.up? || message.to_s == 'k'
43
+ move(-1)
44
+ elsif message.down? || message.to_s == 'j'
45
+ move(1)
46
+ elsif space?(message)
47
+ toggle
48
+ elsif message.to_s == 'a'
49
+ toggle_all
50
+ elsif message.enter?
51
+ submit
52
+ end
53
+ nil
54
+ end
55
+
56
+ def view
57
+ lines = ["#{Theme.accent(Theme::CURSOR)} #{Theme.bold(@label)}"]
58
+ window.each { |index| lines << choice_line(index) }
59
+ lines << " #{Theme.muted(hint)}"
60
+ lines << " #{Theme.danger(Theme::CROSS)} #{Theme.danger(@error)}" if @error
61
+ lines.join("\n")
62
+ end
63
+
64
+ private
65
+
66
+ def choice_line(index)
67
+ display = @choices[index].first
68
+ box = @checked.include?(index) ? Theme.ok(Theme::CHECKED) : Theme.muted(Theme::UNCHECKED)
69
+ pointer = index == @cursor ? Theme.accent(Theme::CURSOR) : ' '
70
+ text = index == @cursor ? Theme.accent(display) : display
71
+ " #{pointer} #{box} #{text}"
72
+ end
73
+
74
+ def hint
75
+ counter = "#{@checked.length}/#{@choices.length} selected"
76
+ "space toggle · a all · enter confirm · esc back · #{counter}"
77
+ end
78
+
79
+ # The spacebar arrives as KEY_SPACE from some terminals and as a plain
80
+ # ' ' rune from others; KeyMessage#space? only recognises the first, so
81
+ # relying on it alone makes the toggle key dead on half of them.
82
+ def space?(message)
83
+ message.space? || message.to_s == ' '
84
+ end
85
+
86
+ def move(delta)
87
+ @cursor = (@cursor + delta) % @choices.length
88
+ end
89
+
90
+ def toggle
91
+ @checked.include?(@cursor) ? @checked.delete(@cursor) : @checked.add(@cursor)
92
+ @error = nil
93
+ end
94
+
95
+ # All-or-nothing on one key: with eight services enabled, ticking each
96
+ # in turn is the most common thing the old checkbox prompt made tedious.
97
+ def toggle_all
98
+ @checked = @checked.length == @choices.length ? Set.new : (0...@choices.length).to_set
99
+ @error = nil
100
+ end
101
+
102
+ def submit
103
+ if @checked.empty?
104
+ @error = 'select at least one — space to tick, a for all'
105
+ return
106
+ end
107
+
108
+ @done = true
109
+ end
110
+
111
+ def window
112
+ return (0...@choices.length) if @choices.length <= WINDOW
113
+
114
+ start = [[@cursor - (WINDOW / 2), 0].max, @choices.length - WINDOW].min
115
+ (start...(start + WINDOW))
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end