mirror of
https://github.com/EdiFarcas/Car-Fuel-Tracking-App.git
synced 2026-06-22 05:00:53 +03:00
Update app improvment 2. Dashboard, stat, UI. All
This commit is contained in:
Generated
+446
-4
@@ -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
@@ -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';
|
||||
@@ -53,6 +53,7 @@ enum FuelType {
|
||||
GASOLINE
|
||||
DIESEL
|
||||
LPG
|
||||
ELECTRIC
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)]`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 car’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user