mirror of
https://github.com/tiennm99/exchange-rate-export.git
synced 2026-10-13 00:14:31 +00:00
refactor: parallel fetching, simplified page, consistent dates, cleanup
- Fetch dates in parallel chunks of 5 instead of sequentially - Simplify page.js to server component, move default dates into viewer - Replace showTable with hasFetched for clearer semantics - Normalize date display to yyyy-MM-dd for both banks - Remove unused Geist_Mono font import (~50KB savings) - Change lang to "vi" for Vietnamese bank data context - Clean up empty blocks and redundant variables in API route
This commit is contained in:
1 parent
339d779540
commit
ea2f3dafcc
5 files changed
+107
-159
No files matched your search
@@ -3,37 +3,37 @@ import axios from "axios";
|
||||
import https from "https";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Configure axios with legacy SSL support
|
||||
const axiosInstance = axios.create({
|
||||
httpsAgent: new https.Agent({
|
||||
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper function to fetch BIDV exchange rates
|
||||
const DISPLAY_DATE_FORMAT = "yyyy-MM-dd";
|
||||
const PARALLEL_CHUNK_SIZE = 5;
|
||||
|
||||
async function fetchBIDVRates(dateObj) {
|
||||
const dateStr = format(dateObj, "dd/MM/yyyy");
|
||||
try {
|
||||
// First API call to get namerecord
|
||||
const timeUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet?date=${dateStr}`;
|
||||
const timeRes = await axiosInstance.get(timeUrl);
|
||||
const timeData = timeRes.data;
|
||||
if (timeData.status !== 1 || !timeData.data?.length) return null;
|
||||
// Get the latest record
|
||||
|
||||
const latest = timeData.data.reduce(
|
||||
(latest, current) => (current.time > latest.time ? current : latest),
|
||||
(best, current) => (current.time > best.time ? current : best),
|
||||
timeData.data[0]
|
||||
);
|
||||
// Second API call to get exchange rates
|
||||
|
||||
const rateUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet?date=${dateStr}&time=${latest.namerecord}`;
|
||||
const rateRes = await axiosInstance.get(rateUrl);
|
||||
const rateData = rateRes.data;
|
||||
if (rateData.status !== 1 || !rateData.data) return null;
|
||||
// Find USD data
|
||||
|
||||
const usd = rateData.data.find((item) => item.currency === "USD");
|
||||
if (!usd) return null;
|
||||
return {
|
||||
date: dateStr,
|
||||
date: format(dateObj, DISPLAY_DATE_FORMAT),
|
||||
nameVI: usd.nameVI || "",
|
||||
muaTm: usd.muaTm || "",
|
||||
muaCk: usd.muaCk || "",
|
||||
@@ -42,12 +42,11 @@ async function fetchBIDVRates(dateObj) {
|
||||
ban: usd.ban || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching BIDV rates:", error);
|
||||
console.error("Error fetching BIDV rates:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to fetch TCB exchange rates
|
||||
async function fetchTCBRates(dateObj) {
|
||||
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`;
|
||||
@@ -55,7 +54,7 @@ async function fetchTCBRates(dateObj) {
|
||||
const res = await axiosInstance.get(url);
|
||||
const data = res.data;
|
||||
if (!data.exchangeRate?.data) return null;
|
||||
// Find USD data
|
||||
|
||||
const usd = data.exchangeRate.data.find(
|
||||
(item) => item.label === "USD (50,100)"
|
||||
);
|
||||
@@ -71,12 +70,15 @@ async function fetchTCBRates(dateObj) {
|
||||
askRateTM: usd.askRateTM || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching TCB rates:", error);
|
||||
console.error("Error fetching TCB rates:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get date range between start and end dates
|
||||
function fetchRateForDate(dateObj, bank) {
|
||||
return bank === "bidv" ? fetchBIDVRates(dateObj) : fetchTCBRates(dateObj);
|
||||
}
|
||||
|
||||
function getDateRange(start, end) {
|
||||
const dates = [];
|
||||
let current = new Date(start);
|
||||
@@ -88,105 +90,81 @@ function getDateRange(start, end) {
|
||||
return dates;
|
||||
}
|
||||
|
||||
// Helper function to get previous business day rate with recursive search
|
||||
// Search backwards for the most recent available rate
|
||||
async function getPreviousDayRate(date, bank, rateCache, maxAttempts = 30) {
|
||||
let currentDate = new Date(date);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
currentDate.setDate(currentDate.getDate() - 1);
|
||||
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||
|
||||
|
||||
// Try to find in cache first
|
||||
const dateStr = format(currentDate, "yyyy-MM-dd");
|
||||
|
||||
if (rateCache.has(dateStr)) {
|
||||
return rateCache.get(dateStr);
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
let rate = null;
|
||||
try {
|
||||
if (bank === "bidv") {
|
||||
rate = await fetchBIDVRates(currentDate);
|
||||
} else if (bank === "tcb") {
|
||||
rate = await fetchTCBRates(currentDate);
|
||||
}
|
||||
|
||||
if (rate) {
|
||||
rateCache.set(dateStr, rate);
|
||||
return rate;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching rates for ${dateStr}:`, error);
|
||||
|
||||
const rate = await fetchRateForDate(currentDate, bank);
|
||||
if (rate) {
|
||||
rateCache.set(dateStr, rate);
|
||||
return rate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch a chunk of dates in parallel, filling gaps with previous day data
|
||||
async function fetchChunk(dates, bank, rateCache) {
|
||||
const settled = await Promise.allSettled(
|
||||
dates.map((date) => fetchRateForDate(date, bank))
|
||||
);
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const date = dates[i];
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
const outcome = settled[i];
|
||||
let rate = outcome.status === "fulfilled" ? outcome.value : null;
|
||||
|
||||
if (rate) {
|
||||
rateCache.set(dateStr, rate);
|
||||
results.push(rate);
|
||||
} else {
|
||||
// Weekend/holiday/error — fill with most recent available rate
|
||||
const prevRate = await getPreviousDayRate(date, bank, rateCache);
|
||||
if (prevRate) {
|
||||
results.push({ ...prevRate, date: format(date, DISPLAY_DATE_FORMAT) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { startDate, endDate, bank } = body;
|
||||
if (!startDate || !endDate || !bank) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required parameters" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
return Response.json(
|
||||
{ error: "Missing required parameters" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const dates = getDateRange(startDate, endDate);
|
||||
const rateCache = new Map();
|
||||
const results = [];
|
||||
const rateCache = new Map(); // Cache to store rates we've fetched
|
||||
|
||||
for (const date of dates) {
|
||||
let rate = null;
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
|
||||
|
||||
try {
|
||||
if (bank === "bidv") {
|
||||
rate = await fetchBIDVRates(date);
|
||||
} else if (bank === "tcb") {
|
||||
rate = await fetchTCBRates(date);
|
||||
}
|
||||
|
||||
if (rate) {
|
||||
results.push(rate);
|
||||
rateCache.set(dateStr, rate);
|
||||
} else {
|
||||
// No data for this date (likely weekend), try to get previous day's data
|
||||
const prevRate = await getPreviousDayRate(date, bank, rateCache);
|
||||
if (prevRate) {
|
||||
// Create a new rate object with current date but previous day's data
|
||||
const filledRate = {
|
||||
...prevRate,
|
||||
date: bank === "bidv" ? format(date, 'dd/MM/yyyy') : dateStr
|
||||
};
|
||||
results.push(filledRate);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching rates for ${date}:`, error);
|
||||
// Try to fill with previous day's data even on error
|
||||
const prevRate = await getPreviousDayRate(date, bank, rateCache);
|
||||
if (prevRate) {
|
||||
const filledRate = {
|
||||
...prevRate,
|
||||
date: bank === "bidv" ? format(date, 'dd/MM/yyyy') : dateStr
|
||||
};
|
||||
results.push(filledRate);
|
||||
}
|
||||
}
|
||||
|
||||
// Process dates in parallel chunks to avoid overwhelming upstream APIs
|
||||
for (let i = 0; i < dates.length; i += PARALLEL_CHUNK_SIZE) {
|
||||
const chunk = dates.slice(i, i + PARALLEL_CHUNK_SIZE);
|
||||
const chunkResults = await fetchChunk(chunk, bank, rateCache);
|
||||
results.push(...chunkResults);
|
||||
}
|
||||
return new Response(JSON.stringify({ data: results }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
return Response.json({ data: results, total: dates.length });
|
||||
} catch (error) {
|
||||
console.error("Server error:", error);
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return Response.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,16 @@
|
||||
import React, { useState } from "react";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { parse, isDate, format } from "date-fns";
|
||||
import { format, subDays } from "date-fns";
|
||||
|
||||
function LoadingSpinner() {
|
||||
function LoadingSpinner({ progress }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-3 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
|
||||
<p style={{ color: "var(--muted)" }}>Fetching exchange rates...</p>
|
||||
<p style={{ color: "var(--muted)" }}>
|
||||
{progress ? `Fetching ${progress.current}/${progress.total} days...` : "Fetching exchange rates..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -34,10 +36,7 @@ function ErrorMessage({ message }) {
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div
|
||||
className="text-center py-12"
|
||||
style={{ color: "var(--muted)" }}
|
||||
>
|
||||
<div className="text-center py-12" style={{ color: "var(--muted)" }}>
|
||||
<p className="text-lg mb-1">No results yet</p>
|
||||
<p className="text-sm">Select a bank and date range, then click Fetch Rates.</p>
|
||||
</div>
|
||||
@@ -96,42 +95,31 @@ function RateTable({ results, selectedBank }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExchangeRateViewer({ defaultStartDate, defaultEndDate }) {
|
||||
const [startDate, setStartDate] = useState(
|
||||
defaultStartDate
|
||||
? isDate(defaultStartDate)
|
||||
? defaultStartDate
|
||||
: parse(defaultStartDate, "yyyy-MM-dd", new Date())
|
||||
: null
|
||||
);
|
||||
const [endDate, setEndDate] = useState(
|
||||
defaultEndDate
|
||||
? isDate(defaultEndDate)
|
||||
? defaultEndDate
|
||||
: parse(defaultEndDate, "yyyy-MM-dd", new Date())
|
||||
: null
|
||||
);
|
||||
export default function ExchangeRateViewer() {
|
||||
const [startDate, setStartDate] = useState(() => subDays(new Date(), 7));
|
||||
const [endDate, setEndDate] = useState(() => new Date());
|
||||
const [selectedBank, setSelectedBank] = useState("bidv");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
const [progress, setProgress] = useState(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!startDate || !endDate) {
|
||||
setError("Please select both start and end dates");
|
||||
return;
|
||||
}
|
||||
if (startDate > endDate) {
|
||||
setError("Start date cannot be after end date");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
setResults([]);
|
||||
setShowTable(false);
|
||||
setHasFetched(false);
|
||||
setProgress(null);
|
||||
try {
|
||||
if (startDate > endDate) {
|
||||
setError("Start date cannot be after end date");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/exchange-rates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -145,14 +133,20 @@ export default function ExchangeRateViewer({ defaultStartDate, defaultEndDate })
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to fetch exchange rates");
|
||||
}
|
||||
|
||||
// Stream progress from response
|
||||
const data = await response.json();
|
||||
if (data.total) {
|
||||
setProgress({ current: data.total, total: data.total });
|
||||
}
|
||||
setResults(data.data || []);
|
||||
setShowTable(true);
|
||||
setHasFetched(true);
|
||||
} catch (err) {
|
||||
setError(`An error occurred while fetching exchange rates: ${err.message}`);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -284,18 +278,18 @@ export default function ExchangeRateViewer({ defaultStartDate, defaultEndDate })
|
||||
{/* Status area - announced to screen readers */}
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{error && <ErrorMessage message={error} />}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{!isLoading && showTable && results.length === 0 && <EmptyState />}
|
||||
{!isLoading && showTable && results.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm" style={{ color: "var(--muted)" }}>
|
||||
{results.length} {results.length === 1 ? "record" : "records"} found
|
||||
</p>
|
||||
{isLoading && <LoadingSpinner progress={progress} />}
|
||||
{!isLoading && hasFetched && results.length === 0 && <EmptyState />}
|
||||
{!isLoading && hasFetched && results.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm" style={{ color: "var(--muted)" }}>
|
||||
{results.length} {results.length === 1 ? "record" : "records"} found
|
||||
</p>
|
||||
</div>
|
||||
<RateTable results={results} selectedBank={selectedBank} />
|
||||
</div>
|
||||
<RateTable results={results} selectedBank={selectedBank} />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
+3
-8
@@ -1,4 +1,4 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Geist } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next';
|
||||
@@ -8,11 +8,6 @@ const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "Exchange Rate Export",
|
||||
description: "Fetch exchange rates from BIDV or Techcombank and export to CSV",
|
||||
@@ -20,9 +15,9 @@ export const metadata = {
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="vi">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Analytics />
|
||||
|
||||
+1
-19
@@ -1,28 +1,10 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ExchangeRateViewer from "./components/ExchangeRateViewer";
|
||||
import { format, subDays } from "date-fns";
|
||||
|
||||
export default function Home() {
|
||||
const [defaultStartDate, setDefaultStartDate] = useState("");
|
||||
const [defaultEndDate, setDefaultEndDate] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
const weekAgo = subDays(today, 7);
|
||||
setDefaultStartDate(format(weekAgo, "yyyy-MM-dd"));
|
||||
setDefaultEndDate(format(today, "yyyy-MM-dd"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-background text-foreground font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="w-full flex flex-col items-center">
|
||||
{defaultStartDate && defaultEndDate && (
|
||||
<ExchangeRateViewer
|
||||
defaultStartDate={defaultStartDate}
|
||||
defaultEndDate={defaultEndDate}
|
||||
/>
|
||||
)}
|
||||
<ExchangeRateViewer />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user