Update app improvment 2. Dashboard, stat, UI. All

This commit is contained in:
EdiFarcas
2025-07-09 01:48:50 +03:00
parent 1ca2ae8798
commit 27f7ede84a
34 changed files with 1635 additions and 200 deletions
+446 -4
View File
@@ -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",
+4 -1
View File
@@ -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",
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "FuelType" ADD VALUE 'ELECTRIC';
+1
View File
@@ -53,6 +53,7 @@ enum FuelType {
GASOLINE
DIESEL
LPG
ELECTRIC
}
enum Currency {
+53
View File
@@ -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));
}
+29
View File
@@ -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);
}
+3 -3
View File
@@ -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"
/>
<div className="relative w-full max-w-2xl">
<input
@@ -47,12 +47,12 @@ export default function LoginPage() {
value={password}
required
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-base text-gray-500 hover:text-[var(--primary)] focus:outline-none"
className="absolute right-4 top-1/2 -translate-y-1/2 text-base text-[var(--foreground)]/60 hover:text-[var(--primary)] focus:outline-none"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
+4 -4
View File
@@ -36,7 +36,7 @@ export default function RegisterPage() {
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(--secondary)] 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(--secondary)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50"
/>
<div className="relative w-full max-w-2xl">
<input
@@ -45,19 +45,19 @@ export default function RegisterPage() {
value={password}
required
onChange={(e) => 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(--secondary)] 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(--secondary)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 pr-16"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-base text-gray-500 hover:text-[var(--secondary)] focus:outline-none"
className="absolute right-4 top-1/2 -translate-y-1/2 text-base text-[var(--foreground)]/60 hover:text-[var(--secondary)] focus:outline-none"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<button type="submit" className="w-full max-w-2xl bg-[var(--secondary)] text-white py-4 text-lg rounded-lg font-semibold shadow hover:bg-green-700 hover:scale-105 hover:shadow-lg transition-all duration-200">
<button type="submit" className="w-full max-w-2xl bg-[var(--secondary)] text-white py-4 text-lg rounded-lg font-semibold shadow hover:bg-[var(--secondary)]/80 hover:scale-105 hover:shadow-lg transition-all duration-200">
Register
</button>
{error && <p className="text-red-500 text-center w-full">{error}</p>}
@@ -18,6 +18,7 @@ const currencies = ['EUR', 'USD', 'RON', 'GBP'];
export default function FillUpsPage() {
const { carId } = useParams();
const [fillups, setFillups] = useState<FillUp[]>([]);
const [carFuelType, setCarFuelType] = useState<string | undefined>(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<HTMLInputElement | HTMLSelectElement>) => {
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 (
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow">
<h1 className="text-2xl font-bold text-[var(--primary)]">Fuel Fill-Ups</h1>
<div className="text-center text-[var(--foreground)]/60 py-12">Loading car details...</div>
</main>
);
}
return (
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow">
<h1 className="text-2xl font-bold text-[var(--primary)]">Fuel Fill-Ups</h1>
@@ -76,19 +94,23 @@ export default function FillUpsPage() {
<span className="text-xs text-gray-500">Enter the odometer reading at fill-up</span>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="liters" className="font-medium text-[var(--primary)]">Liters</label>
<label htmlFor="liters" className="font-medium text-[var(--primary)]">
{carFuelType === 'ELECTRIC' ? 'Kilowatt-hours' : 'Liters'}
</label>
<input
id="liters"
name="liters"
type="number"
step="0.01"
placeholder="e.g. 45.5"
placeholder={carFuelType === 'ELECTRIC' ? 'e.g. 45.5' : 'e.g. 45.5'}
value={form.liters}
onChange={handleChange}
required
className="border border-[var(--border)] px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--primary)] bg-[var(--muted)] text-[var(--foreground)]"
/>
<span className="text-xs text-gray-500">How many liters did you fill?</span>
<span className="text-xs text-gray-500">
{carFuelType === 'ELECTRIC' ? 'How many kilowatt-hours did you charge?' : 'How many liters did you fill?'}
</span>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="cost" className="font-medium text-[var(--primary)]">Total Cost</label>
@@ -127,7 +149,7 @@ export default function FillUpsPage() {
<ul className="space-y-3">
{fillups.map((fill) => (
<FillUpCard key={fill.id} fill={fill} /> // ✅
<FillUpCard key={fill.id} fill={{ ...fill, car: { fuelType: carFuelType } }} />
))}
</ul>
</main>
+66 -24
View File
@@ -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 <div className="p-8 text-center text-red-500 text-lg font-semibold">Car not found or unauthorized access.</div>;
}
// Fuel type badge color and icon
const fuelMeta: Record<string, { color: string; icon: React.ReactNode }> = {
GASOLINE: { color: 'bg-blue-100 text-blue-700', icon: <FaGasPump className="inline mr-1" /> },
DIESEL: { color: 'bg-yellow-100 text-yellow-800', icon: <FaGasPump className="inline mr-1" /> },
LPG: { color: 'bg-pink-100 text-pink-700', icon: <FaGasPump className="inline mr-1" /> },
ELECTRIC: { color: 'bg-green-100 text-green-700', icon: <FaBolt className="inline mr-1 text-green-500" /> },
};
return (
<main className="max-w-3xl mx-auto p-8 space-y-6 bg-[var(--muted)] rounded-xl shadow">
<h1 className="text-3xl font-bold text-[var(--primary)]">{car.name}</h1>
<p className="text-lg text-gray-700">{car.make} {car.model} ({car.year})</p>
<p className="text-gray-600">Fuel Type: <span className="font-semibold text-[var(--secondary)]">{car.fuelType}</span></p>
<div className="flex flex-wrap gap-4 mt-8">
<Link
href={`/dashboard/cars/${car.id}/mileage`}
className="bg-[var(--primary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-blue-700 transition"
>
Add Mileage
</Link>
<Link
href={`/dashboard/cars/${car.id}/fillups`}
className="bg-[var(--secondary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-green-700 transition"
>
Add Fill-Up
</Link>
<Link
href={`/dashboard/cars/${car.id}/stats`}
className="bg-[var(--accent)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-purple-800 transition"
>
📊 View Stats
</Link>
<main className="max-w-3xl mx-auto p-0 md:p-8 mt-8">
<div className="rounded-3xl shadow-2xl bg-[var(--muted)] p-0 md:p-10 flex flex-col gap-10 border border-[var(--border)]">
{/* Car Icon & Header */}
<div className="flex flex-col items-center gap-3 p-8 rounded-t-3xl bg-[var(--muted)] border-b border-[var(--border)] shadow-sm">
<span className="text-6xl text-[var(--primary)] drop-shadow-lg mb-2"><FaCarSide /></span>
<h1 className="text-4xl font-extrabold text-[var(--primary)] leading-tight tracking-tight flex items-center gap-3">
{car.name}
<span className="inline-block px-3 py-1 rounded-full text-base font-bold bg-gray-200 text-gray-700 ml-2">{car.year}</span>
</h1>
<span className={`mt-2 px-4 py-1 rounded-full text-sm font-bold flex items-center gap-1 ${fuelMeta[car.fuelType]?.color || 'bg-gray-100 text-gray-700'}`}>{fuelMeta[car.fuelType]?.icon}{car.fuelType}</span>
</div>
{/* Car Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 px-8">
<div className="flex items-center gap-3">
<FaIndustry className="text-2xl text-gray-400" />
<span className="font-semibold text-[var(--foreground)]">Make:</span>
<span className="ml-1 text-gray-400 font-mono text-lg">{car.make}</span>
</div>
<div className="flex items-center gap-3">
<FaCarSide className="text-2xl text-gray-400" />
<span className="font-semibold text-[var(--foreground)]">Model:</span>
<span className="ml-1 text-gray-400 font-mono text-lg">{car.model}</span>
</div>
<div className="flex items-center gap-3">
<FaCalendarAlt className="text-2xl text-gray-400" />
<span className="font-semibold text-[var(--foreground)]">Year:</span>
<span className="ml-1 text-gray-400 font-mono text-lg">{car.year}</span>
</div>
<div className="flex items-center gap-3">
{fuelMeta[car.fuelType]?.icon}
<span className="font-semibold text-[var(--foreground)]">Fuel:</span>
<span className={`ml-1 px-2 py-1 rounded-full text-xs font-bold ${fuelMeta[car.fuelType]?.color || 'bg-gray-100 text-gray-700'}`}>{car.fuelType}</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-4 justify-center px-8 pb-8">
<Link
href={`/dashboard/cars/${car.id}/mileage`}
className="flex items-center gap-2 bg-[var(--primary)] text-white px-6 py-3 rounded-lg font-bold shadow-lg hover:bg-blue-700 hover:scale-[1.03] transition-all"
>
Add Mileage
</Link>
<Link
href={`/dashboard/cars/${car.id}/fillups`}
className="flex items-center gap-2 bg-[var(--secondary)] text-white px-6 py-3 rounded-lg font-bold shadow-lg hover:bg-green-700 hover:scale-[1.03] transition-all"
>
Add Fill-Up
</Link>
<Link
href={`/dashboard/cars/${car.id}/stats`}
className="flex items-center gap-2 bg-[var(--accent)] text-white px-6 py-3 rounded-lg font-bold shadow-lg hover:bg-purple-800 hover:scale-[1.03] transition-all"
>
📊 View Stats
</Link>
</div>
</div>
</main>
);
+21 -53
View File
@@ -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 <div className="p-6">Car not found or unauthorized.</div>;
const fillUps = car.fillUps;
if (fillUps.length < 2) {
return (
<main className="max-w-xl mx-auto p-8 flex flex-col items-center justify-center bg-[var(--muted)] rounded-xl shadow space-y-4">
<div className="flex flex-col items-center gap-2">
<span className="text-5xl text-[var(--primary)]"></span>
<h1 className="text-2xl font-bold text-[var(--primary)]">Not Enough Data</h1>
<p className="text-gray-700 text-center">Add at least 2 fill-ups to see your fuel statistics and insights.</p>
<a href={`/dashboard/cars/${params.carId}/fillups`} className="mt-4 inline-block bg-[var(--secondary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-green-700 transition"> Add Fill-Up</a>
</div>
</main>
);
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 (
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow">
<h1 className="text-3xl font-bold text-[var(--primary)] flex items-center gap-2">
<span role="img" aria-label="stats">📊</span> Fuel Stats
</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<StatCard label="Total Distance" value={`${totalDistance.toFixed(0)} km`} icon="🛣️" color="primary" />
<StatCard label="Total Fuel Used" value={`${totalLiters.toFixed(2)} L`} icon="⛽" color="secondary" />
<StatCard label="Total Fuel Cost" value={`${totalCost.toFixed(2)} ${fillUps[0].currency}`} icon="💸" color="accent" />
<StatCard label="Average Consumption" value={`${avgConsumption.toFixed(2)} L / 100km`} icon="📏" color="primary" />
<StatCard label="Average Cost / km" value={`${costPerKm.toFixed(2)} ${fillUps[0].currency}`} icon="💰" color="secondary" />
</div>
</main>
);
return <StatsPageClient fillUps={fillUpsWithCarName} carId={params.carId} error={error} />;
}
+80 -49
View File
@@ -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 (
<main className="max-w-lg mx-auto p-8 bg-[var(--muted)] rounded-xl shadow space-y-6">
<h1 className="text-2xl font-bold text-[var(--primary)]">Add New Car</h1>
<form onSubmit={handleSubmit} className="space-y-5">
<input
name="name"
value={form.name}
onChange={handleChange}
required
placeholder="Car Name"
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
/>
<input
name="make"
value={form.make}
onChange={handleChange}
required
placeholder="Make (e.g. BMW)"
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
/>
<input
name="model"
value={form.model}
onChange={handleChange}
required
placeholder="Model (e.g. 320i)"
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
/>
<input
name="year"
type="number"
value={form.year}
onChange={handleChange}
required
placeholder="Year"
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
/>
<select
name="fuelType"
value={form.fuelType}
onChange={handleChange}
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
<main className="max-w-lg mx-auto mt-12 p-8 bg-[var(--muted)] border border-[var(--border)] rounded-2xl shadow-xl space-y-8 flex flex-col items-center">
<div className="flex flex-col items-center gap-2">
<span className="text-4xl text-[var(--primary)]">🚗</span>
<h1 className="text-3xl font-extrabold text-[var(--primary)] tracking-tight">Add New Car</h1>
<p className="text-[var(--foreground)]/70 text-center text-base">Fill in your car details to start tracking fuel and mileage.</p>
</div>
<form onSubmit={handleSubmit} className="w-full space-y-5">
<div>
<label htmlFor="name" className="block mb-1 font-semibold text-[var(--foreground)]">Car Name</label>
<input
id="name"
name="name"
value={form.name}
onChange={handleChange}
required
placeholder="e.g. My BMW"
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
/>
</div>
<div>
<label htmlFor="make" className="block mb-1 font-semibold text-[var(--foreground)]">Make</label>
<input
id="make"
name="make"
value={form.make}
onChange={handleChange}
required
placeholder="e.g. BMW"
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
/>
</div>
<div>
<label htmlFor="model" className="block mb-1 font-semibold text-[var(--foreground)]">Model</label>
<input
id="model"
name="model"
value={form.model}
onChange={handleChange}
required
placeholder="e.g. 320i"
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
/>
</div>
<div>
<label htmlFor="year" className="block mb-1 font-semibold text-[var(--foreground)]">Year</label>
<input
id="year"
name="year"
value={form.year}
onChange={handleChange}
required
placeholder="e.g. 2020"
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
/>
</div>
<div>
<label htmlFor="fuelType" className="block mb-1 font-semibold text-[var(--foreground)]">Fuel Type</label>
<select
id="fuelType"
name="fuelType"
value={form.fuelType}
onChange={handleChange}
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] transition"
>
{fuelTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<button
type="submit"
className="w-full bg-[var(--primary)] text-white py-3 rounded-lg font-bold shadow-lg hover:bg-[var(--primary)]/80 hover:scale-[1.03] hover:shadow-xl transition-all focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
>
{fuelTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<button type="submit" className="w-full bg-[var(--primary)] text-white py-3 rounded-lg font-semibold shadow hover:bg-blue-700 transition">
Save Car
</button>
{error && <p className="text-red-500 text-center">{error}</p>}
{error && <p className="text-red-500 text-center font-semibold">{error}</p>}
</form>
</main>
);
+41 -18
View File
@@ -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 (
<main className="flex flex-col gap-8 max-w-5xl mx-auto p-6">
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<h1 className="text-3xl font-bold text-[var(--primary)]">Your Cars</h1>
<Link href="/dashboard/cars/new" className="bg-[var(--primary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-blue-700 transition">
<main className="max-w-4xl mx-auto p-6 space-y-6">
{/* Personalized Greeting */}
<div className="flex items-center gap-4 bg-[var(--muted)] rounded-xl p-6 mb-4 border border-[var(--border)] shadow">
<span className="w-12 h-12 flex items-center justify-center rounded-full bg-[var(--primary)] text-white font-bold text-2xl border-2 border-[var(--border)]">
{session.user?.name?.[0]?.toUpperCase() || session.user?.email?.[0]?.toUpperCase() || 'U'}
</span>
<div>
<h2 className="text-xl font-bold text-[var(--primary)]">Welcome back{session.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}!</h2>
<p className="text-[var(--foreground)]/60 text-sm">Ready to track your next fill-up?</p>
</div>
</div>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Your Cars</h1>
<Link href="/dashboard/cars/new" className="bg-[var(--primary)] text-white px-4 py-2 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]">
+ Add Car
</Link>
</div>
{user?.cars.length ? (
<ul className="grid gap-6 sm:grid-cols-2 md:grid-cols-3">
{user.cars.map((car) => (
<CarCard key={car.id} car={car} />
))}
</ul>
{cars.length ? (
<CarGrid cars={cars} />
) : (
<div className="text-center text-gray-500 py-12">
<p className="mb-4">No cars added yet.</p>
<Link href="/dashboard/cars/new" className="text-[var(--primary)] underline hover:text-blue-700">Add your first car</Link>
<div className="flex flex-col items-center gap-2 py-12 text-[var(--foreground)]/40" role="status" aria-live="polite">
<span className="text-5xl" aria-hidden="true">🚗</span>
<div className="font-semibold">No cars added yet.</div>
<div className="text-sm">Add your first car to get started!</div>
<Link href="/dashboard/cars/new" className="mt-4 bg-[var(--primary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-[var(--primary)]/80 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]">
+ Add Car
</Link>
</div>
)}
{/* Recent Activity Section */}
<section className="mt-10">
<h2 className="text-xl font-bold text-[var(--primary)] mb-4">Recent Activity</h2>
<RecentActivity />
</section>
</main>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+21 -14
View File
@@ -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 {
+16
View File
@@ -24,8 +24,24 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// This script will set the correct theme class on <html> 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 (
<html lang="en">
<head>
<script dangerouslySetInnerHTML={{ __html: setThemeScript }} />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[var(--background)] text-[var(--foreground)]`}
>
+30
View File
@@ -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 (
<div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-6 w-full max-w-3xl mx-auto"
aria-label="Achievements"
>
{achievements.map(a => (
<div
key={a.id}
className={`flex flex-col items-center p-2 rounded-lg shadow border transition-all duration-200 w-full min-w-0 min-h-20 ${a.achieved ? 'bg-[var(--background)] border-[var(--primary)]' : 'bg-[var(--muted)] border-[var(--border)] opacity-60'}`}
aria-label={a.achieved ? `${a.label} unlocked` : `${a.label} locked`}
>
<span className={`text-xl mb-0.5 ${a.achieved ? 'text-[var(--primary)]' : 'text-[var(--foreground)]/40'}`}>{a.icon}</span>
<span className="font-bold text-xs text-center mb-0.5 break-words leading-tight">{a.label}</span>
<span className="text-[10px] text-center text-[var(--foreground)]/70 break-words leading-tight">{a.description}</span>
</div>
))}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export default function AchievementsSkeleton() {
return (
<div className="flex flex-wrap gap-3 mb-6 animate-pulse">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex flex-col items-center p-2 rounded-lg shadow border w-32 h-20 bg-[var(--muted)] border-[var(--border)] opacity-60">
<div className="w-8 h-8 rounded-full bg-[var(--border)] mb-1" />
<div className="h-3 w-3/4 bg-[var(--border)] rounded mb-0.5" />
<div className="h-2 w-2/3 bg-[var(--border)] rounded" />
</div>
))}
</div>
);
}
+38 -7
View File
@@ -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 (
<Link href={`/dashboard/cars/${car.id}`} className="block group">
<div className="border border-[var(--border)] rounded-xl p-6 shadow-sm bg-white group-hover:shadow-lg transition flex flex-col gap-2 h-full">
<h2 className="text-xl font-bold text-[var(--primary)] group-hover:underline">{car.name}</h2>
<p className="text-gray-700">{car.make} {car.model} <span className="text-gray-400">({car.year})</span></p>
<p className="text-sm text-[var(--secondary)] mt-1 font-semibold">Fuel Type: {car.fuelType}</p>
<Link href={`/dashboard/cars/${car.id}`}>
<div className="border border-[var(--border)] rounded-xl p-4 shadow-sm hover:shadow-md transition bg-[var(--muted)]">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-[var(--primary)]">{car.name}</h2>
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-bold ${getFuelBadgeColor(car.fuelType)}`}>{car.fuelType}</span>
</div>
<p className="text-[var(--foreground)]/80">{car.make} {car.model} ({car.year})</p>
<div className="mt-3 text-sm text-[var(--foreground)]/80">
{lastFill ? (
<div>Last Fill-Up: <span className="font-semibold">{new Date(lastFill.date).toLocaleDateString()}</span> <span className="ml-2">({lastFill.mileage} km)</span></div>
) : (
<div className="opacity-60">Last Fill-Up: Not enough data</div>
)}
{avgMileage !== null ? (
<div>Avg. Mileage: <span className="font-semibold">{avgMileage} km</span></div>
) : (
<div className="opacity-60">Avg. Mileage: Not enough data</div>
)}
</div>
</div>
</Link>
);
+56
View File
@@ -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 (
<div>
<div className="flex flex-wrap gap-4 mb-4 items-center">
<label htmlFor="fuel-filter" className="font-medium text-sm">Fuel:</label>
<select id="fuel-filter" value={fuel} onChange={e => setFuel(e.target.value)} className="border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">
{fuelTypes.map((type) => (
<option key={type} value={type}>{type.charAt(0) + type.slice(1).toLowerCase()}</option>
))}
</select>
<label htmlFor="sort-cars" className="font-medium text-sm ml-4">Sort by:</label>
<select id="sort-cars" value={sort} onChange={e => setSort(e.target.value)} className="border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{filtered.length ? (
<ul className="grid gap-4 sm:grid-cols-2 md:grid-cols-3" role="list" aria-label="Your cars">
{filtered.map((car) => (
<li key={car.id} className="focus-within:ring-2 focus-within:ring-[var(--primary)]" tabIndex={-1}>
<CarCard car={car} />
</li>
))}
</ul>
) : (
<div className="flex flex-col items-center gap-2 py-12 text-gray-400" role="status" aria-live="polite">
<span className="text-5xl" aria-hidden="true">🚗</span>
<div className="font-semibold">No cars match your filter.</div>
</div>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function ChartSkeleton() {
return (
<div className="w-full h-72 bg-[var(--muted)] rounded-xl p-4 shadow border border-[var(--border)] flex items-center justify-center animate-pulse mb-6">
<div className="w-3/4 h-2/3 bg-[var(--border)] rounded" />
</div>
);
}
+8 -5
View File
@@ -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 (
<Link
href={href}
className={`px-4 py-2 rounded-lg font-semibold transition hover:bg-[var(--primary)] hover:text-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)] ${isActive ? "underline text-[var(--primary)]" : "text-[var(--foreground)]"}`}
className={`px-4 py-2 rounded-lg font-semibold transition border border-[var(--primary)] shadow-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]
${isActive ? "bg-[var(--primary)] text-white" : "bg-[var(--muted)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white"}`}
aria-current={isActive ? "page" : undefined}
>
{children}
@@ -34,13 +36,14 @@ export default function ClientNavbar() {
<div className="hidden md:flex gap-4 items-center">
<NavLink href="/dashboard">Dashboard</NavLink>
{isLoggedIn && <NavLink href="/dashboard/cars/new">Add Car</NavLink>}
<ThemeToggle />
{isLoggedIn ? (
<div className="flex items-center gap-2 ml-4">
<span className="w-9 h-9 rounded-full bg-[var(--primary)] text-white flex items-center justify-center font-bold text-lg border-2 border-[var(--border)] shadow" title={session.user?.name || session.user?.email || undefined}>{userInitial}</span>
<form action="/api/auth/signout" method="post">
<button
type="submit"
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-semibold shadow hover:bg-purple-800 transition focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-semibold shadow hover:bg-[var(--accent)]/80 transition focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
Log Out
</button>
@@ -49,7 +52,7 @@ export default function ClientNavbar() {
) : (
<>
<Link href="/auth/login" className="px-4 py-2 rounded-lg border border-[var(--primary)] text-[var(--primary)] font-semibold hover:bg-[var(--primary)] hover:text-white transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Login</Link>
<Link href="/auth/register" className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-semibold shadow hover:bg-blue-700 transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Register</Link>
<Link href="/auth/register" className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-semibold shadow hover:bg-[var(--primary)]/80 transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Register</Link>
</>
)}
</div>
@@ -72,7 +75,7 @@ export default function ClientNavbar() {
<form action="/api/auth/signout" method="post" className="w-full">
<button
type="submit"
className="w-full px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-semibold shadow hover:bg-purple-800 transition mt-2 focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
className="w-full px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-semibold shadow hover:bg-[var(--accent)]/80 transition mt-2 focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
Log Out
</button>
@@ -81,7 +84,7 @@ export default function ClientNavbar() {
) : (
<>
<Link href="/auth/login" className="w-full px-4 py-2 rounded-lg border border-[var(--primary)] text-[var(--primary)] font-semibold hover:bg-[var(--primary)] hover:text-white transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Login</Link>
<Link href="/auth/register" className="w-full px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-semibold shadow hover:bg-blue-700 transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Register</Link>
<Link href="/auth/register" className="w-full px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-semibold shadow hover:bg-[var(--primary)]/80 transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]">Register</Link>
</>
)}
</div>
+8 -6
View File
@@ -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 (
<div className="border rounded p-3 bg-white shadow-sm">
<div className="font-semibold text-black">{fill.mileage} km - {fill.liters} L</div>
<div className="text-sm text-gray-500">
<div className="border border-[var(--border)] rounded p-3 bg-[var(--background)] shadow-sm">
<div className="font-semibold text-[var(--foreground)]">
{fill.mileage} km - {fill.liters} {isElectric ? 'kWh' : 'L'}
</div>
<div className="text-sm text-[var(--foreground)]/60">
{fill.cost} {fill.currency} {new Date(fill.date).toLocaleString()}
</div>
</div>
+80
View File
@@ -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 = (
<span className="flex items-center gap-1">
Fuel Consumption Over Time
<InfoTooltip
label="Chart info"
description={`This chart shows your car's fuel consumption trend over time, calculated from your fill-up records. Units: ${isElectric ? 'kWh/100km (lower is better)' : (displayUnits === 'imperial' ? 'MPG (higher is better)' : 'L/100km (lower is better)')}.`}
/>
</span>
);
return (
<div className="w-full h-72 bg-[var(--muted)] rounded-xl p-4 shadow border border-[var(--border)] flex flex-col">
<h2 className="text-lg font-bold mb-2 text-[var(--primary)] flex items-center justify-center">{chartTitle}</h2>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 10, right: 20, left: 30, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" stroke="var(--foreground)" fontSize={12} />
<YAxis
stroke="var(--foreground)"
fontSize={12}
domain={[0, 'auto']}
tickFormatter={v => v}
label={{ value: isElectric ? 'kWh/100km' : (displayUnits === 'imperial' ? 'MPG' : 'L/100km'), angle: -90, position: 'insideLeft', offset: 10 }}
/>
<RechartsTooltip contentStyle={{ background: 'var(--background)', color: 'var(--foreground)', border: '1px solid var(--border)' }} />
<Line type="monotone" dataKey="consumption" stroke="#2563eb" strokeWidth={2} dot={{ r: 4, fill: '#2563eb' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
{/* Y-axis info icon for screen readers, visually above chart */}
<div className="sr-only" aria-live="polite">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)')}.</div>
</div>
);
}
+50
View File
@@ -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<HTMLButtonElement>(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 (
<span className="relative inline-block align-middle">
<button
ref={iconRef}
type="button"
aria-label={label}
aria-describedby={open ? tooltipId : undefined}
tabIndex={0}
className="ml-1 text-[var(--primary)] hover:text-[var(--accent)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] rounded-full"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none"/><text x="10" y="15" textAnchor="middle" fontSize="12" fill="currentColor" fontFamily="Arial" fontWeight="bold">i</text></svg>
</button>
{open && (
<div
id={tooltipId}
role="tooltip"
className="absolute z-50 left-1/2 -translate-x-1/2 mt-2 w-56 bg-[var(--background)] text-[var(--foreground)] border border-[var(--border)] rounded-lg shadow-lg p-3 text-sm"
>
{description}
</div>
)}
</span>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<main className="max-w-4xl mx-auto p-6">{children}</main>
</div>
);
+58
View File
@@ -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<ActivityItem[]>([]);
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 <div className="text-[var(--foreground)]/40 flex flex-col items-center gap-2 py-8" role="status" aria-live="polite"><span className="text-4xl" aria-hidden="true"></span>Loading activity...</div>;
if (!activity.length) return (
<div className="flex flex-col items-center gap-2 py-8 text-[var(--foreground)]/40" role="status" aria-live="polite">
<span className="text-4xl" aria-hidden="true">📋</span>
<div className="font-semibold">No recent activity yet.</div>
<div className="text-sm">Start by logging a fill-up or mileage entry!</div>
</div>
);
return (
<ul className="space-y-3" role="list" aria-label="Recent activity">
{activity.map((item) => (
<li key={item.type + item.id} className="flex items-center gap-3 bg-[var(--muted)] rounded-lg p-4 border border-[var(--border)] shadow-sm focus-within:ring-2 focus-within:ring-[var(--primary)]" tabIndex={0} aria-label={`${item.type === 'fillup' ? 'Fill-Up' : 'Mileage'} for ${item.car.name}`}>
<span className={`text-2xl ${item.type === 'fillup' ? 'text-[var(--secondary)]' : 'text-[var(--primary)]'}`} aria-hidden="true">{item.type === 'fillup' ? '⛽' : '🛣️'}</span>
<div className="flex-1">
<div className="font-semibold text-[var(--foreground)]">
{item.type === 'fillup' ? 'Fill-Up' : 'Mileage'} for <span className="text-[var(--primary)]">{item.car.name}</span>
</div>
<div className="text-sm text-[var(--foreground)]/60">
{item.type === 'fillup'
? `${item.liters} L, ${item.mileage} km${item.cost ? `, ${item.cost} ${item.currency}` : ''}`
: `${item.mileage} km`}
{' '}on {new Date(item.date).toLocaleDateString()}
</div>
</div>
</li>
))}
</ul>
);
}
+35 -7
View File
@@ -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 (
<div className="border rounded-xl p-4 shadow-sm bg-white flex items-center gap-4">
{icon && <span className={`text-2xl ${color ? `text-[var(--${color})]` : ''}`}>{icon}</span>}
<div>
<p className="text-sm text-gray-500">{label}</p>
<p className={`text-2xl font-semibold ${valueClass}`}>{value}</p>
<div
className="rounded-xl p-4 shadow-sm flex flex-col justify-center w-full"
style={{ background: cardBg, border: `1.5px solid ${cardBorder}` }}
>
<div className="flex flex-row items-center justify-center w-full gap-4">
{icon && <span className={`text-2xl ${color ? `text-[var(--${color})]` : ''}`}>{icon}</span>}
<div className="flex flex-col items-center w-full">
<p className="text-sm text-[var(--foreground)]/60 flex items-center gap-1 text-center w-full justify-center">
{label}
{info && (
<InfoTooltip label={`Info: ${label}`} description={info} />
)}
</p>
<p className={`text-2xl font-semibold ${valueClass} text-center w-full`}>{value}</p>
</div>
</div>
</div>
);
+50
View File
@@ -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 (
<button
className="px-4 py-2 rounded-lg border border-[var(--primary)] font-semibold shadow-sm transition bg-[var(--muted)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)] mb-4 ml-2"
onClick={handleExport}
type="button"
aria-label="Export stats as CSV"
>
Export CSV
</button>
);
}
+236
View File
@@ -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<number | null>(30);
const [units, setUnits] = useState<Units>('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 (
<main className="max-w-5xl mx-auto p-4 sm:p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow m-2 sm:m-4 mb-8 px-2 sm:px-8 lg:px-16">
<div className="flex flex-col items-center gap-4">
<span className="text-7xl mb-2 opacity-80" role="img" aria-label="Error"></span>
<h1 className="text-2xl font-bold text-[var(--primary)]">Oops, Something Went Wrong</h1>
<p className="text-[var(--foreground)]/80 text-center max-w-xs">{error}</p>
<a href={`/dashboard/cars/${carId}/fillups`} className="mt-4 inline-block bg-[var(--primary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-[var(--secondary)] transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"> Add Your First Fill-Up</a>
</div>
</main>
);
}
if (loading) {
return (
<main className="max-w-5xl mx-auto p-4 sm:p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow m-2 sm:m-4 mb-8 px-2 sm:px-8 lg:px-16">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<div className="h-10 w-40 bg-[var(--border)] rounded-full animate-pulse" />
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<div className="h-10 w-32 bg-[var(--border)] rounded-full animate-pulse" />
<div className="h-10 w-32 bg-[var(--border)] rounded-full animate-pulse" />
</div>
<ChartSkeleton />
<StatsSkeleton />
<AchievementsSkeleton />
</main>
);
}
// 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 (
<main className="flex flex-col items-center justify-center py-16 px-2 min-h-[60vh]">
<div className="w-full max-w-md mx-auto bg-[var(--muted)] rounded-xl shadow p-8 flex flex-col items-center">
<span className="text-7xl mb-2 opacity-80" role="img" aria-label="No stats">📉</span>
<h1 className="text-2xl font-bold text-[var(--primary)] text-center">No Stats Yet</h1>
<p className="text-[var(--foreground)]/80 text-center max-w-xs mb-4">Add at least 2 fill-ups to unlock your personalized fuel stats, insights, and achievements. Start tracking your cars journey today!</p>
<a href={`/dashboard/cars/${carId}/fillups`} className="mt-4 inline-block bg-[var(--primary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-[var(--secondary)] transition focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"> Add Your First Fill-Up</a>
</div>
</main>
);
}
// 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 (
<main className="flex flex-col items-center justify-center py-8 px-2">
<div className="w-full max-w-2xl mx-auto p-4 sm:p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow m-2 sm:m-4 mb-8 px-2 sm:px-8 lg:px-16">
<h1 className="text-3xl sm:text-4xl font-extrabold text-[var(--primary)] flex items-center gap-3 m-2 sm:m-4 mt-0 justify-center">
<span role="img" aria-label="stats">📊</span> Fuel Stats
</h1>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 w-full justify-center">
{!isElectric && (
<UnitsToggle units={units} setUnits={setUnits} disabled={isElectric} />
)}
{isElectric && (
<span className="text-xs font-semibold text-[var(--foreground)]/70 mb-1 sm:mb-0 sm:mr-2">Units: kWh/100km</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 mb-2 w-full justify-center">
<div className="flex items-center gap-2 w-auto">
<StatsTimeRangeFilter value={range} onChange={setRange} />
</div>
<div className="flex items-end h-full">
<StatsExportButton fillUps={filtered} units={displayUnits} isElectric={isElectric} />
</div>
</div>
<div className="w-full overflow-x-auto flex justify-center">
<FuelStatsChart fillUps={chartFillUps} units={displayUnits} />
</div>
{comparison && (
<div className={`flex items-center gap-2 p-4 rounded-lg mb-2 font-medium ${comparison.improved ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'} w-full justify-center mx-auto`}>
{comparison.improved ? '⬇️' : '⬆️'}
<span>
Avg. consumption: <b>{units === 'imperial' ? `${lPer100kmToMpg(avgLast30 ?? 0).toFixed(2)} MPG` : `${avgLast30?.toFixed(2)} L/100km`}</b> ({comparison.improved ? '-' : '+'}{comparison.percent}%)
<span className="ml-2 text-xs text-[var(--foreground)]/60">(Prev: {units === 'imperial' ? `${lPer100kmToMpg(avgPrev30 ?? 0).toFixed(2)} MPG` : `${avgPrev30?.toFixed(2)} L/100km`})</span>
</span>
</div>
)}
{insight && (
<div className="mb-4 text-[var(--foreground)]/80 text-sm flex items-center gap-2 w-full justify-center mx-auto">
<span role="img" aria-label="tip">💡</span> {insight}
</div>
)}
<div className="w-full flex justify-center">
<div className="bg-[var(--background)] border border-[var(--border)] rounded-3xl shadow-2xl p-4 sm:p-10 mb-6 w-full max-w-3xl m-0 sm:m-4 mb-8 animate-fadein flex justify-center items-center">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-8 w-full">
<StatCard label={`Total Distance`} value={`${totalDistance.toFixed(0)} ${displayUnits === 'imperial' ? 'mi' : 'km'}`} icon="🛣️" color="primary" info={displayUnits === 'imperial' ? 'Total miles driven in selected period.' : 'Total kilometers driven in selected period.'} />
<StatCard label={`Total Fuel Used`} value={`${totalLiters.toFixed(2)} ${isElectric ? 'kWh' : (displayUnits === 'imperial' ? 'gal' : 'L')}`} icon="⛽" color="secondary" info={isElectric ? 'Total kilowatt-hours used in selected period.' : (displayUnits === 'imperial' ? 'Total gallons used in selected period.' : 'Total liters used in selected period.')} />
<StatCard label="Total Fuel Cost" value={`${totalCost.toFixed(2)} ${filtered[0].currency}`} icon="💸" color="accent" info="Sum of all fill-up costs in selected period." />
<StatCard label="Average Consumption" value={`${avgConsumption.toFixed(2)} ${isElectric ? 'kWh / 100km' : (displayUnits === 'imperial' ? 'MPG' : 'L / 100km')}`} icon="📏" color="primary" info={isElectric ? 'Kilowatt-hours per 100km (lower is better).' : (displayUnits === 'imperial' ? 'Miles per gallon (higher is better).' : 'Liters per 100km (lower is better).')} />
<StatCard label={`Average Cost / ${displayUnits === 'imperial' ? 'mi' : 'km'}`} value={`${costPerDist.toFixed(2)} ${filtered[0].currency}`} icon="💰" color="secondary" info={`Average fuel cost per ${displayUnits === 'imperial' ? 'mile' : 'kilometer'}.`} />
</div>
</div>
</div>
<Achievements achievements={achievements} />
</div>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
export default function StatsSkeleton() {
return (
<div className="bg-[var(--background)] border border-[var(--border)] rounded-3xl shadow-2xl p-10 mb-6 w-full mx-auto m-4 mb-8 mx-4 animate-pulse">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-xl bg-[var(--muted)]">
<div className="w-10 h-10 rounded-full bg-[var(--border)]" />
<div className="flex-1 space-y-2">
<div className="h-4 w-1/2 bg-[var(--border)] rounded" />
<div className="h-6 w-2/3 bg-[var(--border)] rounded" />
</div>
</div>
))}
</div>
</div>
);
}
+47
View File
@@ -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 (
<div
className="flex flex-row items-center gap-2 w-auto mb-0"
role="group"
aria-label="Select time range for stats"
>
<span className="text-xs font-semibold text-[var(--foreground)]/70 mr-2 mb-0">
Sort by:
</span>
<div className="flex flex-row gap-2 w-auto mb-0">
{ranges.map((r) => (
<button
key={r.label}
className={`w-full sm:w-auto px-4 py-1 rounded-full border font-semibold shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] text-sm
${value === r.value
? "bg-[var(--primary)] text-white border-[var(--primary)] scale-105 shadow-lg"
: "bg-[var(--muted)] text-[var(--primary)] border-[var(--border)] hover:bg-[var(--primary)] hover:text-white hover:scale-105"}
`}
onClick={() => onChange(r.value)}
type="button"
aria-label={r.aria}
aria-pressed={value === r.value}
>
{r.label}
</button>
))}
</div>
</div>
);
}
+45
View File
@@ -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 (
<div className="flex items-center gap-2">
<button
aria-label="Light mode"
className={`px-4 py-2 rounded-lg border border-[var(--primary)] shadow-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]
${theme === 'light' ? 'bg-[var(--primary)] text-white' : 'bg-[var(--muted)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white'}`}
onClick={() => setTheme('light')}
>🌞</button>
<button
aria-label="Dark mode"
className={`px-4 py-2 rounded-lg border border-[var(--primary)] shadow-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]
${theme === 'dark' ? 'bg-[var(--primary)] text-white' : 'bg-[var(--muted)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white'}`}
onClick={() => setTheme('dark')}
>🌚</button>
</div>
);
}
+43
View File
@@ -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 (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-1 sm:gap-2 w-full max-w-xs">
<span className="text-xs font-semibold text-[var(--foreground)]/70 mb-1 sm:mb-0 sm:mr-2">Fuel:</span>
<div className="flex flex-col sm:flex-row gap-2 w-full items-center">
<button
type="button"
className={`w-full sm:w-auto px-3 py-1 rounded-md text-sm font-semibold border shadow-sm transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]
${units === "metric"
? "bg-[var(--primary)] text-white border-[var(--primary)] scale-105 shadow-lg"
: "bg-[var(--muted)] text-[var(--primary)] border-[var(--border)] hover:bg-[var(--primary)] hover:text-white hover:scale-105"}
`}
aria-checked={units === "metric"}
role="radio"
tabIndex={0}
onClick={() => !disabled && setUnits("metric")}
disabled={disabled}
>
Metric (L/100km)
</button>
<button
type="button"
className={`w-full sm:w-auto px-3 py-1 rounded-md text-sm font-semibold border shadow-sm transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]
${units === "imperial"
? "bg-[var(--primary)] text-white border-[var(--primary)] scale-105 shadow-lg"
: "bg-[var(--muted)] text-[var(--primary)] border-[var(--border)] hover:bg-[var(--primary)] hover:text-white hover:scale-105"}
`}
aria-checked={units === "imperial"}
role="radio"
tabIndex={0}
onClick={() => !disabled && setUnits("imperial")}
disabled={disabled}
>
Imperial (MPG)
</button>
</div>
</div>
);
}