timeprice 0.2.0 → 0.3.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: 160a9501bc1004df45cd71202f3ae07711068adade2118cfe4605a2e92d92f6f
4
- data.tar.gz: 68ea05103245f2bb1fb02d701fd5db6e1bb814a2b47d20f6eb9011d94a57b269
3
+ metadata.gz: 3b400f9dc1652d4476d884f59a2721e4f07338e76c5cc7afab50896267e1123c
4
+ data.tar.gz: 1a894e2464aa1f1495ddaa24c284b819a8afc07309cd3080f7c8c70744959ef2
5
5
  SHA512:
6
- metadata.gz: fdddfd9cbcf4cefad6ee73134eb8ef3e5ad5b863aa8d5a78fa789b8890cefec0690cf3c1022f1b596c2bdeaf6f9a8f1122d8ff6ef9adfb7dd393e0aa9346afb6
7
- data.tar.gz: 4342d02e30f4058f6e7a98eedab2fd0fa54b14031f061bf4d17b093d02f6a1e5cd16ea1f994e7d868cc893fe29119bdae1a398724665f14502c9b135ee1396b0
6
+ metadata.gz: 1f2c0b9ae5a4f8de71bdfdfb28f911b676773d406f86149b0c12ad5fbeb002ad51e42d43ed9f21d41c6ddd1a5b2760c2df58796759f66e7b78ac0dc784cee971
7
+ data.tar.gz: 8dc9e2ff73ddf7d0126538794bb4612a8709881628b713a1e878fe8fba7eddd510597f5a4f0a07e8c9d3b63cc751880df9150db208616f7fc52306ae2474bd10
data/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.3.0] - 2026-05-11
9
+
10
+ ### Added
11
+ - `Timeprice::CpiLookup` and `Timeprice::CpiPoint` (Data.define of value +
12
+ granularity). Owns all knowledge of the parsed CPI JSON shape so
13
+ `Inflation.adjust` is a 6-line orchestration.
14
+ - `Timeprice::Sources::Coverage` — isolates runtime filesystem walking
15
+ (FX year scan, JSON.parse of rate files) from the attribution registry.
16
+ - `Timeprice::Point#fx_anchor_date` — resolves a year / month / day `Point`
17
+ to the day-resolved string FX lookup needs (mid-year for `YYYY`,
18
+ mid-month for `YYYY-MM`).
19
+ - `Timeprice::Supported.decimals_for(currency)` — single source of truth
20
+ for ISO 4217 minor-unit counts; non-CLI callers of `Timeprice.exchange`
21
+ can now format results consistently.
22
+ - `Timeprice::CLI::Presenters::{Inflation, Exchange, Compare, Sources}` —
23
+ each presenter exposes `#text_lines` and `#json_hash`; the CLI dispatches
24
+ via a single `#render(presenter)` helper.
25
+
26
+ ### Changed
27
+ - CLI output redesigned for readability: every `inflation`, `fx`, and `compare`
28
+ command now leads with the answer on line 1 (e.g. `3,530,921 VND in 2024`),
29
+ followed by the calculation chain indented below. `head -1` extracts just
30
+ the headline. Numbers are comma-grouped; JSON output is rounded to currency
31
+ precision (no more `1861291.9999999998`).
32
+ - `timeprice sources` now renders as an aligned `ID / SOURCE / LICENSE /
33
+ COVERAGE` table by default. Use `timeprice sources --verbose` (`-v`) for the
34
+ previous detailed view with license URLs and full attribution.
35
+ - Top-level `timeprice help` rewritten — no more truncated descriptions; lists
36
+ command names + descriptions, matching the `git` / `gh` / `cargo` convention.
37
+ - `Point.coerce` rewritten with pattern matching; the CLI's
38
+ `parse_compare_token` now delegates to it instead of re-implementing
39
+ the shape rules.
40
+ - `Compare.resolve_points` uses explicit `raise … unless` guards instead of
41
+ `… || (raise …)` nil-pun.
42
+
43
+ ### Removed
44
+ - Undocumented back-compat constants: `Timeprice::SUPPORTED_COUNTRIES`,
45
+ `Timeprice::SUPPORTED_CURRENCIES`, and `Timeprice::Compare::CURRENCY_TO_COUNTRY`.
46
+ Use `Supported::COUNTRIES`, `Supported::CURRENCIES`, and
47
+ `Supported::CURRENCY_TO_COUNTRY` directly.
48
+ - `Lint/DuplicateBranch` RuboCop exclusion for `cli.rb` — the duplicate
49
+ was collapsed into a single `rescue Timeprice::Error, ArgumentError`.
50
+
51
+ ### Fixed
52
+ - Friendlier error messages: `Error: AMOUNT must be a number, got "abc"`
53
+ instead of Ruby's raw `invalid value for Float(): "abc"`. Missing-options
54
+ errors now say `missing required options: --from, --to` with a `See:
55
+ timeprice help inflation` hint.
56
+
8
57
  ## [0.2.0] - 2026-05-11
9
58
 
10
59
  ### Added
data/README.md CHANGED
@@ -32,16 +32,25 @@ Requires Ruby >= 3.2.
32
32
 
33
33
  ```bash
34
34
  $ timeprice inflation 100 --from 1990-01 --to 2024-01 --country US
35
- 100.00 USD in 1990-01 is 242.09 USD in 2024-01 (US, granularity: monthly)
35
+ 242.09 USD in 2024-01
36
+ 100.00 USD (1990-01) -> 242.09 USD (2024-01)
37
+ US · monthly CPI
36
38
 
37
39
  $ timeprice fx 100 USD JPY --date 2010-06-15
38
- 100.00 USD on 2010-06-15 = 9118.00 JPY (rate: 91.1800)
40
+ 9,118 JPY on 2010-06-15
41
+ 100.00 USD -> 9,118 JPY
42
+ rate 91.18
39
43
 
40
44
  $ timeprice compare 100 --from "2010 USD" --to "2024 VND"
41
- 100.00 USD in 2010 -> 3530920.58 VND in 2024
42
- steps: convert at 2010 (fx rate 18612.920000) -> 1861292.0000 VND, then inflate in VN (cpi ratio 1.897027, granularity: annual)
45
+ 3,530,921 VND in 2024
46
+ 100.00 USD (2010)
47
+ -> fx @ 18,612.92 -> 1,861,292 VND (2010)
48
+ -> inflate x1.8970 VN -> 3,530,921 VND (2024, annual)
43
49
  ```
44
50
 
51
+ The first line of each result is the answer — pipe through `head -1` if a
52
+ script only needs the headline figure.
53
+
45
54
  Every command supports `--json` for machine-readable output:
46
55
 
47
56
  ```bash
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../supported"
4
+
5
+ module Timeprice
6
+ class CLI < Thor
7
+ # Number/currency formatting helpers shared by every CLI emitter.
8
+ # Lives as a mixin (rather than a free-standing module function set) so
9
+ # callers can use the helpers as plain methods inside `no_commands` blocks.
10
+ module Formatting
11
+ def fmt_money(amount, currency)
12
+ with_commas(format("%.#{Supported.decimals_for(currency)}f", amount))
13
+ end
14
+
15
+ # Two decimals once we're past the unit threshold; six decimals for
16
+ # sub-unit rates so tiny rates (e.g. 0.000045) still carry signal.
17
+ def fmt_rate(rate)
18
+ decimals = rate.to_f.abs >= 1 ? 2 : 6
19
+ with_commas(format("%.#{decimals}f", rate))
20
+ end
21
+
22
+ def round_money(amount, currency)
23
+ amount.to_f.round(Supported.decimals_for(currency))
24
+ end
25
+
26
+ def with_commas(num_str)
27
+ sign = num_str.start_with?("-") ? "-" : ""
28
+ whole, frac = num_str.sub(/\A-/, "").split(".", 2)
29
+ whole = whole.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
30
+ frac ? "#{sign}#{whole}.#{frac}" : "#{sign}#{whole}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatting"
4
+
5
+ module Timeprice
6
+ class CLI < Thor
7
+ module Presenters
8
+ # Renders a CompareResult for the CLI in text and JSON formats.
9
+ class Compare
10
+ include Formatting
11
+
12
+ def initialize(result)
13
+ @result = result
14
+ end
15
+
16
+ def json_hash
17
+ @result.to_h.merge(
18
+ amount: round_money(@result.amount, @result.to_currency),
19
+ original_amount: round_money(@result.original_amount, @result.from_currency),
20
+ converted_amount: round_money(@result.converted_amount, @result.to_currency),
21
+ fx_rate: @result.fx_rate.to_f.round(6),
22
+ cpi_ratio: @result.cpi_ratio.to_f.round(6)
23
+ )
24
+ end
25
+
26
+ # Headline + left-to-right chain so the FX + CPI composition reads naturally.
27
+ def text_lines
28
+ final = "#{fmt_money(@result.amount, @result.to_currency)} #{@result.to_currency}"
29
+ original = "#{fmt_money(@result.original_amount, @result.from_currency)} #{@result.from_currency}"
30
+ converted = "#{fmt_money(@result.converted_amount, @result.to_currency)} #{@result.to_currency}"
31
+ step1 = "fx @ #{fmt_rate(@result.fx_rate)}"
32
+ step2 = "inflate x#{format("%.4f", @result.cpi_ratio)} #{@result.country}"
33
+ width = [step1.length, step2.length].max
34
+ [
35
+ "#{final} in #{@result.to_date}",
36
+ " #{original} (#{@result.from_date})",
37
+ format(" -> %-#{width}s -> %s (%s)", step1, converted, @result.from_date),
38
+ format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date, @result.granularity),
39
+ ]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatting"
4
+
5
+ module Timeprice
6
+ class CLI < Thor
7
+ module Presenters
8
+ # Renders an ExchangeResult for the CLI in text and JSON formats.
9
+ class Exchange
10
+ include Formatting
11
+
12
+ def initialize(result)
13
+ @result = result
14
+ end
15
+
16
+ def json_hash
17
+ @result.to_h.merge(
18
+ amount: round_money(@result.amount, @result.to),
19
+ original_amount: round_money(@result.original_amount, @result.from),
20
+ rate: @result.rate.to_f.round(6)
21
+ )
22
+ end
23
+
24
+ def text_lines
25
+ [
26
+ "#{fmt_money(@result.amount, @result.to)} #{@result.to} on #{@result.date}",
27
+ format(" %s %s -> %s %s",
28
+ fmt_money(@result.original_amount, @result.from), @result.from,
29
+ fmt_money(@result.amount, @result.to), @result.to),
30
+ " #{rate_line}",
31
+ ]
32
+ end
33
+
34
+ private
35
+
36
+ def rate_line
37
+ line = "rate #{fmt_rate(@result.rate)}"
38
+ return line unless @result.effective_date && @result.effective_date != @result.date
39
+
40
+ "#{line} from #{@result.effective_date} (fallback)"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatting"
4
+
5
+ module Timeprice
6
+ class CLI < Thor
7
+ module Presenters
8
+ # Renders an InflationResult for the CLI in text and JSON formats.
9
+ class Inflation
10
+ include Formatting
11
+
12
+ def initialize(result)
13
+ @result = result
14
+ @ccy = result.country_currency_label
15
+ end
16
+
17
+ def json_hash
18
+ @result.to_h.merge(
19
+ amount: round_money(@result.amount, @ccy),
20
+ original_amount: round_money(@result.original_amount, @ccy)
21
+ )
22
+ end
23
+
24
+ def text_lines
25
+ [
26
+ "#{fmt_money(@result.amount, @ccy)} #{@ccy} in #{@result.to}",
27
+ format(" %s %s (%s) -> %s %s (%s)",
28
+ fmt_money(@result.original_amount, @ccy), @ccy, @result.from,
29
+ fmt_money(@result.amount, @ccy), @ccy, @result.to),
30
+ " #{@result.country} · #{@result.granularity} CPI",
31
+ ]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timeprice
4
+ class CLI < Thor
5
+ module Presenters
6
+ # Renders the sources list in compact-table, verbose, and JSON formats.
7
+ class Sources
8
+ MAX_SOURCE_NAME = 60
9
+
10
+ def initialize(list, verbose: false)
11
+ @list = list
12
+ @verbose = verbose
13
+ end
14
+
15
+ def json_hash
16
+ @list
17
+ end
18
+
19
+ def text_lines
20
+ @verbose ? verbose_lines : table_lines
21
+ end
22
+
23
+ private
24
+
25
+ def table_lines
26
+ rows = @list.map do |s|
27
+ [s[:id].to_s, short_source_name(s[:name]), s[:license].to_s, s[:coverage].to_s]
28
+ end
29
+ headers = %w[ID SOURCE LICENSE COVERAGE]
30
+ widths = headers.each_with_index.map { |h, i| [h.length, *rows.map { |r| r[i].length }].max }
31
+ fmt = " %-#{widths[0]}s %-#{widths[1]}s %-#{widths[2]}s %s"
32
+ [
33
+ format(fmt, *headers),
34
+ *rows.map { |r| format(fmt, *r) },
35
+ "",
36
+ "Run `timeprice sources --verbose` for license URLs and full attribution.",
37
+ ]
38
+ end
39
+
40
+ def verbose_lines
41
+ @list.flat_map do |s|
42
+ [
43
+ s[:name].to_s,
44
+ " id: #{s[:id]}",
45
+ " license: #{s[:license]}",
46
+ " license_url: #{s[:license_url]}",
47
+ " attribution: #{s[:attribution]}",
48
+ " coverage: #{s[:coverage]}",
49
+ "",
50
+ ]
51
+ end
52
+ end
53
+
54
+ # Cap the source-name column width. Truncation is last resort — the full
55
+ # name (with series code) is preserved in `--verbose` output.
56
+ def short_source_name(name)
57
+ s = name.to_s
58
+ return s if s.length <= MAX_SOURCE_NAME
59
+
60
+ "#{s[0, MAX_SOURCE_NAME - 1]}…"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/timeprice/cli.rb CHANGED
@@ -3,6 +3,10 @@
3
3
  require "thor"
4
4
  require "json"
5
5
  require_relative "../timeprice"
6
+ require_relative "cli/presenters/inflation"
7
+ require_relative "cli/presenters/exchange"
8
+ require_relative "cli/presenters/compare"
9
+ require_relative "cli/presenters/sources"
6
10
 
7
11
  module Timeprice
8
12
  class CLI < Thor
@@ -16,8 +20,59 @@ module Timeprice
16
20
 
17
21
  class_option :json, type: :boolean, default: false, desc: "Output result as JSON"
18
22
 
23
+ # Return false so Thor::Error propagates to our wrapper in `start`, where
24
+ # we prettify the message and add a `See: timeprice help COMMAND` hint.
19
25
  def self.exit_on_failure?
20
- true
26
+ false
27
+ end
28
+
29
+ KNOWN_COMMANDS = %w[inflation fx compare sources version].freeze
30
+
31
+ # Top-level help lists command names + descriptions only — matching git,
32
+ # gh, cargo. Arg signatures live in per-command help (`timeprice help fx`).
33
+ HELP_ROWS = [
34
+ ["inflation", "Inflation-adjust an amount between two dates"],
35
+ ["fx", "Convert an amount between currencies on a date"],
36
+ ["compare", "Combine FX + inflation across (year, currency) points"],
37
+ ["sources", "List bundled data sources and coverage"],
38
+ ["version", "Print the installed timeprice version"],
39
+ ].freeze
40
+
41
+ # Pass debug: true so Thor re-raises Thor::Error (including option-parse
42
+ # failures) instead of printing its own message and silently continuing
43
+ # when exit_on_failure? is false. We catch and format ourselves.
44
+ def self.start(given_args = ARGV, config = {})
45
+ super(given_args, config.merge(debug: true))
46
+ rescue Thor::Error => e
47
+ warn "Error: #{prettify_thor_message(e.message)}"
48
+ cmd = given_args.first
49
+ warn " See: timeprice help #{cmd}" if KNOWN_COMMANDS.include?(cmd)
50
+ exit 1
51
+ end
52
+
53
+ def self.prettify_thor_message(msg)
54
+ msg
55
+ .sub(/\ANo value provided for required options /, "missing required options: ")
56
+ .gsub("'", "")
57
+ end
58
+
59
+ # Thor's API dictates the positional boolean signature — keyword arg
60
+ # would break the override.
61
+ def self.help(shell, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
62
+ return super if subcommand
63
+
64
+ shell.say "timeprice — offline historical inflation & FX for Ruby"
65
+ shell.say ""
66
+ shell.say "Commands:"
67
+ width = HELP_ROWS.map { |usage, _| usage.length }.max
68
+ HELP_ROWS.each do |usage, desc|
69
+ shell.say format(" %-#{width}s %s", usage, desc)
70
+ end
71
+ shell.say ""
72
+ shell.say "Global options:"
73
+ shell.say " --json Output result as JSON"
74
+ shell.say ""
75
+ shell.say "Run `timeprice help COMMAND` for usage and options."
21
76
  end
22
77
 
23
78
  desc "inflation AMOUNT", "Inflation-adjust an amount between two dates"
@@ -27,12 +82,12 @@ module Timeprice
27
82
  def inflation(amount)
28
83
  with_error_handling do
29
84
  result = Timeprice.inflation(
30
- amount: Float(amount),
85
+ amount: parse_amount(amount),
31
86
  from: options[:from],
32
87
  to: options[:to],
33
88
  country: options[:country]
34
89
  )
35
- emit_inflation(result)
90
+ render Presenters::Inflation.new(result)
36
91
  end
37
92
  end
38
93
 
@@ -41,12 +96,12 @@ module Timeprice
41
96
  def fx(amount, from_currency, to_currency)
42
97
  with_error_handling do
43
98
  result = Timeprice.exchange(
44
- amount: Float(amount),
99
+ amount: parse_amount(amount),
45
100
  from: from_currency,
46
101
  to: to_currency,
47
102
  date: options[:date]
48
103
  )
49
- emit_exchange(result)
104
+ render Presenters::Exchange.new(result)
50
105
  end
51
106
  end
52
107
 
@@ -58,30 +113,19 @@ module Timeprice
58
113
  from_tuple = parse_compare_token(options[:from], label: "--from")
59
114
  to_tuple = parse_compare_token(options[:to], label: "--to")
60
115
  result = Timeprice.compare(
61
- amount: Float(amount),
116
+ amount: parse_amount(amount),
62
117
  from: from_tuple,
63
118
  to: to_tuple
64
119
  )
65
- emit_compare(result)
120
+ render Presenters::Compare.new(result)
66
121
  end
67
122
  end
68
123
 
69
124
  desc "sources", "List bundled data sources and coverage"
125
+ method_option :verbose, type: :boolean, default: false, aliases: "-v",
126
+ desc: "Include license URLs and full attribution"
70
127
  def sources
71
- list = Timeprice::Sources.list
72
- if options[:json]
73
- say JSON.generate(list)
74
- else
75
- list.each do |s|
76
- say s[:name].to_s
77
- say " id: #{s[:id]}"
78
- say " license: #{s[:license]}"
79
- say " license_url: #{s[:license_url]}"
80
- say " attribution: #{s[:attribution]}"
81
- say " coverage: #{s[:coverage]}"
82
- say ""
83
- end
84
- end
128
+ render Presenters::Sources.new(Timeprice::Sources.list, verbose: options[:verbose])
85
129
  end
86
130
 
87
131
  desc "version", "Print the installed timeprice version"
@@ -94,19 +138,27 @@ module Timeprice
94
138
  end
95
139
 
96
140
  no_commands do
97
- # Currencies with no minor unit — render whole numbers, no decimals.
98
- ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
141
+ def render(presenter)
142
+ if options[:json]
143
+ say JSON.generate(presenter.json_hash)
144
+ else
145
+ presenter.text_lines.each { |line| say line }
146
+ end
147
+ end
99
148
 
100
149
  def with_error_handling
101
150
  yield
102
- rescue Timeprice::Error => e
103
- warn "Error: #{e.message}"
104
- exit 1
105
- rescue ArgumentError => e
151
+ rescue Timeprice::Error, ArgumentError => e
106
152
  warn "Error: #{e.message}"
107
153
  exit 1
108
154
  end
109
155
 
156
+ def parse_amount(raw)
157
+ Float(raw)
158
+ rescue ArgumentError, TypeError
159
+ raise ArgumentError, "AMOUNT must be a number, got #{raw.inspect}"
160
+ end
161
+
110
162
  def parse_compare_token(token, label:)
111
163
  raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
112
164
 
@@ -115,96 +167,13 @@ module Timeprice
115
167
  raise ArgumentError,
116
168
  "#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
117
169
  end
118
- year = parts.find { |p| p.match?(/\A\d{4}\z/) }
119
- currency = parts.find { |p| p.match?(/\A[A-Za-z]{3}\z/) }
120
- if year.nil? || currency.nil?
121
- raise ArgumentError,
122
- "#{label} must contain a 4-digit year and a 3-letter currency code, got #{token.inspect}"
123
- end
124
- [currency.upcase, year]
125
- end
126
-
127
- def fmt_money(amount, currency)
128
- decimals = ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
129
- format("%.#{decimals}f", amount)
130
- end
131
-
132
- def fmt_rate(rate)
133
- abs = rate.to_f.abs
134
- decimals = if abs >= 1000 then 0
135
- elsif abs >= 100 then 2
136
- elsif abs >= 10 then 3
137
- else 4
138
- end
139
- format("%.#{decimals}f", rate)
140
- end
141
-
142
- # Granularity is loud noise on the happy path. Only surface it when the
143
- # answer actually used annual data — that's where users want a heads-up.
144
- def granularity_suffix(granularity)
145
- return "" if granularity == :monthly
146
-
147
- " (granularity: #{granularity})"
148
- end
149
-
150
- def emit_inflation(result)
151
- if options[:json]
152
- say JSON.generate(result.to_h)
153
- else
154
- ccy = result.country_currency_label
155
- say format(
156
- "%s %s in %s is %s %s in %s [%s]%s",
157
- fmt_money(result.original_amount, ccy), ccy, result.from,
158
- fmt_money(result.amount, ccy), ccy, result.to,
159
- result.country, granularity_suffix(result.granularity)
160
- )
161
- end
162
- end
163
170
 
164
- def emit_exchange(result)
165
- if options[:json]
166
- say JSON.generate(result.to_h)
167
- else
168
- line = format(
169
- "%s %s on %s = %s %s (rate: %s)",
170
- fmt_money(result.original_amount, result.from), result.from, result.date,
171
- fmt_money(result.amount, result.to), result.to, fmt_rate(result.rate)
172
- )
173
- if result.effective_date && result.effective_date != result.date
174
- line += " [effective: #{result.effective_date}, fallback]"
175
- end
176
- say line
177
- end
178
- end
171
+ Point.coerce(parts)
172
+ rescue ArgumentError => e
173
+ raise if e.message.start_with?(label)
179
174
 
180
- def emit_compare(result)
181
- if options[:json]
182
- say JSON.generate(result.to_h)
183
- else
184
- say format(
185
- "%s %s in %s -> %s %s in %s",
186
- fmt_money(result.original_amount, result.from_currency), result.from_currency, result.from_date,
187
- fmt_money(result.amount, result.to_currency), result.to_currency, result.to_date
188
- )
189
- say format(
190
- " steps: %s %s -> %s %s (fx %s on %s), then inflate in %s x%.4f%s",
191
- fmt_money(result.original_amount, result.from_currency), result.from_currency,
192
- fmt_money(result.converted_amount, result.to_currency), result.to_currency,
193
- fmt_rate(result.fx_rate), result.from_date,
194
- result.country, result.cpi_ratio, granularity_suffix(result.granularity)
195
- )
196
- end
175
+ raise ArgumentError, "#{label}: #{e.message}"
197
176
  end
198
177
  end
199
178
  end
200
179
  end
201
-
202
- # Tiny shim so we can include currency context in the inflation line without
203
- # bloating the value object — the result doesn't carry currency, only country.
204
- module Timeprice
205
- class InflationResult
206
- def country_currency_label
207
- Supported.currency_for_country(country) || country.to_s.upcase
208
- end
209
- end
210
- end
@@ -28,10 +28,6 @@ module Timeprice
28
28
  # If a future refactor flips the order, the regression test in
29
29
  # spec/timeprice/compare_spec.rb will fail.
30
30
  module Compare
31
- # Map ISO currency → CPI country code. Kept as a back-compat alias;
32
- # the canonical map lives in {Supported::CURRENCY_TO_COUNTRY}.
33
- CURRENCY_TO_COUNTRY = Supported::CURRENCY_TO_COUNTRY
34
-
35
31
  module_function
36
32
 
37
33
  # Compare an amount across two (currency, date) points.
@@ -43,15 +39,14 @@ module Timeprice
43
39
  # @return [CompareResult]
44
40
  # @raise [UnsupportedCurrency] if either currency is not in {Supported::CURRENCIES}
45
41
  def run(amount:, from:, to:)
46
- from_currency, from_date, to_currency, to_date, to_country = resolve_points(from, to)
42
+ from_point, to_point, to_country = resolve_points(from, to)
47
43
 
48
44
  # Step 1: convert at source date into destination currency.
49
- fx_date = normalize_fx_date(from_date)
50
45
  fx_result = Exchange.convert(
51
46
  amount: amount,
52
- from: from_currency,
53
- to: to_currency,
54
- date: fx_date
47
+ from: from_point.currency,
48
+ to: to_point.currency,
49
+ date: from_point.fx_anchor_date
55
50
  )
56
51
  converted = fx_result.amount
57
52
 
@@ -59,18 +54,18 @@ module Timeprice
59
54
  # destination date using destination-country CPI.
60
55
  infl = Inflation.adjust(
61
56
  amount: converted,
62
- from: from_date.to_s,
63
- to: to_date.to_s,
57
+ from: from_point.date.to_s,
58
+ to: to_point.date.to_s,
64
59
  country: to_country
65
60
  )
66
61
 
67
62
  CompareResult.new(
68
63
  amount: infl.amount,
69
64
  original_amount: amount.to_f,
70
- from_currency: from_currency,
71
- from_date: from_date.to_s,
72
- to_currency: to_currency,
73
- to_date: to_date.to_s,
65
+ from_currency: from_point.currency,
66
+ from_date: from_point.date.to_s,
67
+ to_currency: to_point.currency,
68
+ to_date: to_point.date.to_s,
74
69
  country: to_country,
75
70
  fx_rate: fx_result.rate,
76
71
  cpi_ratio: infl.to_index.to_f / infl.from_index,
@@ -79,29 +74,16 @@ module Timeprice
79
74
  )
80
75
  end
81
76
 
82
- # Coerce both points and resolve to_country. Returns a 5-element tuple.
77
+ # Coerce both points and resolve to_country.
83
78
  def resolve_points(from, to)
84
79
  from_point = Point.coerce(from)
85
80
  to_point = Point.coerce(to)
86
- from_currency = from_point.currency
87
- to_currency = to_point.currency
88
- to_country = Supported.country_for_currency(to_currency) ||
89
- (raise UnsupportedCurrency, to_currency)
90
- Supported.country_for_currency(from_currency) || (raise UnsupportedCurrency, from_currency)
81
+ raise UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
91
82
 
92
- [from_currency, from_point.date, to_currency, to_point.date, to_country]
93
- end
83
+ to_country = Supported.country_for_currency(to_point.currency)
84
+ raise UnsupportedCurrency, to_point.currency unless to_country
94
85
 
95
- # If the user gave a year like "2010", anchor FX to mid-year (2010-06-30).
96
- # If they gave "YYYY-MM", anchor to the 15th. Full dates pass through.
97
- def normalize_fx_date(date)
98
- s = date.to_s
99
- case s
100
- when /\A\d{4}\z/ then "#{s}-06-30"
101
- when /\A\d{4}-\d{2}\z/ then "#{s}-15"
102
- when /\A\d{4}-\d{2}-\d{2}\z/ then s
103
- else raise ArgumentError, "Invalid date for compare: #{date.inspect}"
104
- end
86
+ [from_point, to_point, to_country]
105
87
  end
106
88
  end
107
89
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Timeprice
6
+ # CpiPoint pairs a CPI index value with the granularity of how it was
7
+ # resolved (monthly, annual, or annual derived by averaging 12 months).
8
+ CpiPoint = Data.define(:value, :granularity)
9
+
10
+ # Resolves CPI keys ("YYYY" or "YYYY-MM") to a CpiPoint against a single
11
+ # country's parsed CPI data hash. Knowing the JSON shape ("monthly" /
12
+ # "annual" string keys) is isolated here — Inflation just asks for points.
13
+ class CpiLookup
14
+ def initialize(data)
15
+ @data = data
16
+ @monthly = data["monthly"] || {}
17
+ @annual = data["annual"] || {}
18
+ end
19
+
20
+ # @param key [String] "YYYY" or "YYYY-MM"
21
+ # @return [CpiPoint]
22
+ # @raise [DataNotFound] if no CPI value covers `key`
23
+ # @raise [ArgumentError] on malformed `key`
24
+ def at(key)
25
+ key = key.to_s
26
+ case key
27
+ when /\A\d{4}-\d{2}\z/ then monthly_or_annual_fallback(key)
28
+ when /\A\d{4}\z/ then annual_or_monthly_average(key)
29
+ else raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def monthly_or_annual_fallback(month_key)
36
+ return CpiPoint.new(value: @monthly[month_key], granularity: :monthly) if @monthly.key?(month_key)
37
+
38
+ year = month_key[0, 4]
39
+ raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
40
+
41
+ CpiPoint.new(value: @annual[year], granularity: :annual)
42
+ end
43
+
44
+ def annual_or_monthly_average(year)
45
+ return CpiPoint.new(value: @annual[year], granularity: :annual) if @annual.key?(year)
46
+
47
+ months = @monthly.select { |k, _| k.start_with?("#{year}-") }
48
+ raise DataNotFound, missing_message(year) if months.empty?
49
+
50
+ CpiPoint.new(value: months.values.sum.to_f / months.size, granularity: :annual_from_monthly_avg)
51
+ end
52
+
53
+ def missing_message(key)
54
+ country = @data["country"]
55
+ ranges = []
56
+ ranges << "monthly #{@monthly.keys.min}..#{@monthly.keys.max}" if @monthly.any?
57
+ ranges << "annual #{@annual.keys.min}..#{@annual.keys.max}" if @annual.any?
58
+ hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
59
+ "No CPI data for #{key.inspect} in #{country}#{hint}"
60
+ end
61
+ end
62
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "errors"
5
+ require_relative "supported"
5
6
 
6
7
  module Timeprice
7
8
  # Loads and caches the bundled JSON data files. Override the search root
@@ -40,11 +41,10 @@ module Timeprice
40
41
  # @raise [DataNotFound] if the file is missing
41
42
  # @raise [UnsupportedSchemaVersion] if the file uses a future schema
42
43
  def load_cpi(country)
43
- @cpi_cache ||= {}
44
44
  key = country.to_s.downcase
45
45
  code = country.to_s.upcase
46
- @cpi_cache[[data_root, key]] ||= begin
47
- raise UnsupportedCountry, code unless SUPPORTED_COUNTRIES.include?(code)
46
+ cpi_cache[[data_root, key]] ||= begin
47
+ raise UnsupportedCountry, code unless Supported::COUNTRIES.include?(code)
48
48
 
49
49
  path = File.join(data_root, "cpi", "#{key}.json")
50
50
  unless File.exist?(path)
@@ -61,9 +61,8 @@ module Timeprice
61
61
  # @return [Hash] parsed JSON with a "rates" map of date → currency → Float
62
62
  # @raise [DataNotFound] if the per-year file is missing
63
63
  def load_fx_year(year)
64
- @fx_cache ||= {}
65
64
  key = year.to_i
66
- @fx_cache[[data_root, key]] ||= begin
65
+ fx_cache[[data_root, key]] ||= begin
67
66
  path = File.join(data_root, "fx", "usd", "#{key}.json")
68
67
  raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
69
68
 
@@ -73,6 +72,14 @@ module Timeprice
73
72
 
74
73
  private
75
74
 
75
+ def cpi_cache
76
+ @cpi_cache ||= {}
77
+ end
78
+
79
+ def fx_cache
80
+ @fx_cache ||= {}
81
+ end
82
+
76
83
  def parse_with_schema(path)
77
84
  data = JSON.parse(File.read(path))
78
85
  version = data["schema_version"]
@@ -3,6 +3,7 @@
3
3
  require "date"
4
4
  require_relative "errors"
5
5
  require_relative "data_loader"
6
+ require_relative "supported"
6
7
 
7
8
  module Timeprice
8
9
  ExchangeResult = Data.define(
@@ -31,8 +32,8 @@ module Timeprice
31
32
  def convert(amount:, from:, to:, date:)
32
33
  from = from.to_s.upcase
33
34
  to = to.to_s.upcase
34
- raise UnsupportedCurrency, from unless SUPPORTED_CURRENCIES.include?(from)
35
- raise UnsupportedCurrency, to unless SUPPORTED_CURRENCIES.include?(to)
35
+ raise UnsupportedCurrency, from unless Supported::CURRENCIES.include?(from)
36
+ raise UnsupportedCurrency, to unless Supported::CURRENCIES.include?(to)
36
37
 
37
38
  d = parse_date(date)
38
39
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "errors"
4
4
  require_relative "data_loader"
5
+ require_relative "cpi_lookup"
5
6
 
6
7
  module Timeprice
7
8
  # Value object returned by Inflation.adjust.
@@ -14,7 +15,15 @@ module Timeprice
14
15
  InflationResult = Data.define(
15
16
  :amount, :original_amount, :from, :to, :country,
16
17
  :from_index, :to_index, :granularity
17
- )
18
+ ) do
19
+ # The country's primary currency (e.g. "USD" for "US"). Falls back to the
20
+ # uppercased country code if the country isn't in the supported map —
21
+ # callers can still render *some* unit rather than crashing.
22
+ def country_currency_label
23
+ require_relative "supported"
24
+ Supported.currency_for_country(country) || country.to_s.upcase
25
+ end
26
+ end
18
27
 
19
28
  # CPI-based inflation adjustment for the {Supported::COUNTRIES} list.
20
29
  module Inflation
@@ -32,20 +41,20 @@ module Timeprice
32
41
  # @raise [UnsupportedCountry] if `country` is not supported
33
42
  # @raise [DataNotFound] if no CPI data covers the requested period
34
43
  def adjust(amount:, from:, to:, country:)
35
- data = DataLoader.load_cpi(country)
36
- from_index, from_gran = lookup_index(data, from)
37
- to_index, to_gran = lookup_index(data, to)
44
+ lookup = CpiLookup.new(DataLoader.load_cpi(country))
45
+ from_point = lookup.at(from)
46
+ to_point = lookup.at(to)
38
47
 
39
- ratio = to_index.to_f / from_index
48
+ ratio = to_point.value.to_f / from_point.value
40
49
  InflationResult.new(
41
50
  amount: amount.to_f * ratio,
42
51
  original_amount: amount.to_f,
43
52
  from: from,
44
53
  to: to,
45
54
  country: country.to_s.upcase,
46
- from_index: from_index,
47
- to_index: to_index,
48
- granularity: merge_granularity(from_gran, to_gran)
55
+ from_index: from_point.value,
56
+ to_index: to_point.value,
57
+ granularity: merge_granularity(from_point.granularity, to_point.granularity)
49
58
  )
50
59
  end
51
60
 
@@ -60,53 +69,6 @@ module Timeprice
60
69
  result.amount - 1.0
61
70
  end
62
71
 
63
- # Returns [index_value, granularity_symbol]
64
- def lookup_index(data, key)
65
- key = key.to_s
66
- monthly = data["monthly"] || {}
67
- annual = data["annual"] || {}
68
-
69
- case key
70
- when /\A\d{4}-\d{2}\z/
71
- if monthly.key?(key)
72
- [monthly[key], :monthly]
73
- else
74
- year = key[0, 4]
75
- raise DataNotFound, missing_cpi_message(key, data, monthly, annual) unless annual.key?(year)
76
-
77
- [annual[year], :annual]
78
-
79
- end
80
- when /\A\d{4}\z/
81
- if annual.key?(key)
82
- [annual[key], :annual]
83
- else
84
- months = monthly.select { |k, _| k.start_with?("#{key}-") }
85
- raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
86
-
87
- avg = months.values.sum.to_f / months.size
88
- [avg, :annual_from_monthly_avg]
89
- end
90
- else
91
- raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
92
- end
93
- end
94
-
95
- def missing_cpi_message(key, data, monthly, annual)
96
- country = data["country"]
97
- ranges = []
98
- if monthly.any?
99
- ks = monthly.keys.sort
100
- ranges << "monthly #{ks.first}..#{ks.last}"
101
- end
102
- if annual.any?
103
- ks = annual.keys.sort
104
- ranges << "annual #{ks.first}..#{ks.last}"
105
- end
106
- hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
107
- "No CPI data for #{key.inspect} in #{country}#{hint}"
108
- end
109
-
110
72
  # If either end fell back to annual_from_monthly_avg, propagate that label;
111
73
  # else if either is annual, propagate :annual; else :monthly.
112
74
  def merge_granularity(a, b)
@@ -21,23 +21,42 @@ module Timeprice
21
21
  # @return [Point]
22
22
  # @raise [ArgumentError] if shape can't be recognised
23
23
  def self.coerce(input)
24
- return input if input.is_a?(Point)
24
+ case input
25
+ in Point
26
+ input
27
+ in [_, _]
28
+ a, b = input.map(&:to_s)
29
+ currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
30
+ date = [a, b].find { |s| s.match?(/\A\d{4}(-\d{2}(-\d{2})?)?\z/) }
31
+ raise ArgumentError, malformed_pair_message(input) if currency.nil? || date.nil?
25
32
 
26
- unless input.is_a?(Array) && input.size == 2
33
+ new(currency: currency.upcase, date: date)
34
+ else
27
35
  raise ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
28
36
  end
37
+ end
29
38
 
30
- a, b = input.map(&:to_s)
31
- currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
32
- date = [a, b].find { |s| s.match?(/\A\d{4}(-\d{2}(-\d{2})?)?\z/) }
39
+ def self.malformed_pair_message(input)
40
+ "Could not detect currency + date in #{input.inspect} " \
41
+ "(expected a 3-letter currency and a YYYY[-MM[-DD]] date)"
42
+ end
33
43
 
34
- if currency.nil? || date.nil?
35
- raise ArgumentError,
36
- "Could not detect currency + date in #{input.inspect} " \
37
- "(expected a 3-letter currency and a YYYY[-MM[-DD]] date)"
44
+ # Resolve `date` to a full YYYY-MM-DD for FX lookup.
45
+ #
46
+ # Coarser grains anchor to a representative day:
47
+ # - "YYYY" → mid-year (YYYY-06-30)
48
+ # - "YYYY-MM" → mid-month (YYYY-MM-15)
49
+ # - "YYYY-MM-DD" → passes through
50
+ #
51
+ # @return [String]
52
+ # @raise [ArgumentError] if `date` doesn't match any supported shape
53
+ def fx_anchor_date
54
+ case date.to_s
55
+ when /\A\d{4}\z/ then "#{date}-06-30"
56
+ when /\A\d{4}-\d{2}\z/ then "#{date}-15"
57
+ when /\A\d{4}-\d{2}-\d{2}\z/ then date.to_s
58
+ else raise ArgumentError, "Invalid date for Point: #{date.inspect}"
38
59
  end
39
-
40
- new(currency: currency.upcase, date: date)
41
60
  end
42
61
  end
43
62
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../data_loader"
5
+
6
+ module Timeprice
7
+ module Sources
8
+ # Computes coverage strings for bundled data sources at runtime. All
9
+ # filesystem reads happen here so the Sources attribution registry stays
10
+ # a pure data table.
11
+ module Coverage
12
+ module_function
13
+
14
+ # @param src [Hash] one entry from Sources::ATTRIBUTIONS
15
+ # @return [String]
16
+ def for(src)
17
+ case src[:kind]
18
+ when "cpi" then cpi(src[:country])
19
+ when "fx" then fx(src[:id])
20
+ else "n/a"
21
+ end
22
+ rescue StandardError => e
23
+ "(coverage unavailable: #{e.message})"
24
+ end
25
+
26
+ def cpi(country)
27
+ data = DataLoader.load_cpi(country)
28
+ monthly = (data["monthly"] || {}).keys.sort
29
+ annual = (data["annual"] || {}).keys.sort
30
+ parts = []
31
+ parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
32
+ parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
33
+ parts.join(", ")
34
+ end
35
+
36
+ def fx(id)
37
+ years = fx_years
38
+ return "no data" if years.empty?
39
+
40
+ id == "fx_vnd" ? vnd_summary(years) : ecb_summary(years)
41
+ end
42
+
43
+ def fx_years
44
+ Dir[File.join(fx_root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
45
+ end
46
+
47
+ def vnd_summary(years)
48
+ with_vnd = years.select { |y| year_has_currency?(y, %w[VND]) }
49
+ return "no VND data" if with_vnd.empty?
50
+
51
+ "USD↔VND #{with_vnd.first}..#{with_vnd.last}"
52
+ end
53
+
54
+ def ecb_summary(years)
55
+ ecb_years = years.select { |y| year_has_currency?(y, %w[EUR GBP JPY]) }
56
+ return "no ECB data" if ecb_years.empty?
57
+
58
+ "USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
59
+ end
60
+
61
+ def year_has_currency?(year, codes)
62
+ rates = JSON.parse(File.read(File.join(fx_root, "#{year}.json")))["rates"]
63
+ rates.any? { |_, v| v.keys.intersect?(codes) }
64
+ end
65
+
66
+ def fx_root
67
+ File.join(DataLoader.data_root, "fx", "usd")
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "data_loader"
3
+ require_relative "sources/coverage"
4
4
 
5
5
  module Timeprice
6
6
  # Enumerate bundled data sources with license/attribution and the actual
@@ -79,54 +79,7 @@ module Timeprice
79
79
  # :attribution, :coverage (string like "1990-01 to 2026-03 (monthly+annual)").
80
80
  # @return [Array<Hash>]
81
81
  def list
82
- ATTRIBUTIONS.map { |s| s.merge(coverage: coverage_for(s)) }
83
- end
84
-
85
- def coverage_for(src)
86
- case src[:kind]
87
- when "cpi" then cpi_coverage(src[:country])
88
- when "fx" then fx_coverage(src[:id])
89
- else "n/a"
90
- end
91
- rescue StandardError => e
92
- "(coverage unavailable: #{e.message})"
93
- end
94
-
95
- def cpi_coverage(country)
96
- data = DataLoader.load_cpi(country)
97
- monthly = (data["monthly"] || {}).keys.sort
98
- annual = (data["annual"] || {}).keys.sort
99
- parts = []
100
- parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
101
- parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
102
- parts.join(", ")
103
- end
104
-
105
- def fx_coverage(id)
106
- root = File.join(DataLoader.data_root, "fx", "usd")
107
- years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
108
- return "no data" if years.empty?
109
-
110
- case id
111
- when "fx_vnd"
112
- # VND broadcast-from-annual covers earlier years too.
113
- with_vnd = years.select do |y|
114
- d = JSON.parse(File.read(File.join(root, "#{y}.json")))
115
- d["rates"].any? { |_, v| v.key?("VND") }
116
- end
117
- return "no VND data" if with_vnd.empty?
118
-
119
- "USD↔VND #{with_vnd.first}..#{with_vnd.last}"
120
- else
121
- # ECB pairs (EUR/GBP/JPY) start 1999
122
- ecb_years = years.select do |y|
123
- d = JSON.parse(File.read(File.join(root, "#{y}.json")))
124
- d["rates"].any? { |_, v| v.keys.intersect?(%w[EUR GBP JPY]) }
125
- end
126
- return "no ECB data" if ecb_years.empty?
127
-
128
- "USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
129
- end
82
+ ATTRIBUTIONS.map { |s| s.merge(coverage: Coverage.for(s)) }
130
83
  end
131
84
  end
132
85
  end
@@ -21,8 +21,20 @@ module Timeprice
21
21
 
22
22
  CURRENCY_TO_COUNTRY = COUNTRY_TO_CURRENCY.invert.freeze
23
23
 
24
+ # Currencies with no minor unit — formatted as whole numbers.
25
+ ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
26
+
24
27
  module_function
25
28
 
29
+ # ISO 4217 minor-unit count for a currency. Falls back to 2 for unknown
30
+ # codes so callers can still render *some* value rather than crashing.
31
+ #
32
+ # @param currency [String]
33
+ # @return [Integer]
34
+ def decimals_for(currency)
35
+ ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
36
+ end
37
+
26
38
  # @param country [String]
27
39
  # @return [Boolean]
28
40
  def country?(country)
@@ -47,9 +59,4 @@ module Timeprice
47
59
  COUNTRY_TO_CURRENCY[country.to_s.upcase]
48
60
  end
49
61
  end
50
-
51
- # Back-compat aliases — keep the old top-level constants pointing at the
52
- # canonical lists so existing requires of "errors" keep working.
53
- SUPPORTED_COUNTRIES = Supported::COUNTRIES
54
- SUPPORTED_CURRENCIES = Supported::CURRENCIES
55
62
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Timeprice
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: timeprice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -157,13 +157,20 @@ files:
157
157
  - exe/timeprice
158
158
  - lib/timeprice.rb
159
159
  - lib/timeprice/cli.rb
160
+ - lib/timeprice/cli/formatting.rb
161
+ - lib/timeprice/cli/presenters/compare.rb
162
+ - lib/timeprice/cli/presenters/exchange.rb
163
+ - lib/timeprice/cli/presenters/inflation.rb
164
+ - lib/timeprice/cli/presenters/sources.rb
160
165
  - lib/timeprice/compare.rb
166
+ - lib/timeprice/cpi_lookup.rb
161
167
  - lib/timeprice/data_loader.rb
162
168
  - lib/timeprice/errors.rb
163
169
  - lib/timeprice/exchange.rb
164
170
  - lib/timeprice/inflation.rb
165
171
  - lib/timeprice/point.rb
166
172
  - lib/timeprice/sources.rb
173
+ - lib/timeprice/sources/coverage.rb
167
174
  - lib/timeprice/supported.rb
168
175
  - lib/timeprice/version.rb
169
176
  homepage: https://github.com/patrick204nqh/timeprice