Home

dev / tinycast

publicthedavidweng/tinycast· sync paused
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues

Currency conversion in the inline calculator (#35)

2 months ago

246ce28
Authored
Marius7/27/2026, 5:38:19 PM
* Add currency conversion to the inline calculator

The palette now answers "1 euro to dollars", "50 GBP in euros" and
"€20 to GBP" with the same two-column answer card as unit conversions —
original amount and currency on the left, converted amount and currency
on the right, Enter copies and records to Calculator History.

Recognises ISO codes, singular/plural names, common nicknames and the
€ £ $ ¥ ₹ … signs (folded to codes in the tokenizer, so a leading sign
parses like a trailing one), with the existing to / in / -> connectors
and an implied amount of 1.

Currency runs after the unit path, so a query both sides of which are
compatible units stays a measurement: "10 pounds to kg" is weight,
"10 pounds to euros" is money. A currency opposite a unit produces the
usual category error.

Rates come from CurrencyRateStore, a new AppCore-owned manager: one
key-less USD table cached under ~/Library/Caches/<bundle-id>/, refreshed
every 6h with a 15-minute retry after a failure. Offline the last
snapshot keeps answering; with no snapshot the card says so instead of
guessing. The store hands the engine a finished snapshot, so
Core/Calculator/ stays Foundation-only and pure — calc-test.swift runs
the currency cases against a fixed table.

Closes #33

* Source exchange rates from Frankfurter

Swaps exchangerate-api for Frankfurter (frankfurter.dev): open source,
no key, no account, no quota, and rates blended from 84 central banks
rather than one vendor's feed.

The v2 request pins `quotes` to `CalcCurrency.codes`, so it asks for
exactly the currencies the calculator can answer for — the request can't
drift from the table, and the response is ~500 bytes gzipped instead of
the 201-currency firehose. v2 replies with one flat {date, base, quote,
rate} row per pair and omits the base's own row, so the store folds both
into the [code: rate] shape CurrencyRates already stored; nothing
downstream of the snapshot changes.

Sub-cent amounts now format in plain notation instead of %g. Frankfurter
covers IDR, where 1 IDR = 0.00005539 USD would have rendered as
"5.539e-05" — an exponent is not a price.

All 49 codes in the table are covered by the feed.

* Generate the currency table from Frankfurter's own list

The ISO codes and display names were hand-typed, which meant an
arbitrary 49-currency subset and a table that silently goes stale when a
currency is redenominated or retired. Tools/gen-currencies.js now emits
CurrencyData.generated.swift from the same feed CurrencyRateStore reads
rates from, so the table and the rate source cannot drift: all 165 live
currencies resolve, and 5 usd to zmw works without anyone having typed
ZMW.

Only the mechanical half is generated. What the feed can't know stays
hand-written beside it: the natural-language aliases (quid, bucks,
euros), shorter badge labels where the formal name would overflow the
pill, and the currency-sign tie-breaks — the feed lists "$" for eleven
currencies and "¥" for two, and two thirds of its symbols are
multi-character, so no dataset can decide what "$" means here.

Retirement filtering is a backstop, not the mechanism: the default scope
already excludes legacy codes, and a live currency can trail the newest
date by a few days when its market is thin, so the cutoff is 90 days
rather than an exact match. Verified against ?scope=all, which correctly
drops the 36 retired entries and keeps the same 165.

Dropping the quotes= filter follows: the table is now the feed's own
list, so enumerating it in the URL would be a 700-character no-op. The
unfiltered response is ~1.4 KB gzipped.

CUP (Cuban peso) is the one generated code that collides with a unit;
the unit path already runs first, so 1 cup to ml stays volume. Pinned
with a test.

* Generate currency names, signs and nouns from CLDR

I claimed the sign tie-break was a product call no dataset could make.
That was wrong: CLDR writes every dollar but USD as "CA$"/"A$"/"NT$"
and every yen but JPY as "CN¥", so plain "$" and "¥" are each claimed by
exactly one currency. Deriving the map reproduces all 14 signs I had
hand-written, finds 12 more, and disagrees on none. CLDR's display names
are also better than the registry names Frankfurter serves ("US Dollar",
not "United States Dollar").

gen-currencies.js now joins Frankfurter (which currencies exist, and can
be priced) with CLDR's en data (what humans call them), emitting 165
names, 26 signs and 129 nouns. It reads the pinned cldr-json checkout
rather than the host's Intl, whose output tracks the local ICU version
and would make the file unreproducible — the same reason gen-emoji.js
downloads CLDR instead of asking the platform.

Only unambiguous data is emitted, and that filter is what makes the rest
honest. Bare Latin letters CLDR lists as symbols (P for BWP, L for HNL)
are dropped — a letter is indistinguishable from a word to the
tokenizer. Nouns are claimed both as written and diacritic-folded, so
krónur and kronur both resolve. Anything two currencies claim is left
out entirely.

What survives by hand is one table, `contested`: the 21 nouns several
currencies share, where CLDR correctly refuses to choose and we must —
dollars is claimed by 22 currencies, francs 10, pounds 9. Slang and
synonyms are gone rather than hand-maintained: quid, bucks, sterling,
rmb, rouble, reais. They had no source of truth, so they could only rot.
Genuinely ambiguous words stay assigned to nobody: krona is both SEK and
ISK, so it produces no card at all.

Net: 73 hand-written entries become 21, and recognised aliases go from
49 to 150. The badge also gains lineLimit(1) — it had none, so a long
generated name would have wrapped the pill.

* Gate currency conversion behind explicit consent

Currency conversion reaches the network, so it now ships off and stays
off until the user turns it on in Settings → Miscellaneous and accepts a
sheet naming the provider, the request cadence, and what does and
doesn't leave the machine. Declining leaves the switch where it was;
there is no half-enabled state.

The gate is a type rather than a boolean threaded through call sites.
CalcEngine.evaluate takes a CurrencySource that is either .off or
.on(CurrencyRates?), and it defaults to .off, so a caller that forgets
to pass one gets the feature disabled rather than silently enabled. .off
makes CalcCurrency.parseConversion return nil before it parses anything,
so a currency query produces no card at all — not even the category
mismatch error, which would otherwise advertise a feature the user never
turned on. .on(nil) is the consented-but-not-yet-downloaded state, and
that is what earns the "rates unavailable" message.

CurrencyRateStore re-checks consent at every entry point instead of
trusting its caller: reading the cache at init, the source handed to the
engine, start(), each turn of the refresh loop, and twice around the
request — once before it and once after the await, since consent can be
withdrawn while a response is in flight. Revoking cancels the loop,
drops the snapshot and deletes the cached file.

The flag lives on the store, deliberately not in AppSettings:
SettingsBackup mirrors that type field-for-field, so putting it there
would let an imported config — or a Raycast import — grant network
access without anyone agreeing to it.

The copy and the CLAUDE.md invariant deliberately avoid claiming this is
the only networked feature. That would be true today and wrong later, so
the rule is written to generalise: every networked feature ships off and
is consent-gated, and this store is the reference implementation.

Named "Currency Conversion", not "Currency Rate": the README, website,
docs and issue all already call it that, and the pane's own status row
is "Exchange Rates", so naming the toggle after the data would have read
as two names for one thing.

---------

Co-authored-by: abue-ammar <iabueammar@gmail.com>

Parent1d84d43

23 files changed
  • CLAUDE.md+18−3
  • README.md+1−1
  • Tinycast.xcodeproj/project.pbxproj+16−0
  • Tinycast/Core/AppCore.swift+2−0
  • Tinycast/Core/Calculator/CalcCurrency.swift+143−0
  • Tinycast/Core/Calculator/CalcEngine.swift+35−4
  • Tinycast/Core/Calculator/CalcFormatter.swift+13−0
  • Tinycast/Core/Calculator/CalcParser.swift+7
−0
  • Tinycast/Core/Calculator/CurrencyData.generated.swift+338−0
  • Tinycast/Core/CurrencyRateStore.swift+140−0
  • Tinycast/Core/PaletteWindowController.swift+1−0
  • Tinycast/Features/Launcher/CalculatorCardView.swift+23−6
  • Tinycast/Features/RootPaletteView.swift+5−1
  • Tinycast/Features/Settings/MiscellaneousSettingsView.swift+186−0
  • Tinycast/Features/Settings/SettingsRootView.swift+5−1
  • Tools/calc-test.swift+144−6
  • Tools/gen-currencies.js+165−0
  • docs/architecture.md+3−3
  • docs/calculator.md+80−3
  • docs/development.md+22−1
  • website/src/data/comparison.ts+1−1
  • website/src/data/features.ts+1−1
  • website/src/data/gallery.ts+1−1