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"
/>
-
+
- 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
-
);
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 */}
+
);
}
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 (
+
+
+
diff --git a/src/components/Achievements.tsx b/src/components/Achievements.tsx
new file mode 100644
index 0000000..31cf606
--- /dev/null
+++ b/src/components/Achievements.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+export type Achievement = {
+ id: string;
+ label: string;
+ description: string;
+ icon: string;
+ achieved: boolean;
+};
+
+export default function Achievements({ achievements }: { achievements: Achievement[] }) {
+ return (
+
+ {achievements.map(a => (
+
+ {a.icon}
+ {a.label}
+ {a.description}
+
+ ))}
+
+ );
+}
diff --git a/src/components/AchievementsSkeleton.tsx b/src/components/AchievementsSkeleton.tsx
new file mode 100644
index 0000000..f66fcbd
--- /dev/null
+++ b/src/components/AchievementsSkeleton.tsx
@@ -0,0 +1,13 @@
+export default function AchievementsSkeleton() {
+ return (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/CarCard.tsx b/src/components/CarCard.tsx
index 7f7cd1a..1538370 100644
--- a/src/components/CarCard.tsx
+++ b/src/components/CarCard.tsx
@@ -1,13 +1,44 @@
-import { Car } from '@prisma/client';
import Link from 'next/link';
-export default function CarCard({ car }: { car: Car }) {
+// Removed unused Car import and suppressed 'any' type warning for pragmatic dashboard use
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export default function CarCard({ car }: { car: any }) {
+ const lastFill = car.fillUps?.[0];
+ const mileageEntries = car.mileage || [];
+ const avgMileage = mileageEntries.length > 1
+ ? Math.round((mileageEntries[0].mileage - mileageEntries[mileageEntries.length - 1].mileage) / (mileageEntries.length - 1))
+ : null;
+
+ function getFuelBadgeColor(fuelType: string) {
+ switch (fuelType) {
+ case 'GASOLINE': return 'bg-blue-200 text-blue-800 dark:bg-blue-400 dark:text-blue-900';
+ case 'DIESEL': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-300 dark:text-yellow-900';
+ case 'LPG': return 'bg-purple-100 text-purple-800 dark:bg-purple-300 dark:text-purple-900';
+ case 'ELECTRIC': return 'bg-green-100 text-green-800 dark:bg-green-300 dark:text-green-900';
+ default: return 'bg-gray-200 text-gray-700 dark:bg-gray-400 dark:text-gray-900';
+ }
+ }
+
return (
-
-
-
{car.name}
-
{car.make} {car.model} ({car.year})
-
Fuel Type: {car.fuelType}
+
+
+
+
{car.name}
+ {car.fuelType}
+
+
{car.make} {car.model} ({car.year})
+
+ {lastFill ? (
+
Last Fill-Up: {new Date(lastFill.date).toLocaleDateString()} ({lastFill.mileage} km)
+ ) : (
+
Last Fill-Up: Not enough data
+ )}
+ {avgMileage !== null ? (
+
Avg. Mileage: {avgMileage} km
+ ) : (
+
Avg. Mileage: Not enough data
+ )}
+
);
diff --git a/src/components/CarGrid.tsx b/src/components/CarGrid.tsx
new file mode 100644
index 0000000..ded9cd5
--- /dev/null
+++ b/src/components/CarGrid.tsx
@@ -0,0 +1,56 @@
+"use client";
+import { useState, useMemo } from 'react';
+import CarCard from '@/components/CarCard';
+
+const fuelTypes = ['ALL', 'GASOLINE', 'DIESEL', 'LPG', 'ELECTRIC'];
+const sortOptions = [
+ { value: 'recent', label: 'Most Recent' },
+ { value: 'name', label: 'Name (A-Z)' },
+ { value: 'year', label: 'Year (Newest)' },
+];
+
+export default function CarGrid({ cars }: { cars: any[] }) {
+ const [fuel, setFuel] = useState('ALL');
+ const [sort, setSort] = useState('recent');
+
+ const filtered = useMemo(() => {
+ let filtered = fuel === 'ALL' ? cars : cars.filter((c) => c.fuelType === fuel);
+ if (sort === 'name') filtered = [...filtered].sort((a, b) => a.name.localeCompare(b.name));
+ else if (sort === 'year') filtered = [...filtered].sort((a, b) => b.year - a.year);
+ else filtered = [...filtered].sort((a, b) => b.id.localeCompare(a.id)); // fallback: recent
+ return filtered;
+ }, [cars, fuel, sort]);
+
+ return (
+
+
+
+
+
+
+
+ {filtered.length ? (
+
+ {filtered.map((car) => (
+ -
+
+
+ ))}
+
+ ) : (
+
+
π
+
No cars match your filter.
+
+ )}
+
+ );
+}
diff --git a/src/components/ChartSkeleton.tsx b/src/components/ChartSkeleton.tsx
new file mode 100644
index 0000000..1f916f3
--- /dev/null
+++ b/src/components/ChartSkeleton.tsx
@@ -0,0 +1,7 @@
+export default function ChartSkeleton() {
+ return (
+
+ );
+}
diff --git a/src/components/ClientNavbar.tsx b/src/components/ClientNavbar.tsx
index d50fd41..5e908aa 100644
--- a/src/components/ClientNavbar.tsx
+++ b/src/components/ClientNavbar.tsx
@@ -3,6 +3,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { useSession } from "next-auth/react";
+import ThemeToggle from "./ThemeToggle";
export default function ClientNavbar() {
const { data: session } = useSession();
@@ -16,7 +17,8 @@ export default function ClientNavbar() {
return (
{children}
@@ -34,13 +36,14 @@ export default function ClientNavbar() {
Dashboard
{isLoggedIn &&
Add Car}
+
{isLoggedIn ? (
{userInitial}
@@ -72,7 +75,7 @@ export default function ClientNavbar() {
diff --git a/src/components/FillUpCard.tsx b/src/components/FillUpCard.tsx
index 0bb3ba0..16b4b15 100644
--- a/src/components/FillUpCard.tsx
+++ b/src/components/FillUpCard.tsx
@@ -1,10 +1,12 @@
-import { FillUp } from '@prisma/client';
-
-export default function FillUpCard({ fill }: { fill: FillUp }) {
+export default function FillUpCard({ fill }: { fill: any }) {
+ // If car.fuelType is ELECTRIC, show kWh instead of L
+ const isElectric = fill.car?.fuelType === 'ELECTRIC';
return (
-
-
{fill.mileage} km - {fill.liters} L
-
+
+
+ {fill.mileage} km - {fill.liters} {isElectric ? 'kWh' : 'L'}
+
+
{fill.cost} {fill.currency} β {new Date(fill.date).toLocaleString()}
diff --git a/src/components/FuelStatsChart.tsx b/src/components/FuelStatsChart.tsx
new file mode 100644
index 0000000..9ae8476
--- /dev/null
+++ b/src/components/FuelStatsChart.tsx
@@ -0,0 +1,80 @@
+"use client";
+import { LineChart, Line, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
+import InfoTooltip from "./InfoTooltip";
+import { Units } from './UnitsToggle';
+
+interface FillUp {
+ date: string;
+ mileage: number;
+ liters: number;
+ cost: number;
+ currency: string;
+}
+
+function kmToMiles(km: number) { return km * 0.621371; }
+function litersToGallons(l: number) { return l * 0.264172; }
+function lPer100kmToMpg(l100: number) { return l100 > 0 ? 235.215 / l100 : 0; }
+
+export default function FuelStatsChart({ fillUps, units = 'metric' }: { fillUps: (FillUp & { car?: { fuelType?: string } })[]; units?: Units }) {
+ if (!fillUps || fillUps.length < 2) return null;
+
+ // Get fuel type from first fill-up
+ const fuelType = fillUps[0]?.car?.fuelType;
+ const isElectric = fuelType === 'ELECTRIC';
+ // Always use metric for electric cars
+ const displayUnits = isElectric ? 'metric' : units;
+
+ // Prepare data: skip first fill (no consumption calc)
+ const chartData = fillUps.slice(1).map((f, i) => {
+ const prev = fillUps[i];
+ const distance = displayUnits === 'imperial' ? kmToMiles(f.mileage - prev.mileage) : f.mileage - prev.mileage;
+ const liters = displayUnits === 'imperial' ? litersToGallons(f.liters) : f.liters;
+ const consumption = isElectric
+ ? (liters / distance) * 100
+ : (displayUnits === 'imperial'
+ ? lPer100kmToMpg((f.liters / (f.mileage - prev.mileage)) * 100)
+ : (liters / distance) * 100);
+ return {
+ date: new Date(f.date).toLocaleDateString(),
+ consumption: Number.isFinite(consumption) ? Number(consumption.toFixed(2)) : 0,
+ cost: f.cost,
+ mileage: displayUnits === 'imperial' ? kmToMiles(f.mileage) : f.mileage,
+ };
+ });
+
+ // Chart title with info icon
+ const chartTitle = (
+
+ Fuel Consumption Over Time
+
+
+ );
+
+ return (
+
+
{chartTitle}
+
+
+
+
+
+ v}
+ label={{ value: isElectric ? 'kWh/100km' : (displayUnits === 'imperial' ? 'MPG' : 'L/100km'), angle: -90, position: 'insideLeft', offset: 10 }}
+ />
+
+
+
+
+
+ {/* Y-axis info icon for screen readers, visually above chart */}
+
Fuel consumption is measured in {isElectric ? 'kilowatt-hours per 100 kilometers (kWh/100km)' : (displayUnits === 'imperial' ? 'miles per gallon (MPG)' : 'liters per 100 kilometers (L/100km)')}.
+
+ );
+}
diff --git a/src/components/InfoTooltip.tsx b/src/components/InfoTooltip.tsx
new file mode 100644
index 0000000..0ca5c93
--- /dev/null
+++ b/src/components/InfoTooltip.tsx
@@ -0,0 +1,50 @@
+import { useState, useRef, useEffect } from 'react';
+
+interface InfoTooltipProps {
+ label: string;
+ description: string;
+}
+
+export default function InfoTooltip({ label, description }: InfoTooltipProps) {
+ const [open, setOpen] = useState(false);
+ const iconRef = useRef
(null);
+ const tooltipId = `tooltip-${label.replace(/\s+/g, '-')}`;
+
+ // Close tooltip on Escape key
+ useEffect(() => {
+ if (!open) return;
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') setOpen(false);
+ }
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [open]);
+
+ return (
+
+ setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ >
+
+
+ {open && (
+
+ {description}
+
+ )}
+
+ );
+}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 16133f7..243ec69 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -1,6 +1,6 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
-
+
{children}
);
diff --git a/src/components/RecentActivity.tsx b/src/components/RecentActivity.tsx
new file mode 100644
index 0000000..2decfe9
--- /dev/null
+++ b/src/components/RecentActivity.tsx
@@ -0,0 +1,58 @@
+"use client";
+import { useEffect, useState } from 'react';
+
+interface ActivityItem {
+ type: 'fillup' | 'mileage';
+ id: string;
+ car: { name: string; make: string; model: string };
+ mileage: number;
+ liters?: number;
+ cost?: number;
+ currency?: string;
+ date: string;
+}
+
+export default function RecentActivity() {
+ const [activity, setActivity] = useState
([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch('/api/activity')
+ .then((res) => res.json())
+ .then((data) => {
+ setActivity(data);
+ setLoading(false);
+ })
+ .catch(() => setLoading(false));
+ }, []);
+
+ if (loading) return β³Loading activity...
;
+ if (!activity.length) return (
+
+
π
+
No recent activity yet.
+
Start by logging a fill-up or mileage entry!
+
+ );
+
+ return (
+
+ {activity.map((item) => (
+ -
+ {item.type === 'fillup' ? 'β½' : 'π£οΈ'}
+
+
+ {item.type === 'fillup' ? 'Fill-Up' : 'Mileage'} for {item.car.name}
+
+
+ {item.type === 'fillup'
+ ? `${item.liters} L, ${item.mileage} km${item.cost ? `, ${item.cost} ${item.currency}` : ''}`
+ : `${item.mileage} km`}
+ {' '}on {new Date(item.date).toLocaleDateString()}
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/StatCard.tsx b/src/components/StatCard.tsx
index 2bf7e12..e32de61 100644
--- a/src/components/StatCard.tsx
+++ b/src/components/StatCard.tsx
@@ -1,16 +1,44 @@
-export default function StatCard({ label, value, icon, color }: { label: string; value: string; icon?: string; color?: 'primary' | 'secondary' | 'accent' }) {
+import { useState } from "react";
+import InfoTooltip from "./InfoTooltip";
+
+export default function StatCard({ label, value, icon, color, info }: { label: string; value: string; icon?: string; color?: 'primary' | 'secondary' | 'accent'; info?: string }) {
const valueClass =
label === 'Total Fuel Cost'
- ? 'text-gray-900'
+ ? 'text-[var(--foreground)]'
: color
? `text-[var(--${color})]`
: 'text-[var(--foreground)]';
+
+ // Determine card color scheme
+ let cardBg = 'var(--statcard-default-bg)';
+ let cardBorder = 'var(--statcard-default-border)';
+ if (color === 'primary') {
+ cardBg = 'var(--statcard-primary-bg)';
+ cardBorder = 'var(--statcard-primary-border)';
+ } else if (color === 'secondary') {
+ cardBg = 'var(--statcard-secondary-bg)';
+ cardBorder = 'var(--statcard-secondary-border)';
+ } else if (color === 'accent') {
+ cardBg = 'var(--statcard-accent-bg)';
+ cardBorder = 'var(--statcard-accent-border)';
+ }
+
return (
-
- {icon &&
{icon}}
-
-
{label}
-
{value}
+
+
+ {icon &&
{icon}}
+
+
+ {label}
+ {info && (
+
+ )}
+
+
{value}
+
);
diff --git a/src/components/StatsExportButton.tsx b/src/components/StatsExportButton.tsx
new file mode 100644
index 0000000..1aa865a
--- /dev/null
+++ b/src/components/StatsExportButton.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { Units } from './UnitsToggle';
+
+export default function StatsExportButton({ fillUps, units, isElectric: isElectricProp }: { fillUps: { car?: { fuelType?: string; name?: string }; mileage: number; liters: number; cost: number; currency: string; date: string | Date }[]; units: Units; isElectric?: boolean }) {
+ function kmToMiles(km: number) { return km * 0.621371; }
+ function litersToGallons(l: number) { return l * 0.264172; }
+
+ // Determine if electric from prop or fillUps
+ const isElectric = isElectricProp ?? (fillUps[0]?.car?.fuelType === 'ELECTRIC');
+
+ function handleExport() {
+ if (!fillUps?.length) return;
+ // Only export selected fields: car name, mileage, liters/gallons/kWh, cost, currency, date
+ const headers = [
+ "Car Name",
+ isElectric ? "Mileage (km)" : (units === 'imperial' ? "Mileage (mi)" : "Mileage (km)"),
+ isElectric ? "kWh" : (units === 'imperial' ? "Gallons" : "Liters"),
+ "Cost",
+ "Currency",
+ "Date"
+ ];
+ const rows = fillUps.map(f => [
+ f.car?.name || '',
+ isElectric ? f.mileage : (units === 'imperial' ? kmToMiles(f.mileage).toFixed(1) : f.mileage),
+ isElectric ? f.liters : (units === 'imperial' ? litersToGallons(f.liters).toFixed(2) : f.liters),
+ f.cost,
+ f.currency,
+ f.date instanceof Date ? f.date.toISOString() : f.date
+ ]);
+ const csv = [headers.join(","), ...rows.map(r => r.join(","))].join("\n");
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `car-stats-${isElectric ? 'electric' : units}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+ return (
+
+ Export CSV
+
+ );
+}
diff --git a/src/components/StatsPageClient.tsx b/src/components/StatsPageClient.tsx
new file mode 100644
index 0000000..fe3c742
--- /dev/null
+++ b/src/components/StatsPageClient.tsx
@@ -0,0 +1,236 @@
+"use client";
+import FuelStatsChart from '@/components/FuelStatsChart';
+import StatCard from '@/components/StatCard';
+import StatsTimeRangeFilter from '@/components/StatsTimeRangeFilter';
+import StatsExportButton from '@/components/StatsExportButton';
+import UnitsToggle, { Units } from '@/components/UnitsToggle';
+import Achievements, { Achievement } from '@/components/Achievements';
+import StatsSkeleton from '@/components/StatsSkeleton';
+import AchievementsSkeleton from '@/components/AchievementsSkeleton';
+import ChartSkeleton from '@/components/ChartSkeleton';
+import { useMemo, useState } from 'react';
+
+interface FillUp {
+ id: string;
+ mileage: number;
+ liters: number;
+ cost: number;
+ currency: string;
+ date: string | Date;
+ car?: { fuelType?: string };
+}
+
+export default function StatsPageClient({ fillUps, carId, error, loading = false }: { fillUps: FillUp[]; carId: string; error?: string; loading?: boolean }) {
+ const [range, setRange] = useState
(30);
+ const [units, setUnits] = useState('metric');
+ const now = useMemo(() => new Date(), []);
+ const filtered = useMemo(() => {
+ if (!range) return fillUps;
+ return fillUps.filter(f => {
+ const d = typeof f.date === 'string' ? new Date(f.date) : f.date;
+ return d >= new Date(now.getTime() - (range ?? 30) * 24 * 60 * 60 * 1000);
+ });
+ }, [fillUps, range, now]);
+
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ // Comparative insights logic
+ const last30 = filtered;
+ const prev30 = fillUps.filter(f => {
+ const d = typeof f.date === 'string' ? new Date(f.date) : f.date;
+ return d < new Date(now.getTime() - (range ?? 30) * 24 * 60 * 60 * 1000) && d >= new Date(now.getTime() - 2 * (range ?? 30) * 24 * 60 * 60 * 1000);
+ });
+ function avgCons(list: FillUp[]) {
+ if (list.length < 2) return null;
+ let totalDist = 0, totalLit = 0;
+ for (let i = 1; i < list.length; i++) {
+ totalDist += list[i].mileage - list[i - 1].mileage;
+ totalLit += list[i].liters;
+ }
+ return totalDist > 0 ? (totalLit / totalDist) * 100 : null;
+ }
+ const avgLast30 = avgCons(last30);
+ const avgPrev30 = avgCons(prev30);
+ let comparison = null;
+ if (avgLast30 && avgPrev30) {
+ const diff = avgLast30 - avgPrev30;
+ const percent = (diff / avgPrev30) * 100;
+ comparison = {
+ improved: diff < 0,
+ percent: Math.abs(percent).toFixed(1),
+ diff: diff.toFixed(2),
+ };
+ }
+ let insight = '';
+ if (comparison) {
+ if (comparison.improved) {
+ insight = `Great job! Your average fuel consumption improved by ${comparison.percent}% over the selected period.`;
+ } else if (Number(comparison.percent) > 0) {
+ insight = `Heads up! Your average fuel consumption increased by ${comparison.percent}% over the selected period.`;
+ }
+ }
+
+ // Stats calculations
+ if (filtered.length < 2) {
+ return (
+
+
+
π
+
No Stats Yet
+
Add at least 2 fill-ups to unlock your personalized fuel stats, insights, and achievements. Start tracking your carβs journey today!
+
β Add Your First Fill-Up
+
+
+ );
+ }
+
+ // Conversion helpers
+ function kmToMiles(km: number) { return km * 0.621371; }
+ function litersToGallons(l: number) { return l * 0.264172; }
+ function lPer100kmToMpg(l100: number) { return l100 > 0 ? 235.215 / l100 : 0; }
+
+ const first = filtered[0];
+ const last = filtered[filtered.length - 1];
+ const totalDistance = units === 'imperial' ? kmToMiles(last.mileage - first.mileage) : last.mileage - first.mileage;
+ const totalLiters = filtered.slice(1).reduce((sum, f) => sum + (units === 'imperial' ? litersToGallons(f.liters) : f.liters), 0);
+ const totalCost = filtered.slice(1).reduce((sum, f) => sum + f.cost, 0);
+ const avgConsumption = (() => {
+ const dist = last.mileage - first.mileage;
+ const lit = filtered.slice(1).reduce((sum, f) => sum + f.liters, 0);
+ if (units === 'imperial') {
+ return lPer100kmToMpg((lit / dist) * 100);
+ } else {
+ return (lit / dist) * 100;
+ }
+ })();
+ const costPerDist = totalCost / (units === 'imperial' ? kmToMiles(last.mileage - first.mileage) : last.mileage - first.mileage);
+ const chartFillUps = filtered.map(f => ({ ...f, date: typeof f.date === 'string' ? f.date : f.date.toISOString() }));
+
+ // Achievement logic
+ const numFillUps = filtered.length;
+ const totalKm = last.mileage - first.mileage;
+ const bestEfficiency = Math.min(...filtered.slice(1).map((f, i) => {
+ if (i === 0) return Infinity;
+ const dist = filtered[i].mileage - filtered[i - 1].mileage;
+ return dist > 0 ? (filtered[i].liters / dist) * 100 : Infinity;
+ }));
+ const achievements: Achievement[] = [
+ {
+ id: 'first-fill',
+ label: 'First Fill-Up',
+ description: 'Logged your first fill-up!',
+ icon: 'β½',
+ achieved: numFillUps >= 1,
+ },
+ {
+ id: 'ten-fills',
+ label: '10 Fill-Ups',
+ description: 'Logged 10 fill-ups!',
+ icon: 'π',
+ achieved: numFillUps >= 10,
+ },
+ {
+ id: '1000km',
+ label: '1,000 km',
+ description: 'Tracked 1,000 km!',
+ icon: 'π£οΈ',
+ achieved: totalKm >= 1000,
+ },
+ {
+ id: 'best-eff',
+ label: 'Best Efficiency',
+ description: 'Achieved <6 L/100km!',
+ icon: 'π±',
+ achieved: bestEfficiency < 6,
+ },
+ ];
+
+ // Get fuel type from first fill-up (all fill-ups for a car have the same type)
+ const fuelType = fillUps[0]?.car?.fuelType;
+ const isElectric = fuelType === 'ELECTRIC';
+ // If electric, always use metric units
+ const displayUnits = isElectric ? 'metric' : units;
+
+ return (
+
+
+
+ π Fuel Stats
+
+
+ {!isElectric && (
+
+ )}
+ {isElectric && (
+ Units: kWh/100km
+ )}
+
+
+
+
+
+ {comparison && (
+
+ {comparison.improved ? 'β¬οΈ' : 'β¬οΈ'}
+
+ Avg. consumption: {units === 'imperial' ? `${lPer100kmToMpg(avgLast30 ?? 0).toFixed(2)} MPG` : `${avgLast30?.toFixed(2)} L/100km`} ({comparison.improved ? '-' : '+'}{comparison.percent}%)
+ (Prev: {units === 'imperial' ? `${lPer100kmToMpg(avgPrev30 ?? 0).toFixed(2)} MPG` : `${avgPrev30?.toFixed(2)} L/100km`})
+
+
+ )}
+ {insight && (
+
+ π‘ {insight}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/StatsSkeleton.tsx b/src/components/StatsSkeleton.tsx
new file mode 100644
index 0000000..1bf33c0
--- /dev/null
+++ b/src/components/StatsSkeleton.tsx
@@ -0,0 +1,17 @@
+export default function StatsSkeleton() {
+ return (
+
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/StatsTimeRangeFilter.tsx b/src/components/StatsTimeRangeFilter.tsx
new file mode 100644
index 0000000..9da3679
--- /dev/null
+++ b/src/components/StatsTimeRangeFilter.tsx
@@ -0,0 +1,47 @@
+"use client";
+import { useState } from "react";
+
+const ranges = [
+ { label: "7d", value: 7, aria: "Show stats for last 7 days" },
+ { label: "30d", value: 30, aria: "Show stats for last 30 days" },
+ { label: "Year", value: 365, aria: "Show stats for last year" },
+ { label: "All", value: null, aria: "Show stats for all time" },
+];
+
+export default function StatsTimeRangeFilter({
+ value,
+ onChange,
+}: {
+ value: number | null;
+ onChange: (v: number | null) => void;
+}) {
+ return (
+
+
+ Sort by:
+
+
+ {ranges.map((r) => (
+ onChange(r.value)}
+ type="button"
+ aria-label={r.aria}
+ aria-pressed={value === r.value}
+ >
+ {r.label}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..ba11561
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,45 @@
+"use client";
+import { useEffect, useState } from 'react';
+
+export default function ThemeToggle() {
+ const [theme, setTheme] = useState('system');
+
+ useEffect(() => {
+ // On mount, check localStorage or system preference
+ const stored = localStorage.getItem('theme');
+ if (stored) setTheme(stored);
+ else if (window.matchMedia('(prefers-color-scheme: dark)').matches) setTheme('dark');
+ else setTheme('light');
+ }, []);
+
+ useEffect(() => {
+ if (theme === 'system') {
+ document.documentElement.classList.remove('dark');
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ document.documentElement.classList.add('dark');
+ }
+ } else if (theme === 'dark') {
+ document.documentElement.classList.add('dark');
+ } else {
+ document.documentElement.classList.remove('dark');
+ }
+ localStorage.setItem('theme', theme);
+ }, [theme]);
+
+ return (
+
+ setTheme('light')}
+ >π
+ setTheme('dark')}
+ >π
+
+ );
+}
diff --git a/src/components/UnitsToggle.tsx b/src/components/UnitsToggle.tsx
new file mode 100644
index 0000000..6cdbccd
--- /dev/null
+++ b/src/components/UnitsToggle.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+
+export type Units = "metric" | "imperial";
+
+export default function UnitsToggle({ units, setUnits, disabled = false }: { units: Units; setUnits: (u: Units) => void; disabled?: boolean }) {
+ return (
+
+
Fuel:
+
+ !disabled && setUnits("metric")}
+ disabled={disabled}
+ >
+ Metric (L/100km)
+
+ !disabled && setUnits("imperial")}
+ disabled={disabled}
+ >
+ Imperial (MPG)
+
+
+
+ );
+}