diff --git a/package-lock.json b/package-lock.json index ed6f676..6983c20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3808,9 +3808,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5523,9 +5523,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6442,9 +6442,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/src/app/api/exchange-rates/route.js b/src/app/api/exchange-rates/route.js index ba46c5f..8576418 100644 --- a/src/app/api/exchange-rates/route.js +++ b/src/app/api/exchange-rates/route.js @@ -11,12 +11,27 @@ const axiosInstance = axios.create({ const DISPLAY_DATE_FORMAT = "yyyy-MM-dd"; const PARALLEL_CHUNK_SIZE = 5; +const MAX_RETRIES = 2; +const INITIAL_BACKOFF_MS = 500; + +// Retry a function with exponential backoff +async function withRetry(fn, retries = MAX_RETRIES) { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === retries) throw error; + const delay = INITIAL_BACKOFF_MS * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} async function fetchBIDVRates(dateObj, currency) { const dateStr = format(dateObj, "dd/MM/yyyy"); try { const timeUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet?date=${dateStr}`; - const timeRes = await axiosInstance.get(timeUrl); + const timeRes = await withRetry(() => axiosInstance.get(timeUrl)); const timeData = timeRes.data; if (timeData.status !== 1 || !timeData.data?.length) return null; @@ -26,7 +41,7 @@ async function fetchBIDVRates(dateObj, currency) { ); const rateUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet?date=${dateStr}&time=${latest.namerecord}`; - const rateRes = await axiosInstance.get(rateUrl); + const rateRes = await withRetry(() => axiosInstance.get(rateUrl)); const rateData = rateRes.data; if (rateData.status !== 1 || !rateData.data) return null; @@ -55,7 +70,7 @@ async function fetchTCBRates(dateObj, currency) { const dateStr = format(dateObj, "yyyy-MM-dd"); const url = `https://techcombank.com/content/techcombank/web/vn/vi/cong-cu-tien-ich/ty-gia/_jcr_content.exchange-rates.${dateStr}.integration.json`; try { - const res = await axiosInstance.get(url); + const res = await withRetry(() => axiosInstance.get(url)); const data = res.data; if (!data.exchangeRate?.data) return null; @@ -165,7 +180,7 @@ export async function POST(request) { results.push(...chunkResults); } - return Response.json({ data: results, total: dates.length }); + return Response.json({ data: results, total: dates.length, chunks: Math.ceil(dates.length / PARALLEL_CHUNK_SIZE) }); } catch (error) { console.error("Server error:", error); return Response.json( diff --git a/src/app/components/ExchangeRateViewer.js b/src/app/components/ExchangeRateViewer.js index b8706c5..ba6737b 100644 --- a/src/app/components/ExchangeRateViewer.js +++ b/src/app/components/ExchangeRateViewer.js @@ -3,12 +3,29 @@ import React, { useState, useEffect, useCallback } from "react"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; -import { format, subDays, startOfMonth } from "date-fns"; +import { format, subDays, startOfMonth, isSameDay, differenceInCalendarDays } from "date-fns"; import * as XLSX from "xlsx"; import { LoadingSpinner, ErrorMessage, EmptyState } from "./exchange-rate-status"; import { RateTable, ComparisonTable } from "./exchange-rate-table"; import { RateTrendChart } from "./exchange-rate-chart"; +// Read state from URL search params (takes priority over localStorage) +function loadFromUrl() { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const bank = params.get("bank"); + const currency = params.get("currency"); + const start = params.get("start"); + const end = params.get("end"); + if (!bank && !currency && !start && !end) return null; + return { + bank: bank || null, + currency: currency || null, + startDate: start ? new Date(start) : null, + endDate: end ? new Date(end) : null, + }; +} + function loadSavedSettings() { if (typeof window === "undefined") return null; try { @@ -49,11 +66,82 @@ const DATE_PRESETS = [ { label: "This month", start: () => startOfMonth(new Date()), end: () => new Date() }, ]; +function getActivePreset(startDate, endDate) { + if (!startDate || !endDate) return null; + for (const preset of DATE_PRESETS) { + if (isSameDay(startDate, preset.start()) && isSameDay(endDate, preset.end())) { + return preset.label; + } + } + return null; +} + +function applyTheme(value) { + const root = document.documentElement; + if (value === "system") { + root.removeAttribute("data-theme"); + } else { + root.setAttribute("data-theme", value); + } +} + +const THEME_CYCLE = ["system", "light", "dark"]; +const THEME_LABELS = { system: "System", light: "Light", dark: "Dark" }; + +function SunIcon() { + return ( + + ); +} + +function MoonIcon() { + return ( + + ); +} + +function SystemIcon() { + return ( + + ); +} + +// Inline SVG icons (16x16) to avoid adding an icon library dependency +function FetchIcon() { + return ( + + ); +} + +function ExcelIcon() { + return ( + + ); +} + +function CsvIcon() { + return ( + + ); +} + export default function ExchangeRateViewer() { - const [startDate, setStartDate] = useState(() => loadSavedSettings()?.startDate || subDays(new Date(), 7)); - const [endDate, setEndDate] = useState(() => loadSavedSettings()?.endDate || new Date()); - const [selectedBank, setSelectedBank] = useState(() => loadSavedSettings()?.bank || "bidv"); - const [selectedCurrency, setSelectedCurrency] = useState(() => loadSavedSettings()?.currency || "USD"); + const [startDate, setStartDate] = useState(() => subDays(new Date(), 7)); + const [endDate, setEndDate] = useState(() => new Date()); + const [selectedBank, setSelectedBank] = useState("bidv"); + const [selectedCurrency, setSelectedCurrency] = useState("USD"); const [compareMode, setCompareMode] = useState(false); const [showChart, setShowChart] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -61,10 +149,36 @@ export default function ExchangeRateViewer() { const [results, setResults] = useState([]); const [hasFetched, setHasFetched] = useState(false); const [progress, setProgress] = useState(null); + const [theme, setTheme] = useState("system"); + // Restore saved settings on client mount — URL params take priority over localStorage + const [hydrated, setHydrated] = useState(false); useEffect(() => { + const urlSettings = loadFromUrl(); + const saved = urlSettings || loadSavedSettings(); + if (saved) { + if (saved.bank) setSelectedBank(saved.bank); + if (saved.currency) setSelectedCurrency(saved.currency); + if (saved.startDate) setStartDate(saved.startDate); + if (saved.endDate) setEndDate(saved.endDate); + } + const savedTheme = localStorage.getItem("theme") || "system"; + setTheme(savedTheme); + applyTheme(savedTheme); + setHydrated(true); + }, []); + + // Sync state to both localStorage and URL + useEffect(() => { + if (!hydrated) return; saveSettings(selectedBank, selectedCurrency, startDate, endDate); - }, [selectedBank, selectedCurrency, startDate, endDate]); + const params = new URLSearchParams(); + params.set("bank", selectedBank); + params.set("currency", selectedCurrency); + if (startDate) params.set("start", format(startDate, "yyyy-MM-dd")); + if (endDate) params.set("end", format(endDate, "yyyy-MM-dd")); + window.history.replaceState(null, "", `?${params.toString()}`); + }, [selectedBank, selectedCurrency, startDate, endDate, hydrated]); const fetchBank = async (bank) => { const response = await fetch("/api/exchange-rates", { @@ -97,7 +211,8 @@ export default function ExchangeRateViewer() { setIsLoading(true); setResults([]); setHasFetched(false); - setProgress(null); + const totalDays = differenceInCalendarDays(endDate, startDate) + 1; + setProgress({ current: 0, total: totalDays }); try { if (compareMode) { const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]); @@ -129,8 +244,8 @@ export default function ExchangeRateViewer() { }, [startDate, endDate, selectedBank, selectedCurrency, compareMode]); useEffect(() => { - handleFetch(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + if (hydrated) handleFetch(); + }, [hydrated]); // eslint-disable-line react-hooks/exhaustive-deps const getExportRows = () => { if (compareMode) { @@ -170,27 +285,38 @@ export default function ExchangeRateViewer() { URL.revokeObjectURL(url); }; - const inputStyle = { - background: "var(--input-bg)", - color: "var(--foreground)", - borderColor: "var(--input-border)", - }; + const activePreset = getActivePreset(startDate, endDate); return (
- Fetch exchange rates from Vietnamese banks and export to CSV. -
++ Fetch exchange rates from Vietnamese banks and export to CSV. +
+