diff --git a/package-lock.json b/package-lock.json index 5a4d5dd..6e9f836 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,18 +13,24 @@ "@chakra-ui/react": "^3.2.0", "@emotion/react": "^11.13.5", "@eslint/js": "^9.11.0", + "@hookform/resolvers": "^5.2.2", "@stylistic/eslint-plugin": "^2.8.0", "@types/node": "^22.19.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "axios": "^1.13.2", + "date-fns": "^4.1.0", "eslint": "^9.11.0", "eslint-plugin-react": "^7.36.1", "express": "^4.19.2", "globals": "^15.9.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.66.0", "react-router-dom": "^6.23.1", - "typescript-eslint": "^8.6.0" + "recharts": "^3.4.1", + "typescript-eslint": "^8.6.0", + "zod": "^4.1.12" } }, "node_modules/@ark-ui/react": { @@ -2221,6 +2227,18 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2501,6 +2519,42 @@ "node": ">=14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz", + "integrity": "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.2.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remix-run/router": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", @@ -2510,6 +2564,18 @@ "node": ">=14.0.0" } }, + "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/@stylistic/eslint-plugin": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.8.0.tgz", @@ -2538,6 +2604,69 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.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/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2626,6 +2755,12 @@ "@types/react": "*" } }, + "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/@types/webpack-env": { "version": "1.18.8", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz", @@ -4992,6 +5127,15 @@ "node": ">=6" } }, + "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-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5282,6 +5426,127 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "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/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5333,6 +5598,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5350,6 +5625,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", @@ -5841,6 +6122,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", + "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6180,6 +6471,12 @@ "node": ">= 0.6" } }, + "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/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -7482,6 +7779,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/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9449,6 +9755,22 @@ "integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==", "license": "MIT" }, + "node_modules/react-hook-form": { + "version": "7.66.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", + "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-i18next": { "version": "15.7.4", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", @@ -9481,6 +9803,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "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-router": { "version": "6.23.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz", @@ -9537,6 +9882,46 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/recharts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.4.1.tgz", + "integrity": "sha512-35kYg6JoOgwq8sE4rhYkVWwa6aAIgOtT+Ob0gitnShjwUwZmhrmy7Jco/5kJNF4PnLXgt9Hwq+geEMS+WrjU1g==", + "license": "MIT", + "workspaces": [ + "www" + ], + "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/recharts/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/recursive-readdir": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", @@ -9583,6 +9968,21 @@ "recursive-watch": "bin.js" } }, + "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", @@ -9687,6 +10087,12 @@ "node": ">=0.10.0" } }, + "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.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -10620,6 +11026,12 @@ "tslib": "^2" } }, + "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/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11117,6 +11529,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11141,6 +11562,28 @@ "node": ">= 0.8" } }, + "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/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -11637,6 +12080,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index b92fc3c..d063318 100644 --- a/package.json +++ b/package.json @@ -24,17 +24,23 @@ "@chakra-ui/react": "^3.2.0", "@emotion/react": "^11.13.5", "@eslint/js": "^9.11.0", + "@hookform/resolvers": "^5.2.2", "@stylistic/eslint-plugin": "^2.8.0", "@types/node": "^22.19.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "axios": "^1.13.2", + "date-fns": "^4.1.0", "eslint": "^9.11.0", "eslint-plugin-react": "^7.36.1", "express": "^4.19.2", "globals": "^15.9.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.66.0", "react-router-dom": "^6.23.1", - "typescript-eslint": "^8.6.0" + "recharts": "^3.4.1", + "typescript-eslint": "^8.6.0", + "zod": "^4.1.12" } } diff --git a/src/__data__/urls.ts b/src/__data__/urls.ts index 5e55827..beadc2d 100644 --- a/src/__data__/urls.ts +++ b/src/__data__/urls.ts @@ -12,4 +12,6 @@ export const URLs = { url: makeUrl(navs[`link.${pkg.name}.auth`]), isOn: Boolean(navs[`link.${pkg.name}.auth`]) }, + tracker: makeUrl('/tracker'), + stats: makeUrl('/stats'), } diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..4c0a174 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,103 @@ +import axios, { AxiosError } from 'axios' +import { getConfigValue } from '@brojs/cli' + +import type { + ApiResponse, + SignUpRequest, + SignInRequest, + SignInResponse, + LogCigaretteRequest, + Cigarette, + GetCigarettesParams, + DailyStat, + GetDailyStatsParams, +} from '../types/api' + +const TOKEN_KEY = 'smokeToken' + +// Get API base URL from config +const baseURL = String(getConfigValue('smoke-tracker.api') || '/api') + +// Create axios instance +export const apiClient = axios.create({ + baseURL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor to add JWT token +apiClient.interceptors.request.use( + (config) => { + const token = localStorage.getItem(TOKEN_KEY) + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor for error handling +apiClient.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response?.status === 401) { + // Token expired or invalid + localStorage.removeItem(TOKEN_KEY) + // Optionally redirect to login page + if (window.location.pathname !== '/auth/signin') { + window.location.href = '/smoke-tracker/auth/signin' + } + } + return Promise.reject(error) + } +) + +// Auth API +export const authApi = { + signup: async (data: SignUpRequest): Promise> => { + const response = await apiClient.post('/auth/signup', data) + return response.data + }, + + signin: async (data: SignInRequest): Promise> => { + const response = await apiClient.post('/auth/signin', data) + if (response.data.success) { + localStorage.setItem(TOKEN_KEY, response.data.body.token) + } + return response.data + }, + + signout: () => { + localStorage.removeItem(TOKEN_KEY) + }, + + getToken: () => { + return localStorage.getItem(TOKEN_KEY) + }, +} + +// Cigarettes API +export const cigarettesApi = { + log: async (data: LogCigaretteRequest): Promise> => { + const response = await apiClient.post('/cigarettes', data) + return response.data + }, + + getAll: async (params?: GetCigarettesParams): Promise> => { + const response = await apiClient.get('/cigarettes', { params }) + return response.data + }, +} + +// Stats API +export const statsApi = { + getDaily: async (params?: GetDailyStatsParams): Promise> => { + const response = await apiClient.get('/stats/daily', { params }) + return response.data + }, +} + diff --git a/src/app.tsx b/src/app.tsx index f7cc246..ac23717 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -3,12 +3,15 @@ import { BrowserRouter } from 'react-router-dom' import { Dashboard } from './dashboard' import { Provider } from './theme' +import { AuthProvider } from './contexts/AuthContext' const App = () => { return ( - + + + ) diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..153209b --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode } from 'react' +import { Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { URLs } from '../__data__/urls' + +interface ProtectedRouteProps { + children: ReactNode +} + +export const ProtectedRoute: React.FC = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth() + + if (isLoading) { + return
Loading...
+ } + + if (!isAuthenticated) { + return + } + + return <>{children} +} + diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..f0d24ac --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,31 @@ +import { Field as ChakraField } from '@chakra-ui/react' +import * as React from 'react' + +export interface FieldProps extends ChakraField.RootProps { + label?: React.ReactNode + helperText?: React.ReactNode + errorText?: React.ReactNode + optionalText?: React.ReactNode +} + +export const Field = React.forwardRef( + function Field(props, ref) { + const { label, children, helperText, errorText, optionalText, ...rest } = props + return ( + + {label && ( + + {label} + + + )} + {children} + {helperText && ( + {helperText} + )} + {errorText && {errorText}} + + ) + } +) + diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..45b796e --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,85 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react' +import { authApi } from '../api/client' +import type { User, SignUpRequest, SignInRequest } from '../types/api' + +interface AuthContextType { + user: User | null + isAuthenticated: boolean + isLoading: boolean + signin: (data: SignInRequest) => Promise + signup: (data: SignUpRequest) => Promise + signout: () => void +} + +const AuthContext = createContext(undefined) + +interface AuthProviderProps { + children: ReactNode +} + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + // Check if user is already logged in (has valid token) + const token = authApi.getToken() + if (token) { + // In a real app, you might want to verify the token with the backend + // For now, we just check if token exists + setIsLoading(false) + // We don't have user data stored, so user will be set on first authenticated request + } else { + setIsLoading(false) + } + }, []) + + const signin = async (data: SignInRequest) => { + try { + const response = await authApi.signin(data) + if (response.success) { + setUser(response.body.user) + } else { + throw new Error('Sign in failed') + } + } catch (error) { + throw error + } + } + + const signup = async (data: SignUpRequest) => { + try { + const response = await authApi.signup(data) + if (!response.success) { + throw new Error('Sign up failed') + } + } catch (error) { + throw error + } + } + + const signout = () => { + authApi.signout() + setUser(null) + } + + const value: AuthContextType = { + user, + isAuthenticated: !!authApi.getToken(), + isLoading, + signin, + signup, + signout, + } + + return {children} +} + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + diff --git a/src/dashboard.tsx b/src/dashboard.tsx index a691263..465b4bc 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -3,6 +3,10 @@ import { Route, Routes } from 'react-router-dom' import { URLs } from './__data__/urls' import { MainPage } from './pages' +import { SignInPage, SignUpPage } from './pages/auth' +import { TrackerPage } from './pages/tracker' +import { StatsPage } from './pages/stats' +import { ProtectedRoute } from './components/ProtectedRoute' const PageWrapper = ({ children }: React.PropsWithChildren) => ( Loading...}>{children} @@ -15,7 +19,45 @@ export const Dashboard = () => { path={URLs.baseUrl} element={ - + + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } /> diff --git a/src/pages/auth/index.ts b/src/pages/auth/index.ts new file mode 100644 index 0000000..194fa2c --- /dev/null +++ b/src/pages/auth/index.ts @@ -0,0 +1,3 @@ +export { SignInPage } from './signin' +export { SignUpPage } from './signup' + diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx new file mode 100644 index 0000000..9424a42 --- /dev/null +++ b/src/pages/auth/signin.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Link, useNavigate } from 'react-router-dom' +import { + Box, + Button, + Input, + VStack, + Text, + Heading, + Card, +} from '@chakra-ui/react' +import { Field } from '../../components/ui/field' +import { useAuth } from '../../contexts/AuthContext' +import { URLs } from '../../__data__/urls' + +const signinSchema = z.object({ + login: z.string().min(1, 'Логин обязателен'), + password: z.string().min(1, 'Пароль обязателен'), +}) + +type SigninFormData = z.infer + +export const SignInPage: React.FC = () => { + const { signin } = useAuth() + const navigate = useNavigate() + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signinSchema), + }) + + const onSubmit = async (data: SigninFormData) => { + setIsSubmitting(true) + setError(null) + + try { + await signin(data) + navigate(URLs.baseUrl) + } catch (err: any) { + console.error('Signin error:', err) + console.error('Error response:', err?.response) + + let errorMessage = 'Ошибка входа' + + if (err?.response?.data?.errors) { + errorMessage = err.response.data.errors + } else if (err?.response?.data?.message) { + errorMessage = err.response.data.message + } else if (err?.message) { + errorMessage = err.message + } + + setError(errorMessage) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + + + Вход в систему + + +
+ + + + + + + + + + {error && ( + + {error} + + )} + + + + + Нет аккаунта?{' '} + + + Зарегистрироваться + + + + +
+
+
+
+
+ ) +} + diff --git a/src/pages/auth/signup.tsx b/src/pages/auth/signup.tsx new file mode 100644 index 0000000..37b6444 --- /dev/null +++ b/src/pages/auth/signup.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Link, useNavigate } from 'react-router-dom' +import { + Box, + Button, + Input, + VStack, + Text, + Heading, + Card, +} from '@chakra-ui/react' +import { Field } from '../../components/ui/field' +import { useAuth } from '../../contexts/AuthContext' +import { URLs } from '../../__data__/urls' + +const signupSchema = z.object({ + login: z.string().min(3, 'Логин должен содержать минимум 3 символа'), + password: z.string().min(4, 'Пароль должен содержать минимум 4 символов'), + confirmPassword: z.string(), +}).refine((data) => data.password === data.confirmPassword, { + message: 'Пароли не совпадают', + path: ['confirmPassword'], +}) + +type SignupFormData = z.infer + +export const SignUpPage: React.FC = () => { + const { signup } = useAuth() + const navigate = useNavigate() + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupSchema), + }) + + const onSubmit = async (data: SignupFormData) => { + setIsSubmitting(true) + setError(null) + + try { + await signup({ login: data.login, password: data.password }) + // After successful signup, redirect to signin + navigate(URLs.auth.url) + } catch (err: any) { + console.error('Signup error:', err) + console.error('Error response:', err?.response) + + let errorMessage = 'Ошибка регистрации' + + if (err?.response?.data?.errors) { + errorMessage = err.response.data.errors + } else if (err?.response?.data?.message) { + errorMessage = err.response.data.message + } else if (err?.message) { + errorMessage = err.message + } + + setError(errorMessage) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + + + Регистрация + + +
+ + + + + + + + + + + + + + {error && ( + + {error} + + )} + + + + + Уже есть аккаунт?{' '} + + + Войти + + + + +
+
+
+
+
+ ) +} + diff --git a/src/pages/main/main.tsx b/src/pages/main/main.tsx index e15decf..7948c3d 100644 --- a/src/pages/main/main.tsx +++ b/src/pages/main/main.tsx @@ -1,28 +1,117 @@ import React from 'react' -import { Grid, GridItem } from '@chakra-ui/react' +import { Link, useNavigate } from 'react-router-dom' +import { + Box, + Button, + VStack, + HStack, + Text, + Heading, + Card, + Container, +} from '@chakra-ui/react' +import { useAuth } from '../../contexts/AuthContext' +import { URLs } from '../../__data__/urls' export const MainPage = () => { + const { user, signout } = useAuth() + const navigate = useNavigate() + + const handleSignout = () => { + signout() + navigate(URLs.auth.url) + } + return ( - - header - aside - main - footer - + + {/* Header */} + + + + Smoke Tracker + + {user && ( + + Пользователь: {user.login} + + )} + + + + + + + {/* Main content */} + + + + Добро пожаловать в Smoke Tracker! + + Приложение для отслеживания привычки курения + + + + {/* Navigation cards */} + + + + + + + 📝 Трекер + + + Записывайте каждую выкуренную сигарету с временем и заметками + + + + + + + + + + + + + 📊 Статистика + + + Просматривайте графики и анализируйте свою привычку курения + + + + + + + + + + + {/* Info section */} + + + + Возможности приложения: + + ✓ Быстрая запись сигарет одной кнопкой + ✓ Добавление заметок и произвольного времени + ✓ Просмотр истории всех записей + ✓ Дневная статистика с графиками + ✓ Анализ за любой период времени + ✓ Расчет среднего и максимального количества в день + + + + + + + ) } diff --git a/src/pages/stats/index.ts b/src/pages/stats/index.ts new file mode 100644 index 0000000..725bde3 --- /dev/null +++ b/src/pages/stats/index.ts @@ -0,0 +1,2 @@ +export { StatsPage } from './stats' + diff --git a/src/pages/stats/stats.tsx b/src/pages/stats/stats.tsx new file mode 100644 index 0000000..fa65fdf --- /dev/null +++ b/src/pages/stats/stats.tsx @@ -0,0 +1,258 @@ +import React, { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { + Box, + Button, + VStack, + HStack, + Text, + Heading, + Card, + Input, + Stack, +} from '@chakra-ui/react' +import { Field } from '../../components/ui/field' +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts' +import { format, subDays, eachDayOfInterval, parseISO } from 'date-fns' +import { statsApi } from '../../api/client' +import { URLs } from '../../__data__/urls' +import type { DailyStat } from '../../types/api' + +interface FilledDailyStat { + date: string + count: number + displayDate: string +} + +export const StatsPage: React.FC = () => { + const [stats, setStats] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // Default: last 30 days + const [fromDate, setFromDate] = useState( + format(subDays(new Date(), 30), 'yyyy-MM-dd') + ) + const [toDate, setToDate] = useState(format(new Date(), 'yyyy-MM-dd')) + + const fillMissingDates = (data: DailyStat[], from: string, to: string): FilledDailyStat[] => { + const start = parseISO(from) + const end = parseISO(to) + const allDates = eachDayOfInterval({ start, end }) + + return allDates.map((date) => { + const dateStr = format(date, 'yyyy-MM-dd') + const existing = data.find((d) => d.date === dateStr) + + return { + date: dateStr, + count: existing ? existing.count : 0, + displayDate: format(date, 'dd.MM'), + } + }) + } + + const fetchStats = async () => { + setIsLoading(true) + setError(null) + + try { + const fromISO = new Date(fromDate).toISOString() + const toISO = new Date(toDate + 'T23:59:59').toISOString() + + const response = await statsApi.getDaily({ + from: fromISO, + to: toISO, + }) + + if (response.success) { + const filled = fillMissingDates(response.body, fromDate, toDate) + setStats(filled) + } + } catch (err: any) { + const errorMessage = + err?.response?.data?.errors || + err?.response?.data?.message || + 'Ошибка при загрузке статистики' + setError(errorMessage) + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + fetchStats() + }, []) + + const totalCigarettes = stats.reduce((sum, stat) => sum + stat.count, 0) + const averagePerDay = stats.length > 0 ? (totalCigarettes / stats.length).toFixed(1) : 0 + const maxPerDay = Math.max(...stats.map((s) => s.count), 0) + + return ( + + + Статистика курения + + + + + + + + + + + {/* Date range selector */} + + + + Выберите период + + + + setFromDate(e.target.value)} + /> + + + + setToDate(e.target.value)} + /> + + + + + + + + + + + {error && ( + + + + {error} + + + + )} + + {/* Summary statistics */} + + + + + + {totalCigarettes} + + + Всего сигарет + + + + + + {averagePerDay} + + + В среднем в день + + + + + + {maxPerDay} + + + Максимум в день + + + + + + + {/* Chart */} + + + + График по дням + + {isLoading ? ( + + Загрузка... + + ) : stats.length === 0 ? ( + + Нет данных за выбранный период + + ) : ( + + + + + + + `Дата: ${label}`} + formatter={(value: number) => [value, 'Сигарет']} + /> + + + + + + )} + + + + + + ) +} + diff --git a/src/pages/tracker/index.ts b/src/pages/tracker/index.ts new file mode 100644 index 0000000..0c82336 --- /dev/null +++ b/src/pages/tracker/index.ts @@ -0,0 +1,2 @@ +export { TrackerPage } from './tracker' + diff --git a/src/pages/tracker/tracker.tsx b/src/pages/tracker/tracker.tsx new file mode 100644 index 0000000..2a0bccc --- /dev/null +++ b/src/pages/tracker/tracker.tsx @@ -0,0 +1,266 @@ +import React, { useState, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Link } from 'react-router-dom' +import { + Box, + Button, + Input, + VStack, + HStack, + Text, + Heading, + Card, + Textarea, + Stack, +} from '@chakra-ui/react' +import { Field } from '../../components/ui/field' +import { cigarettesApi } from '../../api/client' +import { URLs } from '../../__data__/urls' +import type { Cigarette } from '../../types/api' + +const logCigaretteSchema = z.object({ + smokedAt: z.string().optional(), + note: z.string().optional(), +}) + +type LogCigaretteFormData = z.infer + +export const TrackerPage: React.FC = () => { + const [cigarettes, setCigarettes] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(logCigaretteSchema), + }) + + const fetchCigarettes = async () => { + try { + const response = await cigarettesApi.getAll() + if (response.success) { + // Show most recent first + setCigarettes(response.body.reverse()) + } + } catch (err: any) { + console.error('Failed to fetch cigarettes:', err) + } + } + + useEffect(() => { + fetchCigarettes() + }, []) + + const logQuick = async () => { + setIsLoading(true) + setError(null) + setSuccess(null) + + try { + const response = await cigarettesApi.log({}) + if (response.success) { + setSuccess('Сигарета записана!') + await fetchCigarettes() + setTimeout(() => setSuccess(null), 3000) + } + } catch (err: any) { + const errorMessage = err?.response?.data?.errors || err?.response?.data?.message || 'Ошибка при записи' + setError(errorMessage) + } finally { + setIsLoading(false) + } + } + + const onSubmit = async (data: LogCigaretteFormData) => { + setIsLoading(true) + setError(null) + setSuccess(null) + + try { + const response = await cigarettesApi.log({ + smokedAt: data.smokedAt || undefined, + note: data.note || undefined, + }) + if (response.success) { + setSuccess('Сигарета записана с заметкой!') + reset() + await fetchCigarettes() + setTimeout(() => setSuccess(null), 3000) + } + } catch (err: any) { + const errorMessage = err?.response?.data?.errors || err?.response?.data?.message || 'Ошибка при записи' + setError(errorMessage) + } finally { + setIsLoading(false) + } + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + return ( + + + Трекер курения + + + + + + + + + + + {/* Quick log button */} + + + + + Быстрая запись + + + + + + + {/* Form with custom time and note */} + + +
+ + Запись с дополнительными данными + + + + + + +