diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index c3ab258..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "permissions": { - "allow": [ - "Read", - "Edit", - "Bash(git status *)", - "Bash(git diff *)", - "Bash(git log *)", - "Bash(git branch *)", - "Bash(git checkout *)", - "Bash(git add *)", - "Bash(git commit *)", - "Bash(git stash *)", - "Bash(git merge *)", - "Bash(git rebase *)", - "Bash(git fetch *)", - "Bash(git pull *)", - "Bash(git show *)", - "Bash(git blame *)", - "Bash(ls *)", - "Bash(cat *)", - "Bash(find *)", - "Bash(grep *)", - "Bash(rg *)", - "Bash(head *)", - "Bash(tail *)", - "Bash(wc *)", - "Bash(sort *)", - "Bash(uniq *)", - "Bash(diff *)", - "Bash(echo *)", - "Bash(pwd)", - "Bash(which *)", - "Bash(mkdir *)", - "Bash(cp *)", - "Bash(mv *)", - "Bash(touch *)", - "Bash(cd *)", - "Bash(npm run *)", - "Bash(npm test *)", - "Bash(npm install *)", - "Bash(npx *)", - "Bash(yarn *)", - "Bash(pnpm *)", - "Bash(gradle *)", - "Bash(./gradlew *)", - "Bash(go build *)", - "Bash(go test *)", - "Bash(go run *)", - "Bash(go mod *)", - "Bash(cargo *)", - "Bash(make *)", - "Bash(python *)", - "Bash(pip *)", - "Bash(node *)", - "Bash(java *)", - "Bash(javac *)", - "Bash(sed *)", - "Bash(awk *)", - "Bash(jq *)", - "Bash(tree *)", - "Bash(date *)", - "Bash(env *)", - "Bash(printenv *)", - "Bash(realpath *)", - "Bash(dirname *)", - "Bash(basename *)", - "Bash(stat *)", - "Bash(file *)", - "Bash(xargs *)", - "Bash(tee *)" - ], - "deny": [ - "Bash(git push --force *)", - "Bash(git push -f *)", - "Bash(git push * --force *)", - "Bash(git push * -f *)", - "Bash(rm -rf /)", - "Bash(rm -rf /*)", - "Bash(rm -rf ~)", - "Bash(rm -rf ~/*)", - "Bash(curl * | bash *)", - "Bash(curl * | sh *)", - "Bash(wget * | bash *)", - "Bash(wget * | sh *)" - ] - } -} diff --git a/.gitignore b/.gitignore index c52f499..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ -.claude/session-state - - - # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies diff --git a/package-lock.json b/package-lock.json index 9f6da9c..6e4b54f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,7 @@ "next": "16.1.5", "react": "^19.0.0", "react-datepicker": "^8.4.0", - "react-dom": "^19.0.0", - "recharts": "^3.8.1", - "xlsx": "^0.18.5" + "react-dom": "^19.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1097,42 +1095,6 @@ "node": ">=12.4.0" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1147,18 +1109,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1455,69 +1405,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1539,12 +1426,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", @@ -2197,15 +2078,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2605,19 +2477,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2660,15 +2519,6 @@ "node": ">=6" } }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2708,18 +2558,6 @@ "dev": true, "license": "MIT" }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2735,127 +2573,6 @@ "node": ">= 8" } }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2945,12 +2662,6 @@ } } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3234,16 +2945,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", - "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3683,12 +3384,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3808,9 +3503,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -3866,15 +3561,6 @@ "node": ">= 6" } }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4153,16 +3839,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4205,15 +3881,6 @@ "node": ">= 0.4" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5523,9 +5190,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": { @@ -5673,76 +5340,9 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/recharts": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", - "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5787,12 +5387,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6145,18 +5739,6 @@ "node": ">=0.10.0" } }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6403,12 +5985,6 @@ "node": ">=18" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -6442,9 +6018,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": { @@ -6669,37 +6245,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6805,24 +6350,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6833,27 +6360,6 @@ "node": ">=0.10.0" } }, - "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index a6a611d..ee0d8fb 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,7 @@ "next": "16.1.5", "react": "^19.0.0", "react-datepicker": "^8.4.0", - "react-dom": "^19.0.0", - "recharts": "^3.8.1", - "xlsx": "^0.18.5" + "react-dom": "^19.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/app/api/exchange-rates/route.js b/src/app/api/exchange-rates/route.js index 8576418..64ade56 100644 --- a/src/app/api/exchange-rates/route.js +++ b/src/app/api/exchange-rates/route.js @@ -3,102 +3,80 @@ 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, }), }); -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) { +// Helper function to fetch BIDV exchange rates +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 withRetry(() => axiosInstance.get(timeUrl)); + 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( - (best, current) => (current.time > best.time ? current : best), + (latest, current) => (current.time > latest.time ? current : latest), 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 withRetry(() => axiosInstance.get(rateUrl)); + const rateRes = await axiosInstance.get(rateUrl); const rateData = rateRes.data; if (rateData.status !== 1 || !rateData.data) return null; - - const displayDate = format(dateObj, DISPLAY_DATE_FORMAT); - const items = currency === "ALL" - ? rateData.data - : rateData.data.filter((item) => item.currency === currency); - if (items.length === 0) return null; - - return items.map((item) => ({ - date: displayDate, - nameVI: item.nameVI || "", - muaTm: item.muaTm || "", - muaCk: item.muaCk || "", - currency: item.currency || "", - nameEN: item.nameEN || "", - ban: item.ban || "", - })); + // Find USD data + const usd = rateData.data.find((item) => item.currency === "USD"); + if (!usd) return null; + return { + date: dateStr, + nameVI: usd.nameVI || "", + muaTm: usd.muaTm || "", + muaCk: usd.muaCk || "", + currency: usd.currency || "USD", + nameEN: usd.nameEN || "", + ban: usd.ban || "", + }; } catch (error) { - console.error("Error fetching BIDV rates:", error.message); + console.error("Error fetching BIDV rates:", error); return null; } } -async function fetchTCBRates(dateObj, currency) { +// 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`; try { - const res = await withRetry(() => axiosInstance.get(url)); + const res = await axiosInstance.get(url); const data = res.data; if (!data.exchangeRate?.data) return null; - - const items = currency === "ALL" - ? data.exchangeRate.data - : data.exchangeRate.data.filter((item) => item.sourceCurrency === currency); - if (items.length === 0) return null; - - return items.map((item) => ({ + // Find USD data + const usd = data.exchangeRate.data.find( + (item) => item.label === "USD (50,100)" + ); + if (!usd) return null; + return { date: dateStr, - label: item.label || "", - askRate: item.askRate || "", - bidRateCK: item.bidRateCK || "", - bidRateTM: item.bidRateTM || "", - sourceCurrency: item.sourceCurrency || "", - targetCurrency: item.targetCurrency || "", - askRateTM: item.askRateTM || "", - })); + label: usd.label || "", + askRate: usd.askRate || "", + bidRateCK: usd.bidRateCK || "", + bidRateTM: usd.bidRateTM || "", + sourceCurrency: usd.sourceCurrency || "", + targetCurrency: usd.targetCurrency || "", + askRateTM: usd.askRateTM || "", + }; } catch (error) { - console.error("Error fetching TCB rates:", error.message); + console.error("Error fetching TCB rates:", error); return null; } } -function fetchRateForDate(dateObj, bank, currency) { - return bank === "bidv" ? fetchBIDVRates(dateObj, currency) : fetchTCBRates(dateObj, currency); -} - +// Get date range between start and end dates function getDateRange(start, end) { const dates = []; let current = new Date(start); @@ -110,82 +88,105 @@ function getDateRange(start, end) { return dates; } -// Search backwards for the most recent available rate -async function getPreviousDayRate(date, bank, currency, rateCache, maxAttempts = 30) { +// Helper function to get previous business day rate with recursive search +async function getPreviousDayRate(date, bank, rateCache, maxAttempts = 30) { let currentDate = new Date(date); - for (let attempt = 0; attempt < maxAttempts; attempt++) { + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { currentDate.setDate(currentDate.getDate() - 1); - const dateStr = format(currentDate, "yyyy-MM-dd"); - + const dateStr = format(currentDate, 'yyyy-MM-dd'); + + + // Try to find in cache first if (rateCache.has(dateStr)) { return rateCache.get(dateStr); } - - const rates = await fetchRateForDate(currentDate, bank, currency); - if (rates) { - rateCache.set(dateStr, rates); - return rates; - } - } - return null; -} - -// Fetch a chunk of dates in parallel, filling gaps with previous day data -async function fetchChunk(dates, bank, currency, rateCache) { - const settled = await Promise.allSettled( - dates.map((date) => fetchRateForDate(date, bank, currency)) - ); - - 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 rates = outcome.status === "fulfilled" ? outcome.value : null; - - if (rates) { - rateCache.set(dateStr, rates); - results.push(...rates); - } else { - // Weekend/holiday/error — fill with most recent available rate - const prevRates = await getPreviousDayRate(date, bank, currency, rateCache); - if (prevRates) { - const displayDate = format(date, DISPLAY_DATE_FORMAT); - results.push(...prevRates.map((r) => ({ ...r, date: displayDate }))); + + // 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); } } - return results; + + return null; } export async function POST(request) { try { const body = await request.json(); - const { startDate, endDate, bank, currency = "USD" } = body; + const { startDate, endDate, bank } = body; if (!startDate || !endDate || !bank) { - return Response.json( - { error: "Missing required parameters" }, - { status: 400 } + return new Response( + JSON.stringify({ error: "Missing required parameters" }), + { status: 400, headers: { "Content-Type": "application/json" } } ); } - const dates = getDateRange(startDate, endDate); - const rateCache = new Map(); const results = []; - - // 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, currency, rateCache); - results.push(...chunkResults); + 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); + } + } } - - return Response.json({ data: results, total: dates.length, chunks: Math.ceil(dates.length / PARALLEL_CHUNK_SIZE) }); + return new Response(JSON.stringify({ data: results }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); } catch (error) { console.error("Server error:", error); - return Response.json( - { error: "Internal server error" }, - { status: 500 } - ); + return new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } } diff --git a/src/app/components/ExchangeRateViewer.js b/src/app/components/ExchangeRateViewer.js index ba6737b..36b160d 100644 --- a/src/app/components/ExchangeRateViewer.js +++ b/src/app/components/ExchangeRateViewer.js @@ -1,454 +1,274 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState } from "react"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; -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"; +import { parse, isDate, format } from "date-fns"; -// 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 ( - +export default function ExchangeRateViewer({ + defaultStartDate, + defaultEndDate, +}) { + const [startDate, setStartDate] = useState( + defaultStartDate + ? isDate(defaultStartDate) + ? defaultStartDate + : parse(defaultStartDate, "yyyy-MM-dd", new Date()) + : null ); -} - -function MoonIcon() { - return ( - + const [endDate, setEndDate] = useState( + defaultEndDate + ? isDate(defaultEndDate) + ? defaultEndDate + : parse(defaultEndDate, "yyyy-MM-dd", new Date()) + : null ); -} - -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(() => 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); const [error, setError] = useState(""); const [results, setResults] = useState([]); - const [hasFetched, setHasFetched] = useState(false); - const [progress, setProgress] = useState(null); - const [theme, setTheme] = useState("system"); + const [showTable, setShowTable] = useState(false); - // 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); - 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", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - startDate: format(startDate, "yyyy-MM-dd"), - endDate: format(endDate, "yyyy-MM-dd"), - bank, - currency: selectedCurrency, - }), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Failed to fetch ${bank} rates`); - } - return response.json(); - }; - - const handleFetch = useCallback(async () => { + 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([]); - setHasFetched(false); - const totalDays = differenceInCalendarDays(endDate, startDate) + 1; - setProgress({ current: 0, total: totalDays }); + setShowTable(false); try { - if (compareMode) { - const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]); - const merged = new Map(); - for (const row of (bidvData.data || [])) { - const key = `${row.date}|${row.currency}`; - merged.set(key, { date: row.date, currency: row.currency, bidvBuyTm: row.muaTm, bidvSell: row.ban }); - } - for (const row of (tcbData.data || [])) { - const key = `${row.date}|${row.sourceCurrency}`; - const existing = merged.get(key) || { date: row.date, currency: row.sourceCurrency }; - existing.tcbBidTm = row.bidRateTM; - existing.tcbAsk = row.askRate; - merged.set(key, existing); - } - setResults(Array.from(merged.values()).sort((a, b) => a.date.localeCompare(b.date))); - } else { - const data = await fetchBank(selectedBank); - setResults(data.data || []); + const start = startDate; + const end = endDate; + if (start > end) { + setError("Start date cannot be after end date"); + setIsLoading(false); + return; } - setHasFetched(true); + const response = await fetch("/api/exchange-rates", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + startDate: format(start, 'yyyy-MM-dd'), + endDate: format(end, 'yyyy-MM-dd'), + bank: selectedBank, + }), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to fetch exchange rates"); + } + const data = await response.json(); + setResults(data.data || []); + setShowTable(true); } catch (err) { - setError(`An error occurred while fetching exchange rates: ${err.message}`); + setError( + `An error occurred while fetching exchange rates: ${err.message}` + ); console.error(err); } finally { setIsLoading(false); - setProgress(null); } - }, [startDate, endDate, selectedBank, selectedCurrency, compareMode]); + }; - useEffect(() => { - if (hydrated) handleFetch(); - }, [hydrated]); // eslint-disable-line react-hooks/exhaustive-deps - - const getExportRows = () => { - if (compareMode) { - const headers = ["Date", "Currency", "BIDV Buy TM", "BIDV Sell", "TCB Bid TM", "TCB Ask"]; - return [headers, ...results.map((r) => [r.date, r.currency, r.bidvBuyTm || "", r.bidvSell || "", r.tcbBidTm || "", r.tcbAsk || ""])]; - } + const handleExport = () => { + if (results.length === 0) return; + let csvContent = ""; + let headers = []; if (selectedBank === "bidv") { - const headers = ["Date", "NameVI", "MuaTm", "MuaCk", "Currency", "NameEN", "Ban"]; - return [headers, ...results.map((r) => [r.date, r.nameVI, r.muaTm, r.muaCk, r.currency, r.nameEN, r.ban])]; + headers = [ + "Date", + "NameVI", + "MuaTm", + "MuaCk", + "Currency", + "NameEN", + "Ban", + ]; + csvContent = headers.join(",") + "\n"; + csvContent += results + .map( + (row) => + `"${row.date}","${row.nameVI}","${row.muaTm}","${row.muaCk}","${row.currency}","${row.nameEN}","${row.ban}"` + ) + .join("\n"); + } else { + headers = [ + "Date", + "Label", + "AskRate", + "BidRateCK", + "BidRateTM", + "SourceCurrency", + "TargetCurrency", + "AskRateTM", + ]; + csvContent = headers.join(",") + "\n"; + csvContent += results + .map( + (row) => + `"${row.date}","${row.label}","${row.askRate}","${row.bidRateCK}","${row.bidRateTM}","${row.sourceCurrency}","${row.targetCurrency}","${row.askRateTM}"` + ) + .join("\n"); } - const headers = ["Date", "Label", "AskRate", "BidRateCK", "BidRateTM", "SourceCurrency", "TargetCurrency", "AskRateTM"]; - 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 handleExportExcel = () => { - if (results.length === 0) return; - const ws = XLSX.utils.aoa_to_sheet(getExportRows()); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "Exchange Rates"); - XLSX.writeFile(wb, `${baseFilename}.xlsx`); - }; - - const handleExportCsv = () => { - if (results.length === 0) return; - const ws = XLSX.utils.aoa_to_sheet(getExportRows()); - const csv = XLSX.utils.sheet_to_csv(ws); - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `${baseFilename}.csv`; + link.download = `exchange_rates_${selectedBank}_${ + new Date().toISOString().split("T")[0] + }.csv`; document.body.appendChild(link); link.click(); document.body.removeChild(link); - URL.revokeObjectURL(url); }; - const activePreset = getActivePreset(startDate, endDate); - return ( -
- Fetch exchange rates from Vietnamese banks and export to CSV. -
+- {results.length} {results.length === 1 ? "record" : "records"} found -
-- {progress?.total ? `Fetching rates for ${progress.total} day${progress.total > 1 ? "s" : ""}...` : "Fetching exchange rates..."} -
- {progress?.total > 1 && ( -No exchange rate data
-Select a bank and date range, then click Fetch Rates to get started.
-| - {h.label} - | - ))} -
|---|
| - {cell.value || "-"} - | - ))} -
| - {h.label} - | - ))} -
|---|
| - {cell.value} - | - ))} -