@jarenjs/calc 0.34.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.
Files changed (55) hide show
  1. package/README.md +60 -0
  2. package/dist/types/ast.d.ts +65 -0
  3. package/dist/types/compile.d.ts +52 -0
  4. package/dist/types/component/index.d.ts +259 -0
  5. package/dist/types/component/rates/binance.d.ts +34 -0
  6. package/dist/types/component/rates/coingecko.d.ts +47 -0
  7. package/dist/types/component/rates/index.d.ts +84 -0
  8. package/dist/types/component/rules.d.ts +180 -0
  9. package/dist/types/component/schema.d.ts +38 -0
  10. package/dist/types/env.d.ts +24 -0
  11. package/dist/types/errors.d.ts +18 -0
  12. package/dist/types/index.d.ts +42 -0
  13. package/dist/types/modes/converter.d.ts +46 -0
  14. package/dist/types/modes/financial.d.ts +59 -0
  15. package/dist/types/modes/index.d.ts +38 -0
  16. package/dist/types/modes/programmer.d.ts +50 -0
  17. package/dist/types/modes/scientific.d.ts +28 -0
  18. package/dist/types/modes/standard.d.ts +32 -0
  19. package/dist/types/parser/index.d.ts +32 -0
  20. package/dist/types/plot/plot2d.d.ts +70 -0
  21. package/dist/types/plot/plot3d.d.ts +68 -0
  22. package/dist/types/render/error.d.ts +19 -0
  23. package/dist/types/theme.d.ts +35 -0
  24. package/dist/types/to-expr.d.ts +13 -0
  25. package/dist/types/utils.d.ts +9 -0
  26. package/docs/CALC-FORMAT.md +79 -0
  27. package/package.json +71 -0
  28. package/schemas/financial-inputs.schema.json +15 -0
  29. package/schemas/jaren-calc-ast.schema.json +91 -0
  30. package/schemas/jaren-calc-state.schema.json +52 -0
  31. package/src/ast.js +96 -0
  32. package/src/compile.js +119 -0
  33. package/src/component/index.js +352 -0
  34. package/src/component/rates/binance.js +44 -0
  35. package/src/component/rates/coingecko.js +63 -0
  36. package/src/component/rates/index.js +116 -0
  37. package/src/component/rules.js +118 -0
  38. package/src/component/schema.js +24 -0
  39. package/src/env.js +163 -0
  40. package/src/errors.js +23 -0
  41. package/src/index.js +85 -0
  42. package/src/modes/converter.js +73 -0
  43. package/src/modes/financial.js +89 -0
  44. package/src/modes/index.js +24 -0
  45. package/src/modes/programmer.js +69 -0
  46. package/src/modes/scientific.js +31 -0
  47. package/src/modes/standard.js +39 -0
  48. package/src/parser/index.js +220 -0
  49. package/src/plot/plot2d.js +221 -0
  50. package/src/plot/plot3d.js +177 -0
  51. package/src/render/error.js +25 -0
  52. package/src/theme.js +76 -0
  53. package/src/to-expr.js +84 -0
  54. package/src/utils.js +11 -0
  55. package/styles/calc.css +128 -0
@@ -0,0 +1,352 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The Calculator COMPONENT — part two of the package. Everything
4
+ * here is presentation + app glue; the engine knows none of it. `createCalcComponent(options)` returns the
5
+ * pieces the site (and a standalone `createApp`) compose into one
6
+ * `@jarenjs/app` document:
7
+ *
8
+ * - `initialState()` — the plain-JSON `$.calc` slice (immutable, COW).
9
+ * - `actions` — key/mode/plot/converter action query documents + the
10
+ * financial `@jarenjs/forms` write actions.
11
+ * - `mode` + `rules` — the JSLT `calculator` view (keypad, display, tape,
12
+ * mode menu, plot panel, financial form, converter).
13
+ * - `effects` / `subs` / `subEntry` — the live-rates effect + `when`-gated
14
+ * poll; the pure conversion stays in `@jarenjs/core/convert`.
15
+ * - `viewModel(state)` (a.k.a. `contributeCalcViewModel`) — the derivation
16
+ * boundary: display string, live result, four-base views, plot vnode,
17
+ * forms model, converter options — none of it stored in state.
18
+ * - `createApp()` — a standalone app document for reuse outside the site.
19
+ *
20
+ * The boundary is one-way: the component imports the engine, never the
21
+ * reverse.
22
+ */
23
+
24
+ import {
25
+ createApp as appCreateApp, createFormView, createFormActions, formEventFields,
26
+ } from '@jarenjs/app';
27
+ import { buildFormModel, buildFormViewModel } from '@jarenjs/forms';
28
+
29
+ import {
30
+ calcToVnode, evaluate, MODE_BY_ID, MODES,
31
+ programmerEnv, defaultEnv, wordViews,
32
+ solveTvm, convertValue, unitOptions, converterDimensions,
33
+ } from '../index.js';
34
+ import { standardMode } from '../modes/standard.js';
35
+ import { BASES, WORD_SIZES } from '../modes/programmer.js';
36
+ import { financialMode } from '../modes/financial.js';
37
+ import { converterMode } from '../modes/converter.js';
38
+ import { createRatesLayer, FALLBACK_RATES, CURRENCY_CODES } from './rates/index.js';
39
+ import { CALCULATOR_RULES } from './rules.js';
40
+ import { FINANCIAL_SCHEMA } from './schema.js';
41
+
42
+ const FIN_MODEL = buildFormModel(FINANCIAL_SCHEMA);
43
+
44
+ /**
45
+ * Namespaced form action names so the financial panel never collides with
46
+ * another `@jarenjs/forms` instance on the same page (e.g. the website's
47
+ * schema playground, which also spreads `createFormActions`).
48
+ */
49
+ export const FORM_ACTIONS = {
50
+ input: 'calc-form/input', check: 'calc-form/check', number: 'calc-form/number',
51
+ add: 'calc-form/add', remove: 'calc-form/remove',
52
+ };
53
+
54
+ /** The financial form's view rules (mode 'calculator'), reusable by the site. */
55
+ export const calcFormViewRules = createFormView({ root: '$.ui.calculator.financial.form', actions: FORM_ACTIONS })
56
+ .map((rule) => ({ ...rule, mode: 'calculator' }));
57
+
58
+ /**
59
+ * The initial `$.calc` state slice.
60
+ * @returns {any}
61
+ */
62
+ export function calcInitialState() {
63
+ return {
64
+ mode: 'standard',
65
+ entry: '',
66
+ ans: 0,
67
+ memory: 0,
68
+ angleMode: 'rad',
69
+ base: 'DEC',
70
+ wordBits: 32,
71
+ signed: false,
72
+ tape: [],
73
+ plot: { expr: 'sin(x)', kind: '2d' },
74
+ fin: { nper: 360, rate: 0.5, pv: 200000, pmt: 0, fv: 0, solveFor: 'pmt' },
75
+ conv: { dimension: 'currency', from: 'USD', to: 'EUR', value: 100 },
76
+ rates: { ...FALLBACK_RATES },
77
+ };
78
+ }
79
+
80
+ /** The scope for evaluating the entry buffer in a given state. */
81
+ function scopeOf(calc) {
82
+ return {
83
+ angleMode: calc.angleMode,
84
+ wordBits: calc.wordBits,
85
+ signed: calc.signed,
86
+ ans: calc.ans,
87
+ mem: calc.memory,
88
+ memory: calc.memory,
89
+ };
90
+ }
91
+
92
+ /** The evaluation env for a mode. */
93
+ function envForMode(mode) {
94
+ return mode === 'programmer' ? programmerEnv() : defaultEnv();
95
+ }
96
+
97
+ /** The `on` click binding for a keypad key `k`. */
98
+ function keyBinding(k) {
99
+ if (k === 'clear') return { click: { action: 'calc/clear' } };
100
+ if (k === 'back') return { click: { action: 'calc/back' } };
101
+ if (k === 'equals') return { click: { action: 'calc/equals' } };
102
+ return { click: { action: 'calc/key', with: { k } } };
103
+ }
104
+
105
+ /** Project a mode's keypad descriptor into render-ready rows. */
106
+ function keypadFor(mode) {
107
+ const desc = MODE_BY_ID[mode];
108
+ const rows = desc && desc.keypad ? desc.keypad : standardMode.keypad;
109
+ return rows.map((row, ri) => ({
110
+ key: 'r' + ri,
111
+ keys: row.map((k) => ({
112
+ key: k.label + ':' + k.k,
113
+ label: k.label,
114
+ cls: 'calc-key' + (k.tone ? ' calc-key-' + k.tone : '') + (k.span ? ' calc-key-span' + k.span : ''),
115
+ on: keyBinding(k.k),
116
+ })),
117
+ }));
118
+ }
119
+
120
+ /**
121
+ * The action query documents. `dataPointer` for the financial form is
122
+ * `/calc/fin`.
123
+ * @returns {Record<string, any>}
124
+ */
125
+ export const calcActions = {
126
+ 'calc/key': { patch: [{ op: 'replace', path: '/calc/entry', value: { $concat: ['$.calc.entry', '$payload.k'] } }] },
127
+ 'calc/clear': { patch: [{ op: 'replace', path: '/calc/entry', value: '' }] },
128
+ 'calc/set-entry': { patch: [{ op: 'replace', path: '/calc/entry', value: '$payload' }] },
129
+ 'calc/back': { effects: [{ run: 'calc-edit', with: { entry: '$.calc.entry' } }] },
130
+ 'calc/equals': {
131
+ effects: [{ run: 'calc-eval', with: {
132
+ entry: '$.calc.entry', mode: '$.calc.mode',
133
+ angleMode: '$.calc.angleMode', wordBits: '$.calc.wordBits',
134
+ signed: '$.calc.signed', ans: '$.calc.ans', memory: '$.calc.memory',
135
+ } }],
136
+ },
137
+ 'calc/commit': {
138
+ patch: [
139
+ { op: 'replace', path: '/calc/ans', value: '$payload.value' },
140
+ { op: 'replace', path: '/calc/entry', value: '$payload.display' },
141
+ { op: 'add', path: '/calc/tape/-', value: { expr: '$payload.expr', result: '$payload.display' } },
142
+ ],
143
+ },
144
+ 'calc/mode': { patch: [{ op: 'replace', path: '/calc/mode', value: '$payload.mode' }] },
145
+ 'calc/angle': { patch: [{ op: 'replace', path: '/calc/angleMode', value: '$payload.mode' }] },
146
+ 'calc/base': { patch: [{ op: 'replace', path: '/calc/base', value: '$payload.base' }] },
147
+ 'calc/word': { patch: [{ op: 'replace', path: '/calc/wordBits', value: { $number: '$payload.bits' } }] },
148
+ 'calc/sign': { patch: [{ op: 'replace', path: '/calc/signed', value: { $if: ['$.calc.signed', false, true] } }] },
149
+ 'calc/mem-add': { patch: [{ op: 'replace', path: '/calc/memory', value: { $add: ['$.calc.memory', '$.calc.ans'] } }] },
150
+ 'calc/mem-clear': { patch: [{ op: 'replace', path: '/calc/memory', value: 0 }] },
151
+ // plotting
152
+ 'calc/plot-expr': { patch: [{ op: 'replace', path: '/calc/plot/expr', value: '$event.value' }] },
153
+ 'calc/plot-kind': { patch: [{ op: 'replace', path: '/calc/plot/kind', value: '$payload.kind' }] },
154
+ // converter
155
+ 'calc/conv-dim': { patch: [{ op: 'replace', path: '/calc/conv/dimension', value: '$event.value' }] },
156
+ 'calc/conv-from': { patch: [{ op: 'replace', path: '/calc/conv/from', value: '$event.value' }] },
157
+ 'calc/conv-to': { patch: [{ op: 'replace', path: '/calc/conv/to', value: '$event.value' }] },
158
+ 'calc/conv-value': { patch: [{ op: 'replace', path: '/calc/conv/value', value: { $number: '$event.value' } }] },
159
+ 'calc/conv-swap': {
160
+ patch: [
161
+ { op: 'replace', path: '/calc/conv/from', value: '$.calc.conv.to' },
162
+ { op: 'replace', path: '/calc/conv/to', value: '$.calc.conv.from' },
163
+ ],
164
+ },
165
+ 'calc/rates-refresh': { effects: [{ run: 'rates-fetch', with: { force: true } }] },
166
+ 'calc/rates-ok': { patch: [{ op: 'replace', path: '/calc/rates', value: '$payload' }] },
167
+ 'calc/rates-err': { patch: [{ op: 'replace', path: '/calc/rates/status', value: 'error' }] },
168
+ // the financial panel writes through the (namespaced) @jarenjs/forms actions
169
+ ...createFormActions({ dataPointer: '/calc/fin', actions: FORM_ACTIONS }),
170
+ };
171
+
172
+ /** All calculator view rules (the calculator UI + the financial form). */
173
+ export const calcViewRules = [...CALCULATOR_RULES, ...calcFormViewRules];
174
+
175
+ export { createRatesLayer, FALLBACK_RATES, CURRENCY_CODES } from './rates/index.js';
176
+
177
+ /** The `calc-edit` (backspace) and `calc-eval` (=) JS effect handlers. */
178
+ export const calcEditEffects = {
179
+ 'calc-edit': (props, dispatch) => {
180
+ const entry = String(props?.entry ?? '');
181
+ dispatch('calc/set-entry', entry.slice(0, -1));
182
+ },
183
+ 'calc-eval': (props, dispatch) => {
184
+ const entry = String(props?.entry ?? '');
185
+ if (entry === '') return;
186
+ const mode = props.mode ?? 'standard';
187
+ const env = envForMode(mode);
188
+ const scope = {
189
+ angleMode: props.angleMode, wordBits: props.wordBits, signed: props.signed,
190
+ ans: props.ans, mem: props.memory, memory: props.memory,
191
+ };
192
+ const res = evaluate(entry, scope, { env });
193
+ if (!res.ok) return; // leave the entry; the live display shows the error
194
+ const desc = MODE_BY_ID[mode] ?? standardMode;
195
+ const display = desc.format
196
+ ? desc.format(res.value, { angleMode: props.angleMode, wordBits: props.wordBits, signed: props.signed, base: props.base })
197
+ : String(res.value);
198
+ dispatch('calc/commit', { value: res.value, display, expr: entry });
199
+ },
200
+ };
201
+
202
+ /**
203
+ * The viewModel derivation for the calculator (`contributeCalcViewModel`).
204
+ * Pure: state in, UI document out, no dispatching.
205
+ * @param {any} state
206
+ * @param {{ theme?: any }} [options] plot theme (name, overrides, or
207
+ * `'host'` to follow the embedding host's tokens)
208
+ * @returns {any}
209
+ */
210
+ export function contributeCalcViewModel(state, options = {}) {
211
+ const calc = state.calc;
212
+ if (!calc) return null;
213
+ const desc = MODE_BY_ID[calc.mode] ?? standardMode;
214
+ const env = envForMode(calc.mode);
215
+ const scope = scopeOf(calc);
216
+
217
+ // live result of the current entry
218
+ const live = calc.entry === '' ? { ok: true, value: calc.ans } : evaluate(calc.entry, scope, { env });
219
+ const display = {
220
+ entry: calc.entry === '' ? '0' : calc.entry,
221
+ result: live.ok ? desc.format(live.value, calc) : '',
222
+ error: live.ok ? null : (live.error.message + (live.error.line ? ` (${live.error.line}:${live.error.column})` : '')),
223
+ };
224
+
225
+ // programmer four-base view
226
+ const pv = live.ok && Number.isFinite(live.value) ? live.value : calc.ans;
227
+ const bases = {
228
+ base: calc.base, wordBits: calc.wordBits, signed: calc.signed,
229
+ baseOptions: BASES.map((b) => ({ id: b, label: b, active: b === calc.base, on: { click: { action: 'calc/base', with: { base: b } } } })),
230
+ wordOptions: WORD_SIZES.map((w) => ({ id: String(w), label: w + '-bit', active: w === calc.wordBits, on: { click: { action: 'calc/word', with: { bits: w } } } })),
231
+ signLabel: calc.signed ? 'signed' : 'unsigned',
232
+ views: wordViews(pv, calc.wordBits, calc.signed),
233
+ };
234
+
235
+ // financial: reuse @jarenjs/forms model + viewModel; solve via core
236
+ let financial = null;
237
+ if (calc.mode === 'financial') {
238
+ let form = null;
239
+ try { form = buildFormViewModel(FIN_MODEL, calc.fin, { validateFields: true }); }
240
+ catch { form = null; }
241
+ const result = solveTvm({ ...calc.fin });
242
+ financial = { form, solveFor: calc.fin.solveFor, result: financialMode.format(result) };
243
+ }
244
+
245
+ // converter: options + live result, all through core/convert
246
+ let converter = null;
247
+ if (calc.mode === 'converter') {
248
+ const dim = calc.conv.dimension;
249
+ const units = unitOptions(dim, calc.rates);
250
+ const result = convertValue(dim, +calc.conv.value, calc.conv.from, calc.conv.to, calc.rates);
251
+ converter = {
252
+ dimension: dim,
253
+ dimensions: converterDimensions().map((d) => ({ id: d, label: d, selected: d === dim })),
254
+ from: calc.conv.from,
255
+ to: calc.conv.to,
256
+ unitsFrom: units.map((u) => ({ id: u.id, symbol: u.symbol, selected: u.id === calc.conv.from })),
257
+ unitsTo: units.map((u) => ({ id: u.id, symbol: u.symbol, selected: u.id === calc.conv.to })),
258
+ value: calc.conv.value,
259
+ result: converterMode.format(result),
260
+ isCurrency: dim === 'currency',
261
+ rates: { status: calc.rates.status ?? 'idle', stale: calc.rates.stale === true, at: calc.rates.at ?? 0, count: Object.keys(calc.rates.rates ?? {}).length },
262
+ };
263
+ }
264
+
265
+ // plot vnode (prebuilt; spliced verbatim into the view)
266
+ let plot = null;
267
+ if (calc.mode !== 'financial' && calc.mode !== 'converter' && calc.mode !== 'programmer') {
268
+ plot = {
269
+ expr: calc.plot.expr,
270
+ kind: calc.plot.kind,
271
+ svg: calcToVnode(calc.plot.expr, { kind: calc.plot.kind, theme: options.theme }),
272
+ kinds: [
273
+ { id: '2d', label: 'x·y', active: calc.plot.kind === '2d', on: { click: { action: 'calc/plot-kind', with: { kind: '2d' } } } },
274
+ { id: '3d', label: 'x·y·z', active: calc.plot.kind === '3d', on: { click: { action: 'calc/plot-kind', with: { kind: '3d' } } } },
275
+ ],
276
+ };
277
+ }
278
+
279
+ return {
280
+ mode: calc.mode,
281
+ modes: MODES.map((m) => ({ id: m.id, label: m.label, active: m.id === calc.mode, on: { click: { action: 'calc/mode', with: { mode: m.id } } } })),
282
+ isStandard: calc.mode === 'standard' || calc.mode === 'scientific',
283
+ isScientific: calc.mode === 'scientific',
284
+ isProgrammer: calc.mode === 'programmer',
285
+ isFinancial: calc.mode === 'financial',
286
+ isConverter: calc.mode === 'converter',
287
+ display,
288
+ keypad: keypadFor(calc.mode),
289
+ tape: calc.tape.map((t, i) => ({ key: 'tape' + i, expr: t.expr, result: t.result })),
290
+ memory: calc.memory,
291
+ angle: { mode: calc.angleMode, options: ['rad', 'deg', 'grad'].map((a) => ({ id: a, label: a, active: a === calc.angleMode, on: { click: { action: 'calc/angle', with: { mode: a } } } })) },
292
+ bases,
293
+ financial,
294
+ converter,
295
+ plot,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Create the calculator component.
301
+ * @param {import('./rates/index.js').RatesLayerOptions} [options]
302
+ * @returns {any}
303
+ */
304
+ export function createCalcComponent(options = {}) {
305
+ const rates = createRatesLayer(options);
306
+ const formViewRules = calcFormViewRules;
307
+
308
+ return {
309
+ initialState: calcInitialState,
310
+ actions: calcActions,
311
+ mode: 'calculator',
312
+ rules: calcViewRules,
313
+ formRules: formViewRules,
314
+ effects: { ...calcEditEffects, ...rates.effects },
315
+ subs: { ...rates.subs },
316
+ subEntry: rates.subEntry,
317
+ fallbackRates: rates.fallbackRates,
318
+ viewModel: contributeCalcViewModel,
319
+ codes: CURRENCY_CODES,
320
+
321
+ /**
322
+ * A standalone app document + running app (headless if `node` omitted).
323
+ * @param {any} [appOptions]
324
+ */
325
+ createApp(appOptions = {}) {
326
+ const appDoc = {
327
+ $app: '0.1',
328
+ state: { calc: calcInitialState() },
329
+ view: {
330
+ $jslt: '0.1',
331
+ modes: { calculator: { unmatched: 'error' } },
332
+ rules: [
333
+ // the entry rule lives in the default (unnamed) mode
334
+ { match: '$', body: ['div', { class: 'jaren-calc-app' }, { $apply: ['$.ui.calculator', 'calculator'] }] },
335
+ ...CALCULATOR_RULES,
336
+ ...formViewRules,
337
+ ],
338
+ },
339
+ actions: calcActions,
340
+ subs: [rates.subEntry],
341
+ };
342
+ return appCreateApp(appDoc, {
343
+ ...appOptions,
344
+ // the financial panel's selects carry their values as JSON text
345
+ eventFields: { ...formEventFields(), ...(appOptions.eventFields ?? {}) },
346
+ effects: { ...calcEditEffects, ...rates.effects, ...(appOptions.effects ?? {}) },
347
+ subs: { ...rates.subs, ...(appOptions.subs ?? {}) },
348
+ viewModel: (state) => ({ ...state, ui: { calculator: contributeCalcViewModel(state) } }),
349
+ });
350
+ },
351
+ };
352
+ }
@@ -0,0 +1,44 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The Binance rate adapter (alternative provider). Reads
4
+ * `/api/v3/ticker/price` (crypto USDT pairs), no key. Same one-way rule:
5
+ * fetch + normalize only; conversion is core's `convertCurrency`.
6
+ */
7
+
8
+ const DEFAULT_ENDPOINT = 'https://api.binance.com/api/v3/ticker/price';
9
+
10
+ /**
11
+ * Normalize a Binance ticker list into `{ base, rates, at }`. Only
12
+ * `*USDT` pairs are used; the base is USDT (≈ USD, also mapped to USD 1).
13
+ * @param {Array<{symbol:string, price:string|number}>} data @param {number} at
14
+ * @returns {{ base: string, rates: Record<string, number>, at: number }}
15
+ */
16
+ export function normalizeBinance(data, at = 0) {
17
+ /** @type {Record<string, number>} */
18
+ const rates = { USDT: 1, USD: 1 };
19
+ if (Array.isArray(data)) {
20
+ for (const t of data) {
21
+ if (typeof t.symbol === 'string' && t.symbol.endsWith('USDT')) {
22
+ const sym = t.symbol.slice(0, -4);
23
+ const price = Number(t.price);
24
+ if (Number.isFinite(price)) rates[sym] = price;
25
+ }
26
+ }
27
+ }
28
+ return { base: 'USDT', rates, at };
29
+ }
30
+
31
+ /**
32
+ * Fetch and normalize Binance rates.
33
+ * @param {string[]} codes
34
+ * @param {{ fetch?: typeof globalThis.fetch, endpoint?: string, at?: number }} [opts]
35
+ * @returns {Promise<{ base: string, rates: Record<string, number>, at: number }>}
36
+ */
37
+ export async function fetchBinance(codes, opts = {}) {
38
+ const f = opts.fetch ?? globalThis.fetch;
39
+ const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT;
40
+ const res = await f(endpoint);
41
+ if (!res.ok) throw new Error(`Binance HTTP ${res.status}`);
42
+ const data = await res.json();
43
+ return normalizeBinance(data, opts.at ?? 0);
44
+ }
@@ -0,0 +1,63 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The CoinGecko rate adapter. The **only**
4
+ * network code path for the default provider — it fetches and normalizes,
5
+ * nothing more. The pure conversion is `@jarenjs/core/convert`'s
6
+ * `convertCurrency`, never here. No API key; fiat *and* crypto in one
7
+ * call, everything priced through a USD base. `fetch` is injectable for
8
+ * tests; `endpoint` is overridable.
9
+ */
10
+
11
+ /** Known CoinGecko ids ↔ ticker symbols. */
12
+ export const COINGECKO_IDS = {
13
+ bitcoin: 'BTC', ethereum: 'ETH', tether: 'USDT', binancecoin: 'BNB',
14
+ solana: 'SOL', cardano: 'ADA', ripple: 'XRP', dogecoin: 'DOGE',
15
+ };
16
+ const ID_BY_SYMBOL = Object.fromEntries(Object.entries(COINGECKO_IDS).map(([id, sym]) => [sym, id]));
17
+
18
+ const DEFAULT_ENDPOINT = 'https://api.coingecko.com/api/v3/simple/price';
19
+
20
+ /**
21
+ * Normalize a `/simple/price` response into the common
22
+ * `{ base, rates, at }` shape. Crypto prices are USD directly; fiat rates
23
+ * are derived through the bitcoin pivot (rate[FIAT] = btc_usd / btc_fiat
24
+ * = value of one FIAT unit in USD).
25
+ * @param {any} data @param {string[]} codes @param {number} at
26
+ * @returns {{ base: string, rates: Record<string, number>, at: number }}
27
+ */
28
+ export function normalizeCoinGecko(data, codes, at = 0) {
29
+ /** @type {Record<string, number>} */
30
+ const rates = { USD: 1 };
31
+ for (const [id, sym] of Object.entries(COINGECKO_IDS)) {
32
+ if (data[id] && typeof data[id].usd === 'number') rates[sym] = data[id].usd;
33
+ }
34
+ const btc = data.bitcoin;
35
+ if (btc && typeof btc.usd === 'number') {
36
+ for (const code of codes) {
37
+ if (code === 'USD' || ID_BY_SYMBOL[code]) continue;
38
+ const lc = code.toLowerCase();
39
+ if (typeof btc[lc] === 'number' && btc[lc] !== 0) rates[code] = btc.usd / btc[lc];
40
+ }
41
+ }
42
+ return { base: 'USD', rates, at };
43
+ }
44
+
45
+ /**
46
+ * Fetch and normalize rates for `codes`.
47
+ * @param {string[]} codes
48
+ * @param {{ fetch?: typeof globalThis.fetch, endpoint?: string, at?: number }} [opts]
49
+ * @returns {Promise<{ base: string, rates: Record<string, number>, at: number }>}
50
+ */
51
+ export async function fetchCoinGecko(codes, opts = {}) {
52
+ const f = opts.fetch ?? globalThis.fetch;
53
+ const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT;
54
+ const cryptoIds = codes.filter((c) => ID_BY_SYMBOL[c]).map((c) => ID_BY_SYMBOL[c]);
55
+ const fiats = codes.filter((c) => c !== 'USD' && !ID_BY_SYMBOL[c]).map((c) => c.toLowerCase());
56
+ const ids = [...new Set([...cryptoIds, 'bitcoin'])].join(',');
57
+ const vs = ['usd', ...fiats].join(',');
58
+ const url = `${endpoint}?ids=${ids}&vs_currencies=${vs}`;
59
+ const res = await f(url);
60
+ if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`);
61
+ const data = await res.json();
62
+ return normalizeCoinGecko(data, codes, opts.at ?? 0);
63
+ }
@@ -0,0 +1,116 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The rates layer — the component's impure
4
+ * half. It owns the `rates-fetch` effect and the `rates-poll`
5
+ * subscription, choosing a provider adapter, debouncing to a min refresh
6
+ * interval, and routing success/failure to app actions. It normalizes a
7
+ * rate table into `$.calc.rates`; the **conversion itself** is
8
+ * `@jarenjs/core/convert`'s pure `convertCurrency`, never here.
9
+ *
10
+ * Resilience is the point: a static `FALLBACK_RATES` table keeps the
11
+ * converter, SSR and offline tests working with no network; live rates
12
+ * layer on top only when a fetch succeeds; failures route to an error
13
+ * action.
14
+ */
15
+
16
+ import { fetchCoinGecko } from './coingecko.js';
17
+ import { fetchBinance } from './binance.js';
18
+
19
+ export { fetchCoinGecko, normalizeCoinGecko, COINGECKO_IDS } from './coingecko.js';
20
+ export { fetchBinance, normalizeBinance } from './binance.js';
21
+
22
+ /** The currency codes the converter offers (fiat + crypto). */
23
+ export const CURRENCY_CODES = ['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'BTC', 'ETH', 'USDT', 'BNB', 'SOL'];
24
+
25
+ /**
26
+ * The static last-resort table (value of one unit in USD). Deterministic,
27
+ * so tests / SSR / offline all render a real conversion with no network.
28
+ * @type {{ base: string, rates: Record<string, number>, at: number, stale: boolean, status: string }}
29
+ */
30
+ export const FALLBACK_RATES = {
31
+ base: 'USD',
32
+ rates: {
33
+ USD: 1, EUR: 1.08, GBP: 1.27, JPY: 0.0067, CHF: 1.12, CAD: 0.73, AUD: 0.66,
34
+ BTC: 64000, ETH: 3200, USDT: 1, BNB: 580, SOL: 145,
35
+ },
36
+ at: 0,
37
+ stale: true,
38
+ status: 'fallback',
39
+ };
40
+
41
+ /** Resolve a provider name (or a custom fetcher) to an adapter fn. */
42
+ function resolveAdapter(provider) {
43
+ if (typeof provider === 'function') return provider;
44
+ if (provider === 'binance') return fetchBinance;
45
+ return fetchCoinGecko;
46
+ }
47
+
48
+ /**
49
+ * @typedef {object} RatesLayerOptions
50
+ * @property {'coingecko'|'binance'|((codes:string[],o:any)=>Promise<any>)} [provider]
51
+ * @property {number} [refreshMs] minimum interval between live fetches (debounce)
52
+ * @property {typeof globalThis.fetch} [fetch]
53
+ * @property {string} [endpoint]
54
+ * @property {string[]} [codes]
55
+ * @property {() => number} [now] clock (injectable for tests)
56
+ * @property {any} [fallbackRates]
57
+ */
58
+
59
+ /**
60
+ * Build the rates layer: the `rates-fetch` effect handler, the
61
+ * `rates-poll` subscription handler, the subscription entry and the
62
+ * fallback table.
63
+ * @param {RatesLayerOptions} [options]
64
+ */
65
+ export function createRatesLayer(options = {}) {
66
+ const adapter = resolveAdapter(options.provider ?? 'coingecko');
67
+ const refreshMs = options.refreshMs ?? 60000;
68
+ const codes = options.codes ?? CURRENCY_CODES;
69
+ const now = options.now ?? (() => Date.now());
70
+ const fallbackRates = options.fallbackRates ?? FALLBACK_RATES;
71
+ let lastAt = -Infinity;
72
+ let inflight = false;
73
+
74
+ /**
75
+ * Fetch once (debounced), dispatching `calc/rates-ok` or
76
+ * `calc/rates-err`. `props.force` bypasses the debounce.
77
+ */
78
+ function fetchOnce(props, dispatch) {
79
+ const t = now();
80
+ if (!props?.force && (inflight || t - lastAt < refreshMs)) return;
81
+ inflight = true;
82
+ lastAt = t;
83
+ Promise.resolve(adapter(codes, { fetch: options.fetch, endpoint: options.endpoint, at: t }))
84
+ .then(
85
+ (table) => { inflight = false; dispatch('calc/rates-ok', { ...table, at: t }); },
86
+ (err) => { inflight = false; dispatch('calc/rates-err', { message: String(err?.message ?? err) }); },
87
+ );
88
+ }
89
+
90
+ const effects = {
91
+ /** `{ run: 'rates-fetch', with?: { force?: boolean } }`. */
92
+ 'rates-fetch': (props, dispatch) => fetchOnce(props ?? {}, dispatch),
93
+ };
94
+
95
+ const subs = {
96
+ /**
97
+ * `rates-poll`: kicks an immediate fetch and repeats every
98
+ * `refreshMs`; the cleanup stops the timer. The app only starts it
99
+ * while the `when` gate holds (converter + currency).
100
+ */
101
+ 'rates-poll': (props, dispatch) => {
102
+ fetchOnce({ force: false }, dispatch);
103
+ const id = setInterval(() => fetchOnce({ force: true }, dispatch), refreshMs);
104
+ if (typeof id === 'object' && id && typeof id.unref === 'function') id.unref();
105
+ return () => clearInterval(id);
106
+ },
107
+ };
108
+
109
+ /** The subscription entry for the app document (gated to when it matters). */
110
+ const subEntry = {
111
+ run: 'rates-poll',
112
+ when: { $and: [{ $eq: ['$.calc.mode', 'converter'] }, { $eq: ['$.calc.conv.dimension', 'currency'] }] },
113
+ };
114
+
115
+ return { effects, subs, subEntry, fallbackRates, codes };
116
+ }