diff --git a/package-lock.json b/package-lock.json index aaac36e..e3686f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,10 @@ "next": "15.3.4", "next-auth": "^4.24.11", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-tooltip": "^5.29.1", + "recharts": "^3.0.2" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -412,6 +415,31 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1232,6 +1260,32 @@ "@prisma/debug": "6.11.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "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/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1246,6 +1300,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "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/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1556,6 +1622,69 @@ "optional": true, "peer": true }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "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.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "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", @@ -1591,7 +1720,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1607,6 +1736,12 @@ "@types/react": "^19.0.0" } }, + "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.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", @@ -2607,12 +2742,27 @@ "node": ">=18" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -2693,9 +2843,130 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "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.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "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", @@ -2775,6 +3046,12 @@ } } }, + "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", @@ -3054,6 +3331,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.39.6", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.6.tgz", + "integrity": "sha512-uiVjnLem6kkfXumlwUEWEKnwUN5QbSEB0DHy2rNJt0nkYcob5K0TXJ7oJRzhAcvx+SRmz4TahKyN5V9cly/IPA==", + "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", @@ -3493,6 +3780,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "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", @@ -3919,6 +4212,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "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", @@ -3961,6 +4264,15 @@ "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", @@ -5562,13 +5874,100 @@ "react": "^19.1.0" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "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/react-tooltip": { + "version": "5.29.1", + "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.29.1.tgz", + "integrity": "sha512-rmJmEb/p99xWhwmVT7F7riLG08wwKykjHiMGbDPloNJk3tdI73oHsVOwzZ4SRjqMdd5/xwb/4nmz0RcoMfY7Bw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.1", + "classnames": "^2.3.0" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/recharts": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.0.2.tgz", + "integrity": "sha512-eDc3ile9qJU9Dp/EekSthQPhAVPG48/uM47jk+PF7VBQngxeW3cwQpPHb/GHC1uqwyCRWXcIrDzuHRVrnRryoQ==", + "license": "MIT", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 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", @@ -5613,6 +6012,12 @@ "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", @@ -6221,6 +6626,12 @@ "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", @@ -6487,6 +6898,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -6496,6 +6916,28 @@ "uuid": "dist/bin/uuid" } }, + "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", diff --git a/package.json b/package.json index 08428ee..0eacb88 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "next": "15.3.4", "next-auth": "^4.24.11", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-tooltip": "^5.29.1", + "recharts": "^3.0.2" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/prisma/migrations/20250708103938_add_electric_fueltype/migration.sql b/prisma/migrations/20250708103938_add_electric_fueltype/migration.sql new file mode 100644 index 0000000..fadb241 --- /dev/null +++ b/prisma/migrations/20250708103938_add_electric_fueltype/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "FuelType" ADD VALUE 'ELECTRIC'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7a70d3..4c98ab7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,7 @@ enum FuelType { GASOLINE DIESEL LPG + ELECTRIC } enum Currency { diff --git a/src/app/api/activity/route.ts b/src/app/api/activity/route.ts new file mode 100644 index 0000000..0d5fb64 --- /dev/null +++ b/src/app/api/activity/route.ts @@ -0,0 +1,53 @@ +import { getSession } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { NextResponse } from 'next/server'; + +export async function GET(req: Request) { + const session = await getSession(); + if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + + const userEmail = session.user.email!; + // Get all cars for the user + const cars = await prisma.car.findMany({ + where: { user: { email: userEmail } }, + select: { id: true, name: true, make: true, model: true }, + }); + const carIds = cars.map((c) => c.id); + + // Get recent fill-ups and mileage entries for all cars + const fillUps = await prisma.fillUp.findMany({ + where: { carId: { in: carIds } }, + orderBy: { date: 'desc' }, + take: 10, + include: { car: { select: { name: true, make: true, model: true } } }, + }); + const mileageEntries = await prisma.mileageEntry.findMany({ + where: { carId: { in: carIds } }, + orderBy: { date: 'desc' }, + take: 10, + include: { car: { select: { name: true, make: true, model: true } } }, + }); + + // Merge and sort by date + const activity = [ + ...fillUps.map(f => ({ + type: 'fillup', + id: f.id, + car: f.car, + mileage: f.mileage, + liters: f.liters, + cost: f.cost, + currency: f.currency, + date: f.date, + })), + ...mileageEntries.map(m => ({ + type: 'mileage', + id: m.id, + car: m.car, + mileage: m.mileage, + date: m.date, + })), + ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + return NextResponse.json(activity.slice(0, 10)); +} diff --git a/src/app/api/cars/[carId]/route.ts b/src/app/api/cars/[carId]/route.ts new file mode 100644 index 0000000..63ce1f8 --- /dev/null +++ b/src/app/api/cars/[carId]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function GET(req: Request, { params }: { params: { carId: string } }) { + const session = await getSession(); + if (!session) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + + const carId = params.carId; + if (!carId) return NextResponse.json({ message: 'Missing carId' }, { status: 400 }); + + const car = await prisma.car.findFirst({ + where: { + id: carId, + user: { email: session.user?.email! }, + }, + select: { + id: true, + name: true, + make: true, + model: true, + year: true, + fuelType: true, + }, + }); + + if (!car) return NextResponse.json({ message: 'Car not found' }, { status: 404 }); + return NextResponse.json(car); +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 34d0d0b..34e4b11 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -38,7 +38,7 @@ export default function LoginPage() { value={email} required onChange={(e) => setEmail(e.target.value)} - className="w-full max-w-2xl text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-gray-900 placeholder:text-gray-500" + className="w-full max-w-2xl text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-[var(--background)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50" />
setPassword(e.target.value)} - className="w-full text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-gray-900 placeholder:text-gray-500 pr-16" + className="w-full text-lg border border-[var(--border)] px-6 py-4 rounded-lg bg-[var(--background)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 pr-16" />
- {error &&

{error}

} diff --git a/src/app/dashboard/cars/[carId]/fillups/page.tsx b/src/app/dashboard/cars/[carId]/fillups/page.tsx index 60437f5..29d1da4 100644 --- a/src/app/dashboard/cars/[carId]/fillups/page.tsx +++ b/src/app/dashboard/cars/[carId]/fillups/page.tsx @@ -18,6 +18,7 @@ const currencies = ['EUR', 'USD', 'RON', 'GBP']; export default function FillUpsPage() { const { carId } = useParams(); const [fillups, setFillups] = useState([]); + const [carFuelType, setCarFuelType] = useState(undefined); const [form, setForm] = useState({ mileage: '', liters: '', @@ -33,6 +34,13 @@ export default function FillUpsPage() { .then(setFillups); }, [carId]); + // Fetch car fuel type + useEffect(() => { + fetch(`/api/cars/${carId}`) + .then((res) => res.json()) + .then((car) => setCarFuelType(car?.fuelType)); + }, [carId]); + const handleChange = (e: React.ChangeEvent) => { setForm({ ...form, [e.target.name]: e.target.value }); }; @@ -56,6 +64,16 @@ export default function FillUpsPage() { } }; + // Only render fill-ups when carFuelType is loaded + if (carFuelType === undefined) { + return ( +
+

Fuel Fill-Ups

+
Loading car details...
+
+ ); + } + return (

Fuel Fill-Ups

@@ -76,19 +94,23 @@ export default function FillUpsPage() { Enter the odometer reading at fill-up
- + - How many liters did you fill? + + {carFuelType === 'ELECTRIC' ? 'How many kilowatt-hours did you charge?' : 'How many liters did you fill?'} +
@@ -127,7 +149,7 @@ export default function FillUpsPage() {
    {fillups.map((fill) => ( - // βœ… + ))}
diff --git a/src/app/dashboard/cars/[carId]/page.tsx b/src/app/dashboard/cars/[carId]/page.tsx index 9266d65..df211ed 100644 --- a/src/app/dashboard/cars/[carId]/page.tsx +++ b/src/app/dashboard/cars/[carId]/page.tsx @@ -2,6 +2,7 @@ import { getSession } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { redirect } from 'next/navigation'; import Link from 'next/link'; +import { FaCarSide, FaGasPump, FaCalendarAlt, FaIndustry, FaBolt } from 'react-icons/fa'; interface CarDetailPageProps { params: { @@ -10,13 +11,14 @@ interface CarDetailPageProps { } export default async function CarDetailPage({ params }: CarDetailPageProps) { + const resolvedParams = await params; const session = await getSession(); if (!session) redirect('/auth/login'); const userEmail = session.user?.email!; const car = await prisma.car.findFirst({ where: { - id: params.carId, + id: resolvedParams.carId, user: { email: userEmail }, }, }); @@ -25,30 +27,70 @@ export default async function CarDetailPage({ params }: CarDetailPageProps) { return
Car not found or unauthorized access.
; } + // Fuel type badge color and icon + const fuelMeta: Record = { + GASOLINE: { color: 'bg-blue-100 text-blue-700', icon: }, + DIESEL: { color: 'bg-yellow-100 text-yellow-800', icon: }, + LPG: { color: 'bg-pink-100 text-pink-700', icon: }, + ELECTRIC: { color: 'bg-green-100 text-green-700', icon: }, + }; + return ( -
-

{car.name}

-

{car.make} {car.model} ({car.year})

-

Fuel Type: {car.fuelType}

-
- - βž• Add Mileage - - - βž• Add Fill-Up - - - πŸ“Š View Stats - +
+
+ {/* Car Icon & Header */} +
+ +

+ {car.name} + {car.year} +

+ {fuelMeta[car.fuelType]?.icon}{car.fuelType} +
+ {/* Car Details */} +
+
+ + Make: + {car.make} +
+
+ + Model: + {car.model} +
+
+ + Year: + {car.year} +
+
+ {fuelMeta[car.fuelType]?.icon} + Fuel: + {car.fuelType} +
+
+ {/* Actions */} +
+ + βž• Add Mileage + + + βž• Add Fill-Up + + + πŸ“Š View Stats + +
); diff --git a/src/app/dashboard/cars/[carId]/stats/page.tsx b/src/app/dashboard/cars/[carId]/stats/page.tsx index da98e35..01d65cc 100644 --- a/src/app/dashboard/cars/[carId]/stats/page.tsx +++ b/src/app/dashboard/cars/[carId]/stats/page.tsx @@ -1,7 +1,7 @@ import { getSession } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { redirect } from 'next/navigation'; -import StatCard from '@/components/StatCard'; +import StatsPageClient from '@/components/StatsPageClient'; interface StatsProps { params: { @@ -10,60 +10,28 @@ interface StatsProps { } export default async function StatsPage({ params }: StatsProps) { - const session = await getSession(); - if (!session) redirect('/auth/login'); + let fillUpsWithCarName = []; + let error = null; + try { + const session = await getSession(); + if (!session) redirect('/auth/login'); - const car = await prisma.car.findFirst({ - where: { - id: params.carId, - user: { email: session.user?.email || '' }, - }, - include: { - fillUps: { - orderBy: { mileage: 'asc' }, + const car = await prisma.car.findFirst({ + where: { + id: params.carId, + user: { email: session.user?.email || '' }, }, - }, - }); + include: { + fillUps: { + orderBy: { mileage: 'asc' }, + }, + }, + }); - if (!car) return
Car not found or unauthorized.
; - const fillUps = car.fillUps; - - if (fillUps.length < 2) { - return ( -
-
- β›½ -

Not Enough Data

-

Add at least 2 fill-ups to see your fuel statistics and insights.

- βž• Add Fill-Up -
-
- ); + if (!car) throw new Error('Car not found or unauthorized.'); + fillUpsWithCarName = car.fillUps.map(f => ({ ...f, car: { name: car.name, fuelType: car.fuelType } })); + } catch (e: any) { + error = e?.message || 'An unexpected error occurred.'; } - - // Compute stats - const first = fillUps[0]; - const last = fillUps[fillUps.length - 1]; - - const totalDistance = last.mileage - first.mileage; - const totalLiters = fillUps.slice(1).reduce((sum, f) => sum + f.liters, 0); // ignore first fill - const totalCost = fillUps.slice(1).reduce((sum, f) => sum + f.cost, 0); - - const avgConsumption = (totalLiters / totalDistance) * 100; // L/100km - const costPerKm = totalCost / totalDistance; - - return ( -
-

- πŸ“Š Fuel Stats -

-
- - - - - -
-
- ); + return ; } diff --git a/src/app/dashboard/cars/new/page.tsx b/src/app/dashboard/cars/new/page.tsx index 843ba8a..2bbcc81 100644 --- a/src/app/dashboard/cars/new/page.tsx +++ b/src/app/dashboard/cars/new/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -const fuelTypes = ['GASOLINE', 'DIESEL', 'LPG']; +const fuelTypes = ['GASOLINE', 'DIESEL', 'LPG', 'ELECTRIC']; export default function AddCarPage() { const router = useRouter(); @@ -30,62 +30,93 @@ export default function AddCarPage() { if (res.ok) { router.push('/dashboard'); } else { - const data = await res.json(); + let data = { message: 'Failed to add car' }; + try { + data = await res.json(); + } catch { + // If response is empty or not JSON, keep default error + } setError(data.message || 'Failed to add car'); } }; return ( -
-

Add New Car

-
- - - - - +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ - {error &&

{error}

} + {error &&

{error}

}
); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 73624bf..060aa44 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,39 +1,62 @@ -import Link from 'next/link'; import { getSession } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import Link from 'next/link'; import { redirect } from 'next/navigation'; -import CarCard from '@/components/CarCard'; +import CarGrid from '@/components/CarGrid'; +import RecentActivity from '@/components/RecentActivity'; export default async function DashboardPage() { const session = await getSession(); if (!session) redirect('/auth/login'); const userEmail = session.user.email!; - const user = await prisma.user.findUnique({ - where: { email: userEmail }, - include: { cars: true }, + // Fetch cars with last fill-up and mileage entries + const cars = await prisma.car.findMany({ + where: { user: { email: userEmail } }, + include: { + fillUps: { orderBy: { date: 'desc' }, take: 1 }, + mileage: { orderBy: { date: 'desc' }, take: 5 }, // fixed: use 'mileage' not 'mileageEntries' + }, }); return ( -
-
-

Your Cars

- +
+ {/* Personalized Greeting */} +
+ + {session.user?.name?.[0]?.toUpperCase() || session.user?.email?.[0]?.toUpperCase() || 'U'} + +
+

Welcome back{session.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}!

+

Ready to track your next fill-up?

+
+
+ +
+

Your Cars

+ + Add Car
- {user?.cars.length ? ( -
    - {user.cars.map((car) => ( - - ))} -
+ + {cars.length ? ( + ) : ( -
-

No cars added yet.

- Add your first car +
+ +
No cars added yet.
+
Add your first car to get started!
+ + + Add Car +
)} + + {/* Recent Activity Section */} +
+

Recent Activity

+ +
); } diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/globals.css b/src/app/globals.css index 1f0af3e..7a9c4ab 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,22 +8,29 @@ --accent: #a21caf; /* purple-700 */ --muted: #f3f4f6; /* gray-100 */ --border: #e5e7eb; /* gray-200 */ + --statcard-primary-bg: #e0e7ff; /* indigo-100 */ + --statcard-primary-border: #2563eb; /* blue-600 */ + --statcard-secondary-bg: #dcfce7; /* green-100 */ + --statcard-secondary-border: #22c55e; /* green-500 */ + --statcard-accent-bg: #f3e8ff; /* purple-100 */ + --statcard-accent-border: #a21caf; /* purple-700 */ + --statcard-default-bg: var(--background); + --statcard-default-border: var(--border); } -@theme inline { - --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) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - --muted: #171717; - --border: #232323; - } +.dark { + --background: #0a0a0a; + --foreground: #ededed; + --muted: #171717; + --border: #232323; + --statcard-primary-bg: #1e293b; /* slate-800 */ + --statcard-primary-border: #2563eb; /* blue-600 */ + --statcard-secondary-bg: #052e16; /* green-950 */ + --statcard-secondary-border: #22c55e; /* green-500 */ + --statcard-accent-bg: #3b0764; /* purple-950 */ + --statcard-accent-border: #a21caf; /* purple-700 */ + --statcard-default-bg: var(--background); + --statcard-default-border: var(--border); } body { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a6003b8..9a310df 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -24,8 +24,24 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + // This script will set the correct theme class on before hydration + const setThemeScript = ` + (function() { + try { + var theme = localStorage.getItem('theme'); + if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + } catch (e) {} + })(); + `; return ( + +