Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fa4267b
feat(math): add workday calculations
antonreshetov Mar 29, 2026
6412eb1
feat(math): add timestamps and ISO 8601 support
antonreshetov Mar 29, 2026
0201931
feat(math): add timezone extensions — airport codes, countries, date …
antonreshetov Mar 29, 2026
80da03d
feat(math): add timespan, laptime formats and shorthand time input
antonreshetov Mar 29, 2026
a0b5719
feat(math): add clock time intervals
antonreshetov Mar 29, 2026
63ce5be
fix(math): use absolute value for 'time difference between' phrases
antonreshetov Mar 29, 2026
2c6e0da
fix(math): preserve sign in timezone difference (matches Soulver beha…
antonreshetov Mar 29, 2026
d21823f
fix(math): correct timezone difference sign for 'between' phrases
antonreshetov Mar 29, 2026
826ebc4
test(math): add timezone difference sign verification tests
antonreshetov Mar 29, 2026
87cb21d
test(math): add comprehensive edge case tests from audit
antonreshetov Mar 29, 2026
41c87f4
feat(math): add misc phrase functions
antonreshetov Mar 29, 2026
fe16cca
feat(math): add video timecode and frame rate support
antonreshetov Mar 29, 2026
688d0e3
feat(math): add financial calculations
antonreshetov Mar 29, 2026
1a73f6b
feat(math): add base N conversion and Python-style hex/bin/int
antonreshetov Mar 29, 2026
d38f2e8
feat(math): add large number symbols and comment extensions
antonreshetov Mar 29, 2026
d028ab1
feat(math): add cooking calculations with substance density database
antonreshetov Mar 29, 2026
ef3da0c
feat(math): extend currencies to 166 fiat + 21 crypto + prefix symbols
antonreshetov Mar 29, 2026
1da91bd
test(math): add comprehensive fixture documents for visual testing
antonreshetov Mar 29, 2026
6d4dfef
fix(math): use user locale for timezone date formatting
antonreshetov Mar 29, 2026
efb1dad
feat(math): unify date formatting with configurable dateFormat setting
antonreshetov Mar 29, 2026
d2c267d
feat(math): add date format setting and currency/crypto refresh buttons
antonreshetov Mar 29, 2026
e6aee89
fix: use shadcn Button import in Math preferences
antonreshetov Mar 29, 2026
bf330a4
fix: match Button style with other preferences (remove size=sm)
antonreshetov Mar 29, 2026
bc823dc
fix: unify currency/crypto rate descriptions in preferences
antonreshetov Mar 29, 2026
02fb3c2
fix: restore rate limit info in crypto description
antonreshetov Mar 29, 2026
dd1fc73
fix: clean up currency/crypto rate descriptions
antonreshetov Mar 29, 2026
d9221f4
fix(math): use fixed notation for unit formatting to avoid scientific…
antonreshetov Mar 29, 2026
289488a
feat(math): support total/sum for unit results (currency, same units)
antonreshetov Mar 29, 2026
ffd530c
fix(math): set numericValue for unit results so TOTAL works in UI
antonreshetov Mar 29, 2026
5efc58a
fix: address 5 review findings
antonreshetov Mar 29, 2026
211672a
fix(math): address review fixes for rates and cooking locale
antonreshetov Mar 29, 2026
5ce8318
refactor(test): split useMathEngine tests into domain-based files
antonreshetov Mar 29, 2026
7396ac9
test: add real-world math engine scenarios
antonreshetov Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/main/__tests__/currencyRates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCurrencyRates, refreshFiatRatesForced } from '../currencyRates'

const mocks = vi.hoisted(() => {
const state: {
cache: { rates: Record<string, number>, fetchedAt: number } | null
} = {
cache: null,
}

return {
state,
get: vi.fn(() => state.cache),
set: vi.fn(
(
_key: string,
value: { rates: Record<string, number>, fetchedAt: number },
) => {
state.cache = value
},
),
}
})

vi.mock('../store', () => ({
store: {
currencyRates: {
get: mocks.get,
set: mocks.set,
},
},
}))

describe('currencyRates', () => {
beforeEach(() => {
mocks.state.cache = null
mocks.get.mockClear()
mocks.set.mockClear()
vi.restoreAllMocks()
})

it('does not persist partial cold-start rates to cache', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
result: 'success',
rates: {
EUR: 0.92,
GBP: 0.79,
},
}),
})
.mockResolvedValueOnce({
ok: false,
status: 429,
}),
)

const payload = await getCurrencyRates()

expect(payload.source).toBe('live')
expect(payload.rates).toMatchObject({
USD: 1,
EUR: 0.92,
GBP: 0.79,
})
expect(mocks.set).not.toHaveBeenCalled()
expect(mocks.state.cache).toBeNull()
})

it('throws when forced fiat refresh fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
}),
)

await expect(refreshFiatRatesForced()).rejects.toThrow(
'Currency rates request failed with status 500',
)
})
})
156 changes: 138 additions & 18 deletions src/main/currencyRates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@ export interface CurrencyRatesPayload {
source: 'live' | 'cache' | 'unavailable'
}

const CRYPTO_IDS: Record<string, string> = {
BTC: 'bitcoin',
ETH: 'ethereum',
SOL: 'solana',
DOGE: 'dogecoin',
XRP: 'ripple',
ADA: 'cardano',
DOT: 'polkadot',
LTC: 'litecoin',
AVAX: 'avalanche-2',
SHIB: 'shiba-inu',
BNB: 'binancecoin',
USDT: 'tether',
USDC: 'usd-coin',
XLM: 'stellar',
XMR: 'monero',
EOS: 'eos',
TRX: 'tron',
DASH: 'dash',
NEO: 'neo',
BCH: 'bitcoin-cash',
ETC: 'ethereum-classic',
}

function normalizeRates(rates: Record<string, number>) {
const normalized: Record<string, number> = { USD: 1 }

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

function createPayload(rates: Record<string, number>): CurrencyRatesPayload {
return {
rates: normalizeRates(rates),
fetchedAt: Date.now(),
source: 'live',
}
}

async function fetchFiatRates(): Promise<Record<string, number>> {
const response = await fetch('https://open.er-api.com/v6/latest/USD')
if (!response.ok) {
throw new Error(
`Currency rates request failed with status ${response.status}`,
)
}

const data = (await response.json()) as CurrencyRatesApiResponse
if (data.result !== 'success' || !data.rates) {
throw new Error('Currency rates response is invalid')
}

return data.rates
}

async function fetchCryptoRates(): Promise<Record<string, number>> {
const ids = Object.values(CRYPTO_IDS).join(',')
const response = await fetch(
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`,
)

if (!response.ok) {
if (response.status === 429) {
throw new Error('rate_limit')
}
throw new Error(
`Crypto rates request failed with status ${response.status}`,
)
}

const data = (await response.json()) as Record<string, { usd?: number }>
const rates: Record<string, number> = {}

for (const [code, coinId] of Object.entries(CRYPTO_IDS)) {
const usdPrice = data[coinId]?.usd
if (usdPrice && usdPrice > 0) {
rates[code] = 1 / usdPrice
}
}

return rates
}

export async function getCurrencyRates(): Promise<CurrencyRatesPayload> {
const cached = store.currencyRates.get('cache')

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

try {
const response = await fetch('https://open.er-api.com/v6/latest/USD')
if (!response.ok) {
throw new Error(
`Currency rates request failed with status ${response.status}`,
)
}
const [fiatRates, cryptoRates] = await Promise.allSettled([
fetchFiatRates(),
fetchCryptoRates(),
])

const data = (await response.json()) as CurrencyRatesApiResponse
if (data.result !== 'success' || !data.rates) {
throw new Error('Currency rates response is invalid')
const freshRates: Record<string, number> = {}

if (fiatRates.status === 'fulfilled') {
Object.assign(freshRates, fiatRates.value)
}

const payload = {
rates: normalizeRates(data.rates),
fetchedAt: data.time_last_update_unix
? data.time_last_update_unix * 1000
: Date.now(),
if (cryptoRates.status === 'fulfilled') {
Object.assign(freshRates, cryptoRates.value)
}

store.currencyRates.set('cache', payload)
if (Object.keys(freshRates).length === 0) {
throw new Error('No rates fetched')
}

return {
...payload,
source: 'live',
if (
!cached
&& (fiatRates.status !== 'fulfilled' || cryptoRates.status !== 'fulfilled')
) {
return createPayload(freshRates)
}

const previousRates = cached?.rates || {}
const payload = createPayload({ ...previousRates, ...freshRates })

store.currencyRates.set('cache', {
rates: payload.rates,
fetchedAt: payload.fetchedAt,
})

return payload
}
catch {
if (cached) {
Expand All @@ -80,3 +166,37 @@ export async function getCurrencyRates(): Promise<CurrencyRatesPayload> {
}
}
}

export async function refreshFiatRatesForced(): Promise<CurrencyRatesPayload> {
const fiatRates = await fetchFiatRates()
const cached = store.currencyRates.get('cache')

if (!cached) {
return createPayload(fiatRates)
}

const payload = createPayload({ ...cached.rates, ...fiatRates })
store.currencyRates.set('cache', {
rates: payload.rates,
fetchedAt: payload.fetchedAt,
})

return payload
}

export async function refreshCryptoRatesForced(): Promise<CurrencyRatesPayload> {
const cryptoRates = await fetchCryptoRates()
const cached = store.currencyRates.get('cache')

if (!cached) {
return createPayload(cryptoRates)
}

const payload = createPayload({ ...cached.rates, ...cryptoRates })
store.currencyRates.set('cache', {
rates: payload.rates,
fetchedAt: payload.fetchedAt,
})

return payload
}
20 changes: 20 additions & 0 deletions src/main/i18n/locales/en_US/preferences.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@
"decimalPlaces": {
"label": "Decimal Places",
"description": "Maximum decimal places in results (0\u201314)."
},
"dateFormat": {
"label": "Date Format",
"description": "How dates are displayed in results."
},
"currencyRates": {
"label": "Currency Rates",
"description": "Cached for 1 hour.",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"refreshed": "Currency rates updated",
"refreshError": "Failed to update currency rates"
},
"cryptoRates": {
"label": "Crypto Rates",
"description": "Cached for 1 hour. Rate limit: 10-30 req/min.",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"refreshed": "Crypto rates updated",
"rateLimited": "Rate limit exceeded. Try again later."
}
},
"api": {
Expand Down
14 changes: 13 additions & 1 deletion src/main/ipc/handlers/system.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import path from 'node:path'
import { app, ipcMain, shell } from 'electron'
import { getCurrencyRates } from '../../currencyRates'
import {
getCurrencyRates,
refreshCryptoRatesForced,
refreshFiatRatesForced,
} from '../../currencyRates'
import {
findNoteById,
getNotesFolderPathById,
Expand All @@ -14,6 +18,14 @@ export function registerSystemHandlers() {
return getCurrencyRates()
})

ipcMain.handle('system:currency-rates-refresh', () => {
return refreshFiatRatesForced()
})

ipcMain.handle('system:crypto-rates-refresh', () => {
return refreshCryptoRatesForced()
})

ipcMain.handle('system:reload', () => {
app.relaunch()
app.quit()
Expand Down
7 changes: 7 additions & 0 deletions src/main/store/module/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const storagePath = isWin ? `${homedir()}\\massCode` : `${homedir()}/massCode`
const MATH_DEFAULTS: MathSettings = {
locale: 'en-US',
decimalPlaces: 6,
dateFormat: 'numeric',
}

const PREFERENCES_DEFAULTS: PreferencesStore = {
Expand Down Expand Up @@ -153,13 +154,19 @@ function sanitizeMarkdownSettings(value: unknown): MarkdownSettings {
function sanitizeMathSettings(value: unknown): MathSettings {
const source = asRecord(value)

const dateFormat = readString(source, 'dateFormat', MATH_DEFAULTS.dateFormat)
const validDateFormats = ['numeric', 'short', 'long']

return {
locale: readString(source, 'locale', MATH_DEFAULTS.locale),
decimalPlaces: readNumber(
source,
'decimalPlaces',
MATH_DEFAULTS.decimalPlaces,
),
dateFormat: validDateFormats.includes(dateFormat)
? (dateFormat as MathSettings['dateFormat'])
: MATH_DEFAULTS.dateFormat,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/main/store/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface NotesEditorSettings {
export interface MathSettings {
locale: string
decimalPlaces: number
dateFormat: 'numeric' | 'short' | 'long'
}

export interface PreferencesStore {
Expand Down
2 changes: 2 additions & 0 deletions src/main/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type DBAction = 'migrate-to-markdown'

type SystemAction =
| 'currency-rates'
| 'currency-rates-refresh'
| 'crypto-rates-refresh'
| 'reload'
| 'open-external'
| 'show-notes-folder-in-file-manager'
Expand Down
12 changes: 10 additions & 2 deletions src/renderer/components/math-notebook/Workspace.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,21 @@ const {

const mathSettings = reactive(store.preferences.get('math') as MathSettings)

setFormatSettings(mathSettings.locale, mathSettings.decimalPlaces)
setFormatSettings(
mathSettings.locale,
mathSettings.decimalPlaces,
mathSettings.dateFormat,
)

watch(
mathSettings,
() => {
store.preferences.set('math', JSON.parse(JSON.stringify(mathSettings)))
setFormatSettings(mathSettings.locale, mathSettings.decimalPlaces)
setFormatSettings(
mathSettings.locale,
mathSettings.decimalPlaces,
mathSettings.dateFormat,
)
},
{ deep: true },
)
Expand Down
Loading