From b7aea8b3eac9a7b10ff9b4665bf2e2ae22787c75 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:10:29 +0000 Subject: [PATCH] perf(storage): optimize historical rates batch fetching Refactored `fetchHistoricalRatesBatch` to use a single range-based batch API query to Frankfurter (`minDate..maxDate`), replacing the N+1 `Promise.all` logic for web environment fallback. Also fixed handling of weekend boundary limits by querying an extra 7 days prior to the first requested date to resolve closest previous available rates. Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> --- src/storage.test.ts | 35 ++++++++++++++++++++++++++++++++++- src/storage.ts | 33 +++++++++++++++++++++++++++------ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/storage.test.ts b/src/storage.test.ts index fe20c03..d5342ad 100644 --- a/src/storage.test.ts +++ b/src/storage.test.ts @@ -1,6 +1,6 @@ import { logger } from "./logger"; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage'; +import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates, fetchHistoricalRatesBatch } from './storage'; import { invoke } from '@tauri-apps/api/core'; vi.mock('@tauri-apps/api/core', () => ({ @@ -382,6 +382,39 @@ describe('storage', () => { }); }); + describe('fetchHistoricalRatesBatch', () => { + it('uses invoke when in Tauri', async () => { + vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + const mockRates = { '2024-01-01': { EUR: 0.85 } }; + vi.mocked(invoke).mockResolvedValue(mockRates); + + const result = await fetchHistoricalRatesBatch(['2024-01-01']); + expect(invoke).toHaveBeenCalledWith('fetch_historical_rates_batch', { dates: ['2024-01-01'] }); + expect(result).toEqual(mockRates); + }); + + it('uses fetch when not in Tauri and falls back to closest dates', async () => { + const mockResponse = { rates: { '2023-12-29': { EUR: 0.84 }, '2024-01-02': { EUR: 0.85, JPY: -1 }, '2024-01-05': { EUR: 0.86 } } }; + vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response); + + const dates = ['2024-01-01', '2024-01-03', '2024-01-05']; + const result = await fetchHistoricalRatesBatch(dates); + + expect(fetch).toHaveBeenCalledWith('https://api.frankfurter.dev/v1/2023-12-25..2024-01-05?from=USD'); + + expect(result['2024-01-01']).toEqual({ USD: 1, EUR: 0.84 }); + expect(result['2024-01-03']).toEqual({ USD: 1, EUR: 0.85 }); + expect(result['2024-01-05']).toEqual({ USD: 1, EUR: 0.86 }); + }); + + it('returns empty if fetch fails', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('Network error')); + const result = await fetchHistoricalRatesBatch(['2024-01-01']); + expect(logger.warn).toHaveBeenCalledWith('fetchHistoricalRatesBatch failed', expect.any(Error)); + expect(result).toEqual({}); + }); + }); + describe('fetchHistoricalRates', () => { it('uses invoke when in Tauri', async () => { vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); diff --git a/src/storage.ts b/src/storage.ts index 211180b..3777a9d 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -219,13 +219,34 @@ export async function fetchHistoricalRatesBatch(dates: string[]): Promise>>("fetch_historical_rates_batch", { dates }); } + const sortedDates = [...dates].sort(); + const minDateObj = new Date(sortedDates[0]); + minDateObj.setDate(minDateObj.getDate() - 7); + const minDateQuery = minDateObj.toISOString().split("T")[0]; + const maxDate = sortedDates[sortedDates.length - 1]; + + const res = await fetch(`https://api.frankfurter.dev/v1/${minDateQuery}..${maxDate}?from=USD`); + const json = await res.json(); + if (!json?.rates) return {}; + + const rangeDates = Object.keys(json.rates).sort(); const results: Record> = {}; - await Promise.all( - dates.map(async (date) => { - const rates = await fetchHistoricalRates(date); - if (rates) results[date] = rates; - }) - ); + + for (const date of dates) { + let targetDate = date; + if (!json.rates[date]) { + const possibleDates = rangeDates.filter(d => d <= date); + if (possibleDates.length === 0) continue; + targetDate = possibleDates[possibleDates.length - 1]; + } + + const dayRates = json.rates[targetDate] as Record; + const out: Record = { USD: 1 }; + for (const [k, v] of Object.entries(dayRates)) { + if (v > 0) out[k.toUpperCase()] = v; + } + results[date] = out; + } return results; } catch (e) { logger.warn("fetchHistoricalRatesBatch failed", e);