timeprice 0.1.2 → 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: b0ebaf1340f0abefa6b4dcae31f7d2f2d22021155d24ebc258f15b0495f7a480
4
- data.tar.gz: 6068762094c6008c72f4dd7becb10e1e8cb0d0c17b668234e8e0dff84c494cc2
3
+ metadata.gz: 3b400f9dc1652d4476d884f59a2721e4f07338e76c5cc7afab50896267e1123c
4
+ data.tar.gz: 1a894e2464aa1f1495ddaa24c284b819a8afc07309cd3080f7c8c70744959ef2
5
5
  SHA512:
6
- metadata.gz: fa08a556599384d4ae292064b2342bfdc6976eac3fea784afb7426d623fdd0c8ee43001135bd2f7c3a065e735db8dd6d347459144e44ee702f30505ee23c3c1f
7
- data.tar.gz: 9fd49be55c8790b4a1adfd9350fdd2e6eed82cdcbf8e41c2dd3b1e1514e5b81f9ffa71dc0a9b530738ac2f2d6fa22e969c240bd81dac043e4a4b8e23b21b6924
6
+ metadata.gz: 1f2c0b9ae5a4f8de71bdfdfb28f911b676773d406f86149b0c12ad5fbeb002ad51e42d43ed9f21d41c6ddd1a5b2760c2df58796759f66e7b78ac0dc784cee971
7
+ data.tar.gz: 8dc9e2ff73ddf7d0126538794bb4612a8709881628b713a1e878fe8fba7eddd510597f5a4f0a07e8c9d3b63cc751880df9150db208616f7fc52306ae2474bd10
data/CHANGELOG.md CHANGED
@@ -5,6 +5,69 @@ 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
+
57
+ ## [0.2.0] - 2026-05-11
58
+
59
+ ### Added
60
+ - `Timeprice::Point` value object for compare inputs; `Point.coerce` accepts `Point` instances or 2-tuples in either `[currency, date]` or `[date, currency]` order.
61
+ - `Timeprice::Supported` module — canonical home for `COUNTRIES`, `CURRENCIES`, and the bidirectional currency↔country map. Replaces the duplicated maps in `Compare` and the CLI's `InflationResult` monkey-patch.
62
+ - `Sources::Base` class extracted from the CPI fetchers; `BLS`, `ONS`, and `Eurostat` now subclass it and implement only `fetch` returning `[monthly, annual]`. The drift-check, rebase, merge, write, and summary-log flow is shared.
63
+ - Per-fetcher GitHub Actions `::warning file=…,title=…::` annotations in `scripts/update_data.rb`, so individual fetcher failures show up on the workflow run with a link to the responsible source file.
64
+ - README "Using from Rails / Rake" section covering service objects, Sidekiq, Rake tasks, and `TIMEPRICE_DATA_ROOT`.
65
+ - YARD documentation on the public API (`Timeprice.{inflation,exchange,compare}`, `Inflation`, `Exchange`, `Compare`, `DataLoader`, `Sources`, error classes, `Supported`, `Point`).
66
+
67
+ ### Changed
68
+ - `SUPPORTED_COUNTRIES` / `SUPPORTED_CURRENCIES` are now thin aliases for `Supported::COUNTRIES` / `Supported::CURRENCIES`; existing consumers keep working unchanged.
69
+ - `Compare::CURRENCY_TO_COUNTRY` is now an alias for `Supported::CURRENCY_TO_COUNTRY`.
70
+
8
71
  ## [0.1.2] - 2026-05-11
9
72
 
10
73
  ### 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
@@ -155,6 +164,91 @@ inflated = Timeprice.inflation(amount: 100, from: "2010", to: "2024", country: "
155
164
  converted = Timeprice.exchange(amount: inflated, from: "USD", to: "VND", date: "2024-06-30").amount
156
165
  ```
157
166
 
167
+ ## Using from Rails / Rake
168
+
169
+ `timeprice` is a plain Ruby library — no Railtie, no engine, no autoload magic. It works
170
+ the same way as `BigDecimal` or `JSON`: require it once, call the module functions.
171
+
172
+ ### In a Rails app
173
+
174
+ Add the gem to your `Gemfile`:
175
+
176
+ ```ruby
177
+ gem "timeprice", "~> 0.1"
178
+ ```
179
+
180
+ Then call it directly from controllers, jobs, presenters, or service objects. The library
181
+ is thread-safe (data files are loaded once and cached as frozen hashes), so it's safe to
182
+ call from threaded servers (Puma) and Sidekiq workers:
183
+
184
+ ```ruby
185
+ # app/services/historical_price.rb
186
+ class HistoricalPrice
187
+ def self.in_today_dollars(amount, year)
188
+ Timeprice.inflation(
189
+ amount: amount,
190
+ from: year.to_s,
191
+ to: Date.current.strftime("%Y-%m"),
192
+ country: "US"
193
+ ).amount
194
+ end
195
+ end
196
+ ```
197
+
198
+ Errors all inherit from `Timeprice::Error`, so a single rescue covers everything:
199
+
200
+ ```ruby
201
+ rescue Timeprice::Error => e
202
+ Rails.logger.warn("timeprice lookup failed: #{e.message}")
203
+ nil
204
+ end
205
+ ```
206
+
207
+ Result objects respond to `#to_h`, so they serialize cleanly in JSON APIs:
208
+
209
+ ```ruby
210
+ def show
211
+ render json: Timeprice.exchange(amount: 100, from: "USD", to: "EUR", date: params[:date]).to_h
212
+ end
213
+ ```
214
+
215
+ ### In a Rake task
216
+
217
+ ```ruby
218
+ # lib/tasks/inflation.rake
219
+ require "timeprice"
220
+
221
+ namespace :inflation do
222
+ desc "Print 1990→today inflation for the supported countries"
223
+ task :report do
224
+ today = Date.today.strftime("%Y-%m")
225
+ %w[US UK EU JP VN].each do |c|
226
+ r = Timeprice.inflation(amount: 100, from: "1990", to: today, country: c)
227
+ puts "#{c}: 100 in 1990 → #{r.amount.round(2)} in #{today} (#{r.granularity})"
228
+ end
229
+ end
230
+ end
231
+ ```
232
+
233
+ ### Configuring the data root
234
+
235
+ By default the gem reads from its bundled `data/` directory. To point at a different
236
+ checkout (useful for testing a new data refresh before releasing it), set
237
+ `TIMEPRICE_DATA_ROOT`:
238
+
239
+ ```bash
240
+ TIMEPRICE_DATA_ROOT=/path/to/timeprice/data bundle exec rake inflation:report
241
+ ```
242
+
243
+ Or programmatically:
244
+
245
+ ```ruby
246
+ Timeprice::DataLoader.data_root = "/path/to/timeprice/data"
247
+ ```
248
+
249
+ Reassigning `data_root` clears the in-memory cache, so it's safe to call between requests
250
+ in development.
251
+
158
252
  ## Data sources & attribution
159
253
 
160
254
  `timeprice` redistributes data from several public sources. Each is governed by its own
@@ -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