diff --git a/src/app/api/exchange-rates/route.js b/src/app/api/exchange-rates/route.js index 8576418..e9f480d 100644 --- a/src/app/api/exchange-rates/route.js +++ b/src/app/api/exchange-rates/route.js @@ -1,4 +1,4 @@ -import { format } from "date-fns"; +import { format, addDays, subDays } from "date-fns"; import axios from "axios"; import https from "https"; import crypto from "crypto"; @@ -105,7 +105,7 @@ function getDateRange(start, end) { const last = new Date(end); while (current <= last) { dates.push(new Date(current)); - current.setDate(current.getDate() + 1); + current = addDays(current, 1); } return dates; } @@ -114,7 +114,7 @@ function getDateRange(start, end) { async function getPreviousDayRate(date, bank, currency, rateCache, maxAttempts = 30) { let currentDate = new Date(date); for (let attempt = 0; attempt < maxAttempts; attempt++) { - currentDate.setDate(currentDate.getDate() - 1); + currentDate = subDays(currentDate, 1); const dateStr = format(currentDate, "yyyy-MM-dd"); if (rateCache.has(dateStr)) { diff --git a/src/app/components/exchange-rate-chart.js b/src/app/components/exchange-rate-chart.js index 4f51c3a..d5d1812 100644 --- a/src/app/components/exchange-rate-chart.js +++ b/src/app/components/exchange-rate-chart.js @@ -1,8 +1,11 @@ "use client"; -import React from "react"; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"; +const CHART_COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]; +const MAX_DOTS_DAYS = 31; +const MAX_LEGEND_LINES = 8; + // Parse rate string to number (handles comma-separated values like "25,880") function parseRate(val) { if (!val) return null; @@ -54,8 +57,6 @@ export function RateTrendChart({ results, selectedBank, compareMode }) { } } - const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]; - return (
@@ -80,15 +81,15 @@ export function RateTrendChart({ results, selectedBank, compareMode }) { fontSize: 12, }} /> - {lineKeys.size <= 8 && } + {lineKeys.size <= MAX_LEGEND_LINES && } {Array.from(lineKeys).map((key, i) => ( ))} diff --git a/src/app/components/exchange-rate-constants.js b/src/app/components/exchange-rate-constants.js new file mode 100644 index 0000000..56aa56e --- /dev/null +++ b/src/app/components/exchange-rate-constants.js @@ -0,0 +1,26 @@ +import { subDays, startOfMonth, isSameDay } from "date-fns"; + +export const CURRENCY_OPTIONS = [ + "ALL", "USD", "EUR", "GBP", "JPY", "AUD", + "CAD", "CHF", "SGD", "THB", "HKD", "CNY", "KRW", +]; + +export const DATE_PRESETS = [ + { label: "Today", start: () => new Date(), end: () => new Date() }, + { label: "Last 7 days", start: () => subDays(new Date(), 7), end: () => new Date() }, + { label: "Last 30 days", start: () => subDays(new Date(), 30), end: () => new Date() }, + { label: "This month", start: () => startOfMonth(new Date()), end: () => new Date() }, +]; + +export 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; +} + +export const THEME_CYCLE = ["system", "light", "dark"]; +export const THEME_LABELS = { system: "System", light: "Light", dark: "Dark" }; diff --git a/src/app/components/exchange-rate-icons.js b/src/app/components/exchange-rate-icons.js new file mode 100644 index 0000000..468a41e --- /dev/null +++ b/src/app/components/exchange-rate-icons.js @@ -0,0 +1,47 @@ +export function SunIcon() { + return ( + + ); +} + +export function MoonIcon() { + return ( + + ); +} + +export function SystemIcon() { + return ( + + ); +} + +export function FetchIcon() { + return ( + + ); +} + +export function ExcelIcon() { + return ( + + ); +} + +export function CsvIcon() { + return ( + + ); +} diff --git a/src/app/components/exchange-rate-status.js b/src/app/components/exchange-rate-status.js index 8d0f549..b7cd033 100644 --- a/src/app/components/exchange-rate-status.js +++ b/src/app/components/exchange-rate-status.js @@ -1,12 +1,10 @@ "use client"; -import React from "react"; - export function LoadingSpinner({ progress }) { return (
-
+

{progress?.total ? `Fetching rates for ${progress.total} day${progress.total > 1 ? "s" : ""}...` : "Fetching exchange rates..."}

diff --git a/src/app/components/exchange-rate-storage.js b/src/app/components/exchange-rate-storage.js new file mode 100644 index 0000000..cdd6ac3 --- /dev/null +++ b/src/app/components/exchange-rate-storage.js @@ -0,0 +1,52 @@ +export 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, + }; +} + +export function loadSavedSettings() { + if (typeof window === "undefined") return null; + try { + const saved = localStorage.getItem("exchangeRateSettings"); + if (!saved) return null; + const parsed = JSON.parse(saved); + return { + bank: parsed.bank || "bidv", + currency: parsed.currency || "USD", + startDate: parsed.startDate ? new Date(parsed.startDate) : null, + endDate: parsed.endDate ? new Date(parsed.endDate) : null, + }; + } catch { + return null; + } +} + +export function saveSettings(bank, currency, startDate, endDate) { + try { + localStorage.setItem("exchangeRateSettings", JSON.stringify({ + bank, + currency, + startDate: startDate?.toISOString(), + endDate: endDate?.toISOString(), + })); + } catch { /* ignore quota errors */ } +} + +export function applyTheme(value) { + const root = document.documentElement; + if (value === "system") { + root.removeAttribute("data-theme"); + } else { + root.setAttribute("data-theme", value); + } +} diff --git a/src/app/components/exchange-rate-table.js b/src/app/components/exchange-rate-table.js index 88a1dc5..dabf934 100644 --- a/src/app/components/exchange-rate-table.js +++ b/src/app/components/exchange-rate-table.js @@ -1,7 +1,5 @@ "use client"; -import React from "react"; - export function ComparisonTable({ results }) { const headers = [ { label: "Date", numeric: false }, @@ -41,7 +39,7 @@ export function ComparisonTable({ results }) { ]; return ( {cells.map((cell, i) => ( @@ -60,8 +58,8 @@ export function ComparisonTable({ results }) {
{/* Mobile cards */}
- {results.map((row, idx) => ( -
+ {results.map((row) => ( +
{row.date} {row.currency} @@ -158,9 +156,12 @@ export function RateTable({ results, selectedBank }) { {results.map((row, idx) => { const cells = getRowCells(row); + const rowKey = selectedBank === "bidv" + ? `${row.date}-${row.currency}-${row.nameVI}` + : `${row.date}-${row.sourceCurrency}-${row.label}`; return ( {cells.map((cell, i) => ( @@ -179,10 +180,13 @@ export function RateTable({ results, selectedBank }) {
{/* Mobile cards */}
- {results.map((row, idx) => { + {results.map((row) => { const card = getMobileCard(row); + const rowKey = selectedBank === "bidv" + ? `${row.date}-${row.currency}-${row.nameVI}` + : `${row.date}-${row.sourceCurrency}-${row.label}`; return ( -
+
{card.title} {card.subtitle} diff --git a/src/app/components/ExchangeRateViewer.js b/src/app/components/exchange-rate-viewer.js similarity index 69% rename from src/app/components/ExchangeRateViewer.js rename to src/app/components/exchange-rate-viewer.js index ba6737b..3b0d8cc 100644 --- a/src/app/components/ExchangeRateViewer.js +++ b/src/app/components/exchange-rate-viewer.js @@ -1,141 +1,16 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback } from "react"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; -import { format, subDays, startOfMonth, isSameDay, differenceInCalendarDays } from "date-fns"; +import { format, subDays, 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 { - const saved = localStorage.getItem("exchangeRateSettings"); - if (!saved) return null; - const parsed = JSON.parse(saved); - return { - bank: parsed.bank || "bidv", - currency: parsed.currency || "USD", - startDate: parsed.startDate ? new Date(parsed.startDate) : null, - endDate: parsed.endDate ? new Date(parsed.endDate) : null, - }; - } catch { - return null; - } -} - -function saveSettings(bank, currency, startDate, endDate) { - try { - localStorage.setItem("exchangeRateSettings", JSON.stringify({ - bank, - currency, - startDate: startDate?.toISOString(), - endDate: endDate?.toISOString(), - })); - } catch { /* ignore quota errors */ } -} - -const CURRENCY_OPTIONS = [ - "ALL", "USD", "EUR", "GBP", "JPY", "AUD", - "CAD", "CHF", "SGD", "THB", "HKD", "CNY", "KRW", -]; - -const DATE_PRESETS = [ - { label: "Today", start: () => new Date(), end: () => new Date() }, - { label: "Last 7 days", start: () => subDays(new Date(), 7), end: () => new Date() }, - { label: "Last 30 days", start: () => subDays(new Date(), 30), end: () => new Date() }, - { 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 ( - - ); -} +import { SunIcon, MoonIcon, SystemIcon, FetchIcon, ExcelIcon, CsvIcon } from "./exchange-rate-icons"; +import { loadFromUrl, loadSavedSettings, saveSettings, applyTheme } from "./exchange-rate-storage"; +import { CURRENCY_OPTIONS, DATE_PRESETS, getActivePreset, THEME_CYCLE, THEME_LABELS } from "./exchange-rate-constants"; export default function ExchangeRateViewer() { const [startDate, setStartDate] = useState(() => subDays(new Date(), 7)); @@ -151,7 +26,6 @@ export default function ExchangeRateViewer() { 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(); @@ -168,7 +42,6 @@ export default function ExchangeRateViewer() { setHydrated(true); }, []); - // Sync state to both localStorage and URL useEffect(() => { if (!hydrated) return; saveSettings(selectedBank, selectedCurrency, startDate, endDate); @@ -212,7 +85,7 @@ export default function ExchangeRateViewer() { setResults([]); setHasFetched(false); const totalDays = differenceInCalendarDays(endDate, startDate) + 1; - setProgress({ current: 0, total: totalDays }); + setProgress({ total: totalDays }); try { if (compareMode) { const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]); @@ -260,7 +133,7 @@ export default function ExchangeRateViewer() { return [headers, ...results.map((r) => [r.date, r.label, r.askRate, r.bidRateCK, r.bidRateTM, r.sourceCurrency, r.targetCurrency, r.askRateTM])]; }; - const baseFilename = `exchange_rates_${compareMode ? "compare" : selectedBank}_${selectedCurrency}_${new Date().toISOString().split("T")[0]}`; + const baseFilename = `exchange_rates_${compareMode ? "compare" : selectedBank}_${selectedCurrency}_${format(new Date(), "yyyy-MM-dd")}`; const handleExportExcel = () => { if (results.length === 0) return; @@ -293,7 +166,7 @@ export default function ExchangeRateViewer() {

Exchange Rate Export

- Fetch exchange rates from Vietnamese banks and export to CSV. + Fetch exchange rates from Vietnamese banks and export to Excel or CSV.