Skip to content

Commit 7ab5634

Browse files
feat: extend math engine: currencies, timezones, finance, cooking (#715)
* feat(math): add workday calculations - `workdays in 3 weeks` → 15 workdays - `workdays from April 12 to June 15` → N workdays - `March 3 to March 7 in workdays` → N workdays - `2 workdays after March 3` → date * feat(math): add timestamps and ISO 8601 support - `date to timestamp` → unix timestamp - `date as iso8601` → ISO 8601 string - `2019-04-01T15:30:00 to date` → parsed date - `1733823083000 to date` → millisecond timestamp auto-detection * feat(math): add timezone extensions — airport codes, countries, date in, time difference - Airport codes: LAX, JFK, SYD, NRT, CDG, FRA, etc. - Country names: Japan, Germany, Thailand, etc. - City aliases: Seattle, Boston, Miami, etc. - `date in Vancouver` → date-only display in timezone - `time difference between Seattle and Moscow` → hours - `difference between PDT & AEST` → hours * feat(math): add timespan, laptime formats and shorthand time input - `5.5 minutes as timespan` → 5 min 30 s - `72 days as timespan` → 10 weeks 2 days - `5.5 minutes as laptime` → 00:05:30 - `3h 5m 10s` shorthand input → stacked time units - `03:04:05` laptime input → parsed as hours + minutes + seconds - Laptime arithmetic: `03:04:05 + 01:02:03 as laptime` → 04:06:08 * feat(math): add clock time intervals - `7:30 to 20:45` → 13 hours 15 min - `4pm to 3am` → 11 hours (wraps around midnight) - `9am to 5pm` → 8 hours * fix(math): use absolute value for 'time difference between' phrases * fix(math): preserve sign in timezone difference (matches Soulver behavior) * fix(math): correct timezone difference sign for 'between' phrases * test(math): add timezone difference sign verification tests * test(math): add comprehensive edge case tests from audit - Rounding: negative numbers, to 0 dp, to nearest 1, pi to 10 digits - Fix: 'to N dp' now preserves N digits in output (not capped by default precision) - Percentages: 0%, 100%, 200%, edge cases - Fractions: 0.25 as fraction, -0.5 as fraction - Multipliers: 0x, fractional multiplier - Currency: euros/rubles words, implicit rate with EUR, reverse order - Format directives: 0 in hex/bin, 0.001 in sci, mixed hex+bin input - Conditionals: if with and, expression in condition, expression in postfix if - Calendar: Q1/Q2/Q4, 0 days between, months/years from now/ago - Clock intervals: midnight crossing, 12am/12pm, same time - Timespans: 0 seconds, 1.5 hours, singular form * feat(math): add misc phrase functions - min/max, half, midpoint, random, gcd/lcm - permutations/combinations, clamp, proportions (rule of three) * feat(math): add video timecode and frame rate support - Timecode format HH:MM:SS:FF with `at/@ N fps` (default 24 fps) - `frame`/`frames` and `fps` units registered in math.js - Timecode → frames conversion: `00:30:10:00 @ 24 fps in frames` - Timecode arithmetic: `03:10:20:05 at 30 fps + 50 frames` - fps calculations: `30 fps * 3 minutes` * feat(math): add financial calculations - Compound interest: yearly/monthly/quarterly compounding - Interest only, ROI, annual return, present value - Mortgage: daily/monthly/annual/total repayment and interest * feat(math): add base N conversion and Python-style hex/bin/int - `0b101101 as base 8` → 0o55 - `0x2D as base 2` → 0b101101 - `hex(99)` → 0x63, `bin(0x73)` → 0b1110011, `int(0o55)` → 45 * feat(math): add large number symbols and comment extensions - Large numbers: B/bn (billion), T/tn (trillion), trillion word - Comments: parenthesis comments `$999 (for iPhone)`, end-of-line `// comment` - Parenthesis stripping safely skips function calls like sin(x) * feat(math): add cooking calculations with substance density database - `density of yogurt` → 1.06 g/cm³ - `300g butter in cups` → mass-to-volume conversion - `10 cups olive oil in grams` → volume-to-mass conversion - ~120 cooking substances with densities * feat(math): extend currencies to 166 fiat + 21 crypto + prefix symbols - Expand fiat currencies from 27 to 166 (all from open.er-api.com) - Add 21 cryptocurrencies via CoinGecko API (BTC, ETH, SOL, DOGE, etc.) - Prefixed dollar symbols: US$, NZ$, CA$, AU$, HK$, S$, NT$ - Custom exchange rates: `50 EUR in USD at 1.05 USD/EUR` - CoinGecko + fiat fetched in parallel with 1h cache * test(math): add comprehensive fixture documents for visual testing 14 fixture files covering all math engine features: - arithmetic, variables, percentages, aggregates, rounding - math functions, conditionals, units, currency - fractions/multipliers, calendar, timezones, timespans - finance, misc functions, cooking - 4 real-world scenarios: budget, travel, cooking, dev * fix(math): use user locale for timezone date formatting * feat(math): unify date formatting with configurable dateFormat setting - Single formatMathDate() for all date outputs (timezone, calendar, arithmetic) - DateFormatStyle: 'numeric' | 'short' | 'long' (configurable via setFormatSettings) - Options: timeZone, timeZoneName, dateOnly for context-specific formatting - Remove separate formatTimeZoneDate function - All dates now respect user locale consistently * feat(math): add date format setting and currency/crypto refresh buttons - Date format preference: numeric, short, long - Separate refresh buttons for fiat currencies and crypto rates - IPC handlers for forced refresh (ignores cache) - i18n keys for new preferences * fix: use shadcn Button import in Math preferences * fix: match Button style with other preferences (remove size=sm) * fix: unify currency/crypto rate descriptions in preferences * fix: restore rate limit info in crypto description * fix: clean up currency/crypto rate descriptions * fix(math): use fixed notation for unit formatting to avoid scientific notation * feat(math): support total/sum for unit results (currency, same units) - numericBlock stores value + unit name - Aggregates sum only if all entries have same unit - Mixed units → aggregate returns null (cannot sum) - $100 + $200 + $50 → sum = 350 USD - Unit info extracted from math.js rawResult for block tracking * fix(math): set numericValue for unit results so TOTAL works in UI * fix: address 5 review findings - P1: merge partial rates with existing cache instead of overwriting - P2: show error toast on fiat refresh failure (was showing success) - P2: finance evaluator uses user locale for formatting - P2: cooking evaluator uses user locale for formatting - P2: cooking classifier supports all volume units (pints, quarts, etc.) * fix(math): address review fixes for rates and cooking locale * refactor(test): split useMathEngine tests into domain-based files - useMathEngine.integration.test.ts (66) — multi-line, variables, aggregates, errors - useMathEngine.math.test.ts (229) — arithmetic, rounding, percentages, conditionals - useMathEngine.units.test.ts (59) — unit conversion, area/volume, css, rates - useMathEngine.currency.test.ts (22) — currency symbols, prefixes, modifiers - useMathEngine.timezones.test.ts (28) — timezone display, conversion, difference - useMathEngine.calendar.test.ts (47) — calendar, workdays, timestamps, timespans - useMathEngine.domain.test.ts (17) — finance, cooking - Shared setup in mathEngineTestUtils.ts * test: add real-world math engine scenarios
1 parent 911562f commit 7ab5634

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+5115
-1627
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { getCurrencyRates, refreshFiatRatesForced } from '../currencyRates'
3+
4+
const mocks = vi.hoisted(() => {
5+
const state: {
6+
cache: { rates: Record<string, number>, fetchedAt: number } | null
7+
} = {
8+
cache: null,
9+
}
10+
11+
return {
12+
state,
13+
get: vi.fn(() => state.cache),
14+
set: vi.fn(
15+
(
16+
_key: string,
17+
value: { rates: Record<string, number>, fetchedAt: number },
18+
) => {
19+
state.cache = value
20+
},
21+
),
22+
}
23+
})
24+
25+
vi.mock('../store', () => ({
26+
store: {
27+
currencyRates: {
28+
get: mocks.get,
29+
set: mocks.set,
30+
},
31+
},
32+
}))
33+
34+
describe('currencyRates', () => {
35+
beforeEach(() => {
36+
mocks.state.cache = null
37+
mocks.get.mockClear()
38+
mocks.set.mockClear()
39+
vi.restoreAllMocks()
40+
})
41+
42+
it('does not persist partial cold-start rates to cache', async () => {
43+
vi.stubGlobal(
44+
'fetch',
45+
vi
46+
.fn()
47+
.mockResolvedValueOnce({
48+
ok: true,
49+
json: async () => ({
50+
result: 'success',
51+
rates: {
52+
EUR: 0.92,
53+
GBP: 0.79,
54+
},
55+
}),
56+
})
57+
.mockResolvedValueOnce({
58+
ok: false,
59+
status: 429,
60+
}),
61+
)
62+
63+
const payload = await getCurrencyRates()
64+
65+
expect(payload.source).toBe('live')
66+
expect(payload.rates).toMatchObject({
67+
USD: 1,
68+
EUR: 0.92,
69+
GBP: 0.79,
70+
})
71+
expect(mocks.set).not.toHaveBeenCalled()
72+
expect(mocks.state.cache).toBeNull()
73+
})
74+
75+
it('throws when forced fiat refresh fails', async () => {
76+
vi.stubGlobal(
77+
'fetch',
78+
vi.fn().mockResolvedValue({
79+
ok: false,
80+
status: 500,
81+
}),
82+
)
83+
84+
await expect(refreshFiatRatesForced()).rejects.toThrow(
85+
'Currency rates request failed with status 500',
86+
)
87+
})
88+
})

src/main/currencyRates.ts

Lines changed: 138 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,30 @@ export interface CurrencyRatesPayload {
1414
source: 'live' | 'cache' | 'unavailable'
1515
}
1616

17+
const CRYPTO_IDS: Record<string, string> = {
18+
BTC: 'bitcoin',
19+
ETH: 'ethereum',
20+
SOL: 'solana',
21+
DOGE: 'dogecoin',
22+
XRP: 'ripple',
23+
ADA: 'cardano',
24+
DOT: 'polkadot',
25+
LTC: 'litecoin',
26+
AVAX: 'avalanche-2',
27+
SHIB: 'shiba-inu',
28+
BNB: 'binancecoin',
29+
USDT: 'tether',
30+
USDC: 'usd-coin',
31+
XLM: 'stellar',
32+
XMR: 'monero',
33+
EOS: 'eos',
34+
TRX: 'tron',
35+
DASH: 'dash',
36+
NEO: 'neo',
37+
BCH: 'bitcoin-cash',
38+
ETC: 'ethereum-classic',
39+
}
40+
1741
function normalizeRates(rates: Record<string, number>) {
1842
const normalized: Record<string, number> = { USD: 1 }
1943

@@ -28,6 +52,58 @@ function normalizeRates(rates: Record<string, number>) {
2852
return normalized
2953
}
3054

55+
function createPayload(rates: Record<string, number>): CurrencyRatesPayload {
56+
return {
57+
rates: normalizeRates(rates),
58+
fetchedAt: Date.now(),
59+
source: 'live',
60+
}
61+
}
62+
63+
async function fetchFiatRates(): Promise<Record<string, number>> {
64+
const response = await fetch('https://open.er-api.com/v6/latest/USD')
65+
if (!response.ok) {
66+
throw new Error(
67+
`Currency rates request failed with status ${response.status}`,
68+
)
69+
}
70+
71+
const data = (await response.json()) as CurrencyRatesApiResponse
72+
if (data.result !== 'success' || !data.rates) {
73+
throw new Error('Currency rates response is invalid')
74+
}
75+
76+
return data.rates
77+
}
78+
79+
async function fetchCryptoRates(): Promise<Record<string, number>> {
80+
const ids = Object.values(CRYPTO_IDS).join(',')
81+
const response = await fetch(
82+
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`,
83+
)
84+
85+
if (!response.ok) {
86+
if (response.status === 429) {
87+
throw new Error('rate_limit')
88+
}
89+
throw new Error(
90+
`Crypto rates request failed with status ${response.status}`,
91+
)
92+
}
93+
94+
const data = (await response.json()) as Record<string, { usd?: number }>
95+
const rates: Record<string, number> = {}
96+
97+
for (const [code, coinId] of Object.entries(CRYPTO_IDS)) {
98+
const usdPrice = data[coinId]?.usd
99+
if (usdPrice && usdPrice > 0) {
100+
rates[code] = 1 / usdPrice
101+
}
102+
}
103+
104+
return rates
105+
}
106+
31107
export async function getCurrencyRates(): Promise<CurrencyRatesPayload> {
32108
const cached = store.currencyRates.get('cache')
33109

@@ -39,31 +115,41 @@ export async function getCurrencyRates(): Promise<CurrencyRatesPayload> {
39115
}
40116

41117
try {
42-
const response = await fetch('https://open.er-api.com/v6/latest/USD')
43-
if (!response.ok) {
44-
throw new Error(
45-
`Currency rates request failed with status ${response.status}`,
46-
)
47-
}
118+
const [fiatRates, cryptoRates] = await Promise.allSettled([
119+
fetchFiatRates(),
120+
fetchCryptoRates(),
121+
])
48122

49-
const data = (await response.json()) as CurrencyRatesApiResponse
50-
if (data.result !== 'success' || !data.rates) {
51-
throw new Error('Currency rates response is invalid')
123+
const freshRates: Record<string, number> = {}
124+
125+
if (fiatRates.status === 'fulfilled') {
126+
Object.assign(freshRates, fiatRates.value)
52127
}
53128

54-
const payload = {
55-
rates: normalizeRates(data.rates),
56-
fetchedAt: data.time_last_update_unix
57-
? data.time_last_update_unix * 1000
58-
: Date.now(),
129+
if (cryptoRates.status === 'fulfilled') {
130+
Object.assign(freshRates, cryptoRates.value)
59131
}
60132

61-
store.currencyRates.set('cache', payload)
133+
if (Object.keys(freshRates).length === 0) {
134+
throw new Error('No rates fetched')
135+
}
62136

63-
return {
64-
...payload,
65-
source: 'live',
137+
if (
138+
!cached
139+
&& (fiatRates.status !== 'fulfilled' || cryptoRates.status !== 'fulfilled')
140+
) {
141+
return createPayload(freshRates)
66142
}
143+
144+
const previousRates = cached?.rates || {}
145+
const payload = createPayload({ ...previousRates, ...freshRates })
146+
147+
store.currencyRates.set('cache', {
148+
rates: payload.rates,
149+
fetchedAt: payload.fetchedAt,
150+
})
151+
152+
return payload
67153
}
68154
catch {
69155
if (cached) {
@@ -80,3 +166,37 @@ export async function getCurrencyRates(): Promise<CurrencyRatesPayload> {
80166
}
81167
}
82168
}
169+
170+
export async function refreshFiatRatesForced(): Promise<CurrencyRatesPayload> {
171+
const fiatRates = await fetchFiatRates()
172+
const cached = store.currencyRates.get('cache')
173+
174+
if (!cached) {
175+
return createPayload(fiatRates)
176+
}
177+
178+
const payload = createPayload({ ...cached.rates, ...fiatRates })
179+
store.currencyRates.set('cache', {
180+
rates: payload.rates,
181+
fetchedAt: payload.fetchedAt,
182+
})
183+
184+
return payload
185+
}
186+
187+
export async function refreshCryptoRatesForced(): Promise<CurrencyRatesPayload> {
188+
const cryptoRates = await fetchCryptoRates()
189+
const cached = store.currencyRates.get('cache')
190+
191+
if (!cached) {
192+
return createPayload(cryptoRates)
193+
}
194+
195+
const payload = createPayload({ ...cached.rates, ...cryptoRates })
196+
store.currencyRates.set('cache', {
197+
rates: payload.rates,
198+
fetchedAt: payload.fetchedAt,
199+
})
200+
201+
return payload
202+
}

src/main/i18n/locales/en_US/preferences.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,26 @@
124124
"decimalPlaces": {
125125
"label": "Decimal Places",
126126
"description": "Maximum decimal places in results (0\u201314)."
127+
},
128+
"dateFormat": {
129+
"label": "Date Format",
130+
"description": "How dates are displayed in results."
131+
},
132+
"currencyRates": {
133+
"label": "Currency Rates",
134+
"description": "Cached for 1 hour.",
135+
"refresh": "Refresh",
136+
"refreshing": "Refreshing...",
137+
"refreshed": "Currency rates updated",
138+
"refreshError": "Failed to update currency rates"
139+
},
140+
"cryptoRates": {
141+
"label": "Crypto Rates",
142+
"description": "Cached for 1 hour. Rate limit: 10-30 req/min.",
143+
"refresh": "Refresh",
144+
"refreshing": "Refreshing...",
145+
"refreshed": "Crypto rates updated",
146+
"rateLimited": "Rate limit exceeded. Try again later."
127147
}
128148
},
129149
"api": {

src/main/ipc/handlers/system.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import path from 'node:path'
22
import { app, ipcMain, shell } from 'electron'
3-
import { getCurrencyRates } from '../../currencyRates'
3+
import {
4+
getCurrencyRates,
5+
refreshCryptoRatesForced,
6+
refreshFiatRatesForced,
7+
} from '../../currencyRates'
48
import {
59
findNoteById,
610
getNotesFolderPathById,
@@ -14,6 +18,14 @@ export function registerSystemHandlers() {
1418
return getCurrencyRates()
1519
})
1620

21+
ipcMain.handle('system:currency-rates-refresh', () => {
22+
return refreshFiatRatesForced()
23+
})
24+
25+
ipcMain.handle('system:crypto-rates-refresh', () => {
26+
return refreshCryptoRatesForced()
27+
})
28+
1729
ipcMain.handle('system:reload', () => {
1830
app.relaunch()
1931
app.quit()

src/main/store/module/preferences.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const storagePath = isWin ? `${homedir()}\\massCode` : `${homedir()}/massCode`
2424
const MATH_DEFAULTS: MathSettings = {
2525
locale: 'en-US',
2626
decimalPlaces: 6,
27+
dateFormat: 'numeric',
2728
}
2829

2930
const PREFERENCES_DEFAULTS: PreferencesStore = {
@@ -153,13 +154,19 @@ function sanitizeMarkdownSettings(value: unknown): MarkdownSettings {
153154
function sanitizeMathSettings(value: unknown): MathSettings {
154155
const source = asRecord(value)
155156

157+
const dateFormat = readString(source, 'dateFormat', MATH_DEFAULTS.dateFormat)
158+
const validDateFormats = ['numeric', 'short', 'long']
159+
156160
return {
157161
locale: readString(source, 'locale', MATH_DEFAULTS.locale),
158162
decimalPlaces: readNumber(
159163
source,
160164
'decimalPlaces',
161165
MATH_DEFAULTS.decimalPlaces,
162166
),
167+
dateFormat: validDateFormats.includes(dateFormat)
168+
? (dateFormat as MathSettings['dateFormat'])
169+
: MATH_DEFAULTS.dateFormat,
163170
}
164171
}
165172

src/main/store/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export interface NotesEditorSettings {
8181
export interface MathSettings {
8282
locale: string
8383
decimalPlaces: number
84+
dateFormat: 'numeric' | 'short' | 'long'
8485
}
8586

8687
export interface PreferencesStore {

src/main/types/ipc.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ type DBAction = 'migrate-to-markdown'
3434

3535
type SystemAction =
3636
| 'currency-rates'
37+
| 'currency-rates-refresh'
38+
| 'crypto-rates-refresh'
3739
| 'reload'
3840
| 'open-external'
3941
| 'show-notes-folder-in-file-manager'

src/renderer/components/math-notebook/Workspace.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,21 @@ const {
2020
2121
const mathSettings = reactive(store.preferences.get('math') as MathSettings)
2222
23-
setFormatSettings(mathSettings.locale, mathSettings.decimalPlaces)
23+
setFormatSettings(
24+
mathSettings.locale,
25+
mathSettings.decimalPlaces,
26+
mathSettings.dateFormat,
27+
)
2428
2529
watch(
2630
mathSettings,
2731
() => {
2832
store.preferences.set('math', JSON.parse(JSON.stringify(mathSettings)))
29-
setFormatSettings(mathSettings.locale, mathSettings.decimalPlaces)
33+
setFormatSettings(
34+
mathSettings.locale,
35+
mathSettings.decimalPlaces,
36+
mathSettings.dateFormat,
37+
)
3038
},
3139
{ deep: true },
3240
)

0 commit comments

Comments
 (0)