diff --git a/package-lock.json b/package-lock.json index 236f620..9a26e7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "playwright-test": "^14.1.12", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.10.1", "recharts": "^3.4.1" }, "devDependencies": { @@ -2043,6 +2044,19 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5414,6 +5428,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", + "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", + "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", + "license": "MIT", + "dependencies": { + "react-router": "7.10.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -5760,6 +5812,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index c63b664..206b8e2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "playwright-test": "^14.1.12", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.10.1", "recharts": "^3.4.1" }, "devDependencies": { diff --git a/server/package-lock.json b/server/package-lock.json index 0eaa564..e4afd7f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,14 +10,14 @@ "dependencies": { "@google/generative-ai": "^0.24.1", "@prisma/adapter-better-sqlite3": "^7.1.0", - "@prisma/client": "^6.19.0", + "@prisma/client": "^7.1.0", "@types/better-sqlite3": "^7.6.13", - "bcryptjs": "*", + "bcryptjs": "3.0.3", "better-sqlite3": "^12.5.0", - "cors": "*", - "dotenv": "*", - "express": "*", - "jsonwebtoken": "*", + "cors": "2.8.5", + "dotenv": "17.2.3", + "express": "5.1.0", + "jsonwebtoken": "9.0.2", "ts-node-dev": "^2.0.0" }, "devDependencies": { @@ -27,11 +27,48 @@ "@types/jsonwebtoken": "*", "@types/node": "*", "nodemon": "*", - "prisma": "*", + "prisma": "^7.1.0", "ts-node": "*", "typescript": "*" } }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -44,6 +81,36 @@ "node": ">=12" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz", + "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.6.tgz", + "integrity": "sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.2" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.7.tgz", + "integrity": "sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.3.2" + } + }, "node_modules/@google/generative-ai": { "version": "0.24.1", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", @@ -53,6 +120,19 @@ "node": ">=18.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", + "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -78,6 +158,20 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.12.1.tgz", + "integrity": "sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@prisma/adapter-better-sqlite3": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-better-sqlite3/-/adapter-better-sqlite3-7.1.0.tgz", @@ -89,17 +183,19 @@ } }, "node_modules/@prisma/client": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", - "integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==", - "hasInstallScript": true, + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.1.0.tgz", + "integrity": "sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==", "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.1.0" + }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { "prisma": "*", - "typescript": ">=5.1.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "prisma": { @@ -110,10 +206,16 @@ } } }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.1.0.tgz", + "integrity": "sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==", + "license": "Apache-2.0" + }, "node_modules/@prisma/config": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz", - "integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.1.0.tgz", + "integrity": "sha512-Uz+I43Wn1RYNHtuYtOhOnUcNMWp2Pd3GUDDKs37xlHptCGpzEG3MRR9L+8Y2ISMsMI24z/Ni+ww6OB/OO8M0sQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -124,12 +226,37 @@ } }, "node_modules/@prisma/debug": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz", - "integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==", - "devOptional": true, + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz", + "integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==", "license": "Apache-2.0" }, + "node_modules/@prisma/dev": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.15.0.tgz", + "integrity": "sha512-KhWaipnFlS/fWEs6I6Oqjcy2S08vKGmxJ5LexqUl/3Ve0EgLUsZwdKF0MvqPM5F5ttw8GtfZarjM5y7VLwv9Ow==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.3.2", + "@electric-sql/pglite-socket": "0.0.6", + "@electric-sql/pglite-tools": "0.2.7", + "@hono/node-server": "1.19.6", + "@mrleebo/prisma-ast": "0.12.1", + "@prisma/get-platform": "6.8.2", + "@prisma/query-plan-executor": "6.18.0", + "foreground-child": "3.3.1", + "get-port-please": "3.1.2", + "hono": "4.10.6", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.21.3", + "std-env": "3.9.0", + "valibot": "1.2.0", + "zeptomatch": "2.0.2" + } + }, "node_modules/@prisma/driver-adapter-utils": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.1.0.tgz", @@ -139,53 +266,93 @@ "@prisma/debug": "7.1.0" } }, - "node_modules/@prisma/driver-adapter-utils/node_modules/@prisma/debug": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz", - "integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==", - "license": "Apache-2.0" - }, "node_modules/@prisma/engines": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz", - "integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.1.0.tgz", + "integrity": "sha512-KQlraOybdHAzVv45KWKJzpR9mJLkib7/TyApQpqrsL7FUHfgjIcy8jrVGt3iNfG6/GDDl+LNlJ84JSQwIfdzxA==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.0", - "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", - "@prisma/fetch-engine": "6.19.0", - "@prisma/get-platform": "6.19.0" + "@prisma/debug": "7.1.0", + "@prisma/engines-version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba", + "@prisma/fetch-engine": "7.1.0", + "@prisma/get-platform": "7.1.0" } }, "node_modules/@prisma/engines-version": { - "version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz", - "integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==", + "version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba.tgz", + "integrity": "sha512-qZUevUh+yPhGT28rDQnV8V2kLnFjirzhVD67elRPIJHRsUV/mkII10HSrJrhK/U2GYgAxXR2VEREtq7AsfS8qw==", "devOptional": true, "license": "Apache-2.0" }, - "node_modules/@prisma/fetch-engine": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz", - "integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==", + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz", + "integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.0", - "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", - "@prisma/get-platform": "6.19.0" + "@prisma/debug": "7.1.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.1.0.tgz", + "integrity": "sha512-GZYF5Q8kweXWGfn87hTu17kw7x1DgnehgKoE4Zg1BmHYF3y1Uu0QRY/qtSE4veH3g+LW8f9HKqA0tARG66bxxQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.1.0", + "@prisma/engines-version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba", + "@prisma/get-platform": "7.1.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz", + "integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.1.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz", - "integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.8.2.tgz", + "integrity": "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.0" + "@prisma/debug": "6.8.2" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.8.2.tgz", + "integrity": "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-6.18.0.tgz", + "integrity": "sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/studio-core": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.8.2.tgz", + "integrity": "sha512-/iAEWEUpTja+7gVMu1LtR2pPlvDmveAwMHdTWbDeGlT7yiv0ZTCPpmeAGdq/Y9aJ9Zj1cEGBXGRbmmNPj022PQ==", + "devOptional": true, + "license": "UNLICENSED", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, "node_modules/@standard-schema/spec": { @@ -346,6 +513,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -447,6 +625,16 @@ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -529,23 +717,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -716,6 +908,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -838,6 +1045,29 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -896,6 +1126,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1180,6 +1420,23 @@ "node": ">= 0.8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1233,6 +1490,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1257,6 +1524,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port-please": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.2.tgz", + "integrity": "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -1339,6 +1613,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -1373,6 +1661,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.10.6", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.6.tgz", + "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1398,16 +1696,27 @@ "node": ">= 0.8" } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -1532,6 +1841,20 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1576,15 +1899,32 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1627,6 +1967,39 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lru.min": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", + "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -1741,6 +2114,40 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1900,6 +2307,16 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1954,6 +2371,20 @@ "pathe": "^2.0.3" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1981,31 +2412,58 @@ } }, "node_modules/prisma": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", - "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.1.0.tgz", + "integrity": "sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "6.19.0", - "@prisma/engines": "6.19.0" + "@prisma/config": "7.1.0", + "@prisma/dev": "0.15.0", + "@prisma/engines": "7.1.0", + "@prisma/studio-core": "0.8.2", + "mysql2": "3.15.3", + "postgres": "3.4.7" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { - "typescript": ">=5.1.0" + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, "typescript": { "optional": true } } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2092,22 +2550,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2134,6 +2576,31 @@ "destr": "^2.0.3" } }, + "node_modules/react": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.1" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2160,6 +2627,23 @@ "node": ">=8.10.0" } }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/remeda": { + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.21.3.tgz", + "integrity": "sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.39.1" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2180,6 +2664,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -2235,6 +2729,14 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2269,6 +2771,12 @@ "node": ">= 18" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", @@ -2290,6 +2798,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2362,6 +2893,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2439,6 +2983,16 @@ "source-map": "^0.6.0" } }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2448,6 +3002,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2679,6 +3240,19 @@ "node": "*" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "devOptional": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -2740,6 +3314,21 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "license": "MIT" }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2749,6 +3338,22 @@ "node": ">= 0.8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2772,6 +3377,16 @@ "engines": { "node": ">=6" } + }, + "node_modules/zeptomatch": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.0.2.tgz", + "integrity": "sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.10" + } } } } diff --git a/server/package.json b/server/package.json index 2d6649a..cb94432 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,7 @@ "dependencies": { "@google/generative-ai": "^0.24.1", "@prisma/adapter-better-sqlite3": "^7.1.0", - "@prisma/client": "^6.19.0", + "@prisma/client": "^7.1.0", "@types/better-sqlite3": "^7.6.13", "bcryptjs": "3.0.3", "better-sqlite3": "^12.5.0", @@ -28,8 +28,8 @@ "@types/jsonwebtoken": "*", "@types/node": "*", "nodemon": "*", - "prisma": "*", + "prisma": "^7.1.0", "ts-node": "*", "typescript": "*" } -} \ No newline at end of file +} diff --git a/server/prisma/dev.db b/server/prisma/dev.db index 011faf3..89d7553 100644 Binary files a/server/prisma/dev.db and b/server/prisma/dev.db differ diff --git a/server/prisma/migrations/20251206071150_add_plan_exercise_table/migration.sql b/server/prisma/migrations/20251206071150_add_plan_exercise_table/migration.sql new file mode 100644 index 0000000..8b36ac2 --- /dev/null +++ b/server/prisma/migrations/20251206071150_add_plan_exercise_table/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "PlanExercise" ( + "id" TEXT NOT NULL PRIMARY KEY, + "planId" TEXT NOT NULL, + "exerciseId" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "isWeighted" BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT "PlanExercise_planId_fkey" FOREIGN KEY ("planId") REFERENCES "WorkoutPlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "PlanExercise_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "Exercise" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_WorkoutPlan" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "exercises" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "WorkoutPlan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_WorkoutPlan" ("createdAt", "description", "exercises", "id", "name", "updatedAt", "userId") SELECT "createdAt", "description", "exercises", "id", "name", "updatedAt", "userId" FROM "WorkoutPlan"; +DROP TABLE "WorkoutPlan"; +ALTER TABLE "new_WorkoutPlan" RENAME TO "WorkoutPlan"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 6f0c974..b060a67 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -7,7 +7,6 @@ generator client { datasource db { provider = "sqlite" - url = env("DATABASE_URL") } model User { @@ -60,6 +59,7 @@ model Exercise { isUnilateral Boolean @default(false) sets WorkoutSet[] + planExercises PlanExercise[] } model WorkoutSession { @@ -102,8 +102,19 @@ model WorkoutPlan { user User @relation(fields: [userId], references: [id], onDelete: Cascade) name String description String? - exercises String // JSON string of exercise IDs + exercises String? // JSON string of exercise IDs (Deprecated, to be removed) + planExercises PlanExercise[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +model PlanExercise { + id String @id @default(uuid()) + planId String + plan WorkoutPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + exerciseId String + exercise Exercise @relation(fields: [exerciseId], references: [id]) + order Int + isWeighted Boolean @default(false) +} + diff --git a/server/src/routes/plans.ts b/server/src/routes/plans.ts index c6a9952..a97bf11 100644 --- a/server/src/routes/plans.ts +++ b/server/src/routes/plans.ts @@ -25,12 +25,26 @@ router.get('/', async (req: any, res) => { try { const userId = req.user.userId; const plans = await prisma.workoutPlan.findMany({ - where: { userId } + where: { userId }, + include: { + planExercises: { + include: { exercise: true }, + orderBy: { order: 'asc' } + } + }, + orderBy: { createdAt: 'desc' } }); const mappedPlans = plans.map((p: any) => ({ ...p, - steps: p.exercises ? JSON.parse(p.exercises) : [] + steps: p.planExercises.map((pe: any) => ({ + id: pe.id, + exerciseId: pe.exerciseId, + exerciseName: pe.exercise.name, + exerciseType: pe.exercise.type, + isWeighted: pe.isWeighted, + // Add default properties if needed by PlannedSet interface + })) })); res.json(mappedPlans); @@ -46,28 +60,71 @@ router.post('/', async (req: any, res) => { const userId = req.user.userId; const { id, name, description, steps } = req.body; - const exercisesJson = JSON.stringify(steps || []); + // Steps array contains PlannedSet items + // We need to transact: create/update plan, then replace exercises - const existing = await prisma.workoutPlan.findUnique({ where: { id } }); + await prisma.$transaction(async (tx) => { + // Upsert plan + let plan = await tx.workoutPlan.findUnique({ where: { id } }); - if (existing) { - const updated = await prisma.workoutPlan.update({ - where: { id }, - data: { name, description, exercises: exercisesJson } - }); - res.json({ ...updated, steps: steps || [] }); - } else { - const created = await prisma.workoutPlan.create({ - data: { - id, - userId, - name, - description, - exercises: exercisesJson + if (plan) { + await tx.workoutPlan.update({ + where: { id }, + data: { name, description } + }); + // Delete existing plan exercises + await tx.planExercise.deleteMany({ where: { planId: id } }); + } else { + await tx.workoutPlan.create({ + data: { + id, + userId, + name, + description + } + }); + } + + // Create new plan exercises + if (steps && steps.length > 0) { + await tx.planExercise.createMany({ + data: steps.map((step: any, index: number) => ({ + planId: id, + exerciseId: step.exerciseId, + order: index, + isWeighted: step.isWeighted || false + })) + }); + } + }); + + // Return the updated plan structure + // Since we just saved it, we can mirror back what was sent or re-fetch. + // Re-fetching ensures DB state consistency. + const savedPlan = await prisma.workoutPlan.findUnique({ + where: { id }, + include: { + planExercises: { + include: { exercise: true }, + orderBy: { order: 'asc' } } - }); - res.json({ ...created, steps: steps || [] }); - } + } + }); + + if (!savedPlan) throw new Error("Plan failed to save"); + + const mappedPlan = { + ...savedPlan, + steps: savedPlan.planExercises.map((pe: any) => ({ + id: pe.id, + exerciseId: pe.exerciseId, + exerciseName: pe.exercise.name, + exerciseType: pe.exercise.type, + isWeighted: pe.isWeighted + })) + }; + + res.json(mappedPlan); } catch (error) { console.error('Error saving plan:', error); res.status(500).json({ error: 'Server error' }); diff --git a/server/src/scripts/migratePlans.ts b/server/src/scripts/migratePlans.ts new file mode 100644 index 0000000..40d59e6 --- /dev/null +++ b/server/src/scripts/migratePlans.ts @@ -0,0 +1,42 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function migrate() { + console.log('Starting migration...'); + const plans = await prisma.workoutPlan.findMany(); + console.log(`Found ${plans.length} plans.`); + + for (const plan of plans) { + if (plan.exercises) { + try { + const steps = JSON.parse(plan.exercises); + console.log(`Migrating plan ${plan.name} (${plan.id}) with ${steps.length} steps.`); + + let order = 0; + for (const step of steps) { + await prisma.planExercise.create({ + data: { + planId: plan.id, + exerciseId: step.exerciseId, + order: order++, + isWeighted: step.isWeighted || false + } + }); + } + } catch (e) { + console.error(`Error parsing JSON for plan ${plan.id}:`, e); + } + } + } + console.log('Migration complete.'); +} + +migrate() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/App.tsx b/src/App.tsx index ddb0e2a..243dc1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ - import React, { useState, useEffect } from 'react'; +import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; import Navbar from './components/Navbar'; import Tracker from './components/Tracker/index'; import History from './components/History'; @@ -8,250 +8,112 @@ import AICoach from './components/AICoach'; import Plans from './components/Plans'; import Login from './components/Login'; import Profile from './components/Profile'; -import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types'; -import { getSessions, saveSession, deleteSession, getPlans, getActiveSession, updateActiveSession, deleteActiveSession, updateSetInActiveSession, deleteSetFromActiveSession } from './services/storage'; -import { getCurrentUserProfile, getMe } from './services/auth'; +import { Language, User } from './types'; // Removed unused imports import { getSystemLanguage } from './services/i18n'; -import { logWeight } from './services/weight'; -import { generateId } from './utils/uuid'; +import { useAuth } from './context/AuthContext'; +import { useData } from './context/DataContext'; function App() { - const [currentUser, setCurrentUser] = useState(null); - const [currentTab, setCurrentTab] = useState('TRACK'); + const { currentUser, updateUser, logout } = useAuth(); + const { + sessions, + plans, + activeSession, + activePlan, + startSession, + endSession, + quitSession, + addSet, + removeSet, + updateSet, + updateSession, + deleteSessionById + } = useData(); + const [language, setLanguage] = useState('en'); - - const [sessions, setSessions] = useState([]); - const [plans, setPlans] = useState([]); - const [activeSession, setActiveSession] = useState(null); - const [activePlan, setActivePlan] = useState(null); - + const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { - // Set initial language setLanguage(getSystemLanguage()); - - // Restore session - const restoreSession = async () => { - const token = localStorage.getItem('token'); - if (token) { - const res = await getMe(); - if (res.success && res.user) { - setCurrentUser(res.user); - - // Restore active workout session from database - const activeSession = await getActiveSession(res.user.id); - if (activeSession) { - setActiveSession(activeSession); - // Restore plan if session has planId - if (activeSession.planId) { - const plans = await getPlans(res.user.id); - const plan = plans.find(p => p.id === activeSession.planId); - if (plan) { - setActivePlan(plan); - } - } - } - } else { - localStorage.removeItem('token'); - } - } - }; - restoreSession(); }, []); - useEffect(() => { - const loadSessions = async () => { - if (currentUser) { - const s = await getSessions(currentUser.id); - setSessions(s); - // Load plans - const p = await getPlans(currentUser.id); - setPlans(p); - - } else { - setSessions([]); - setPlans([]); - - } - }; - loadSessions(); - }, [currentUser]); - const handleLogin = (user: User) => { - setCurrentUser(user); - setCurrentTab('TRACK'); + updateUser(user); + navigate('/'); }; const handleLogout = () => { - localStorage.removeItem('token'); - setCurrentUser(null); - setActiveSession(null); - setActivePlan(null); + logout(); + navigate('/login'); }; - const handleLanguageChange = (lang: Language) => { - setLanguage(lang); - }; - - const handleUserUpdate = (updatedUser: User) => { - setCurrentUser(updatedUser); - }; - - const handleStartSession = async (plan?: WorkoutPlan, startWeight?: number) => { - if (!currentUser) return; - if (activeSession) return; - - // Get latest weight from profile or default - const profile = getCurrentUserProfile(currentUser.id); - // Use provided startWeight, or profile weight, or default 70 - const currentWeight = startWeight || profile?.weight || 70; - - const newSession: WorkoutSession = { - id: generateId(), - startTime: Date.now(), - type: 'STANDARD', - userBodyWeight: currentWeight, - sets: [], - planId: plan?.id, - planName: plan?.name - }; - setActivePlan(plan || null); - setActiveSession(newSession); - setCurrentTab('TRACK'); - - // Save to database immediately - await saveSession(currentUser.id, newSession); - - // If startWeight was provided (meaning user explicitly entered it), log it to weight history - if (startWeight) { - await logWeight(startWeight); - } - }; - - const handleEndSession = async () => { - if (activeSession && currentUser) { - const finishedSession = { ...activeSession, endTime: Date.now() }; - await updateActiveSession(currentUser.id, finishedSession); - setSessions(prev => [finishedSession, ...prev]); - setActiveSession(null); - setActivePlan(null); - - // Refetch user to get updated weight - const res = await getMe(); - if (res.success && res.user) { - setCurrentUser(res.user); - } - } - }; - - const handleAddSet = (set: WorkoutSet) => { - if (activeSession && currentUser) { - const updatedSession = { - ...activeSession, - sets: [...activeSession.sets, set] - }; - setActiveSession(updatedSession); - } - }; - - const handleRemoveSetFromActive = async (setId: string) => { - if (activeSession && currentUser) { - await deleteSetFromActiveSession(currentUser.id, setId); - const updatedSession = { - ...activeSession, - sets: activeSession.sets.filter(s => s.id !== setId) - }; - setActiveSession(updatedSession); - } - }; - - const handleUpdateSetInActive = async (updatedSet: WorkoutSet) => { - if (activeSession && currentUser) { - const response = await updateSetInActiveSession(currentUser.id, updatedSet.id, updatedSet); - const updatedSession = { - ...activeSession, - sets: activeSession.sets.map(s => s.id === updatedSet.id ? response : s) - }; - setActiveSession(updatedSession); - } - }; - - const handleQuitSession = async () => { - if (currentUser) { - await deleteActiveSession(currentUser.id); - setActiveSession(null); - setActivePlan(null); - } - }; - - const handleUpdateSession = (updatedSession: WorkoutSession) => { - if (!currentUser) return; - saveSession(currentUser.id, updatedSession); - setSessions(prev => prev.map(s => s.id === updatedSession.id ? updatedSession : s)); - }; - - const handleDeleteSession = (sessionId: string) => { - if (!currentUser) return; - deleteSession(currentUser.id, sessionId); - setSessions(prev => prev.filter(s => s.id !== sessionId)); - }; - - - - if (!currentUser) { - return ; + if (!currentUser && location.pathname !== '/login') { + return ; } return (
- - {/* Desktop Navigation Rail (Left) */} - + {currentUser && ( + + )} {/* Main Content Area */}
- {currentTab === 'TRACK' && ( - - )} - {currentTab === 'PLANS' && ( - - )} - {currentTab === 'HISTORY' && ( - - )} - {currentTab === 'STATS' && } - {currentTab === 'AI_COACH' && } - {currentTab === 'PROFILE' && ( - - )} + + + ) : ( + + ) + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + } /> +
- - {/* Mobile Navigation (rendered inside Navbar component, fixed to bottom) */}
); } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 13f0abe..b78e245 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,47 +1,48 @@ - import React from 'react'; import { Dumbbell, History as HistoryIcon, BarChart2, MessageSquare, ClipboardList, User } from 'lucide-react'; -import { TabView, Language } from '../types'; +import { useLocation, useNavigate } from 'react-router-dom'; import { t } from '../services/i18n'; +import { Language } from '../types'; interface NavbarProps { - currentTab: TabView; - onTabChange: (tab: TabView) => void; lang: Language; } -const Navbar: React.FC = ({ currentTab, onTabChange, lang }) => { +const Navbar: React.FC = ({ lang }) => { + const navigate = useNavigate(); + const location = useLocation(); + const navItems = [ - { id: 'TRACK' as TabView, icon: Dumbbell, label: t('tab_tracker', lang) }, - { id: 'PLANS' as TabView, icon: ClipboardList, label: t('tab_plans', lang) }, - { id: 'HISTORY' as TabView, icon: HistoryIcon, label: t('tab_history', lang) }, - { id: 'STATS' as TabView, icon: BarChart2, label: t('tab_stats', lang) }, - { id: 'AI_COACH' as TabView, icon: MessageSquare, label: t('tab_ai', lang) }, - { id: 'PROFILE' as TabView, icon: User, label: t('tab_profile', lang) }, + { path: '/', icon: Dumbbell, label: t('tab_tracker', lang) }, + { path: '/plans', icon: ClipboardList, label: t('tab_plans', lang) }, + { path: '/history', icon: HistoryIcon, label: t('tab_history', lang) }, + { path: '/stats', icon: BarChart2, label: t('tab_stats', lang) }, + { path: '/coach', icon: MessageSquare, label: t('tab_ai', lang) }, + { path: '/profile', icon: User, label: t('tab_profile', lang) }, ]; + const currentPath = location.pathname; + return ( <> {/* MOBILE: Bottom Navigation Bar (MD3) */}
{navItems.map((item) => { - const isActive = currentTab === item.id; + const isActive = currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path)); return ( ); @@ -51,29 +52,27 @@ const Navbar: React.FC = ({ currentTab, onTabChange, lang }) => { {/* DESKTOP: Navigation Rail (MD3) */}
-
- {navItems.map((item) => { - const isActive = currentTab === item.id; - return ( - - ); - })} -
+
+ {navItems.map((item) => { + const isActive = currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path)); + return ( + + ); + })} +
); diff --git a/src/components/Tracker/useTracker.ts b/src/components/Tracker/useTracker.ts index ca5fc53..665223c 100644 --- a/src/components/Tracker/useTracker.ts +++ b/src/components/Tracker/useTracker.ts @@ -1,8 +1,10 @@ import { useState, useEffect } from 'react'; -import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../../types'; -import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../../services/storage'; +import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan } from '../../types'; +import { getExercises, saveExercise, getPlans } from '../../services/storage'; import { api } from '../../services/api'; - +import { useSessionTimer } from '../../hooks/useSessionTimer'; +import { useWorkoutForm } from '../../hooks/useWorkoutForm'; +import { usePlanExecution } from '../../hooks/usePlanExecution'; interface UseTrackerProps { userId: string; @@ -25,9 +27,7 @@ export const useTracker = ({ activePlan, onSessionStart, onSessionEnd, - onSessionQuit, onSetAdded, - onRemoveSet, onUpdateSet, onSporadicSetAdded }: UseTrackerProps) => { @@ -38,49 +38,28 @@ export const useTracker = ({ const [searchQuery, setSearchQuery] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); - // Timer State - const [elapsedTime, setElapsedTime] = useState('00:00:00'); - - // Form State - const [weight, setWeight] = useState(''); - const [reps, setReps] = useState(''); - const [duration, setDuration] = useState(''); - const [distance, setDistance] = useState(''); - const [height, setHeight] = useState(''); - const [bwPercentage, setBwPercentage] = useState('100'); - // User Weight State const [userBodyWeight, setUserBodyWeight] = useState(userWeight ? userWeight.toString() : '70'); // Create Exercise State const [isCreating, setIsCreating] = useState(false); - // Plan Execution State - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [showPlanPrep, setShowPlanPrep] = useState(null); - const [showPlanList, setShowPlanList] = useState(false); - // Confirmation State const [showFinishConfirm, setShowFinishConfirm] = useState(false); const [showQuitConfirm, setShowQuitConfirm] = useState(false); const [showMenu, setShowMenu] = useState(false); - // Edit Set State - const [editingSetId, setEditingSetId] = useState(null); - const [editWeight, setEditWeight] = useState(''); - const [editReps, setEditReps] = useState(''); - const [editDuration, setEditDuration] = useState(''); - const [editDistance, setEditDistance] = useState(''); - const [editHeight, setEditHeight] = useState(''); - // Quick Log State const [quickLogSession, setQuickLogSession] = useState(null); const [isSporadicMode, setIsSporadicMode] = useState(false); const [sporadicSuccess, setSporadicSuccess] = useState(false); - // Unilateral Exercise State - const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT'); + // Hooks + const elapsedTime = useSessionTimer(activeSession); + const form = useWorkoutForm({ userId, onUpdateSet }); + const planExec = usePlanExecution({ activeSession, activePlan, exercises }); + // Initial Data Load useEffect(() => { const loadData = async () => { const exList = await getExercises(userId); @@ -95,15 +74,7 @@ export const useTracker = ({ setUserBodyWeight(userWeight.toString()); } - // Load Quick Log Session - try { - const response = await api.get('/sessions/quick-log'); - if (response.success && response.session) { - setQuickLogSession(response.session); - } - } catch (error) { - console.error("Failed to load quick log session:", error); - } + loadQuickLogSession(); }; loadData(); }, [activeSession, userId, userWeight, activePlan]); @@ -120,107 +91,30 @@ export const useTracker = ({ } }; - - - // Timer Logic + // Auto-select exercise from plan step useEffect(() => { - let interval: number; - if (activeSession) { - const updateTimer = () => { - const diff = Math.floor((Date.now() - activeSession.startTime) / 1000); - const h = Math.floor(diff / 3600); - const m = Math.floor((diff % 3600) / 60); - const s = diff % 60; - setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`); - }; - - updateTimer(); - interval = window.setInterval(updateTimer, 1000); - } - return () => clearInterval(interval); - }, [activeSession]); - - // Recalculate current step when sets change - useEffect(() => { - if (activeSession && activePlan) { - const performedCounts = new Map(); - for (const set of activeSession.sets) { - performedCounts.set(set.exerciseId, (performedCounts.get(set.exerciseId) || 0) + 1); - } - - let nextStepIndex = activePlan.steps.length; // Default to finished - const plannedCounts = new Map(); - for (let i = 0; i < activePlan.steps.length; i++) { - const step = activePlan.steps[i]; - const exerciseId = step.exerciseId; - plannedCounts.set(exerciseId, (plannedCounts.get(exerciseId) || 0) + 1); - const performedCount = performedCounts.get(exerciseId) || 0; - - if (performedCount < plannedCounts.get(exerciseId)!) { - nextStepIndex = i; - break; - } - } - setCurrentStepIndex(nextStepIndex); - } - }, [activeSession, activePlan]); - - useEffect(() => { - if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) { - if (currentStepIndex < activePlan.steps.length) { - const step = activePlan.steps[currentStepIndex]; - if (step) { - const exDef = exercises.find(e => e.id === step.exerciseId); - if (exDef) { - setSelectedExercise(exDef); - } - } + const step = planExec.getCurrentStep(); + if (step) { + const exDef = exercises.find(e => e.id === step.exerciseId); + if (exDef) { + setSelectedExercise(exDef); } } - }, [currentStepIndex, activePlan, exercises]); + }, [planExec.currentStepIndex, activePlan, exercises]); + // Update form when exercise changes useEffect(() => { const updateSelection = async () => { if (selectedExercise) { - setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100'); setSearchQuery(selectedExercise.name); - const set = await getLastSetForExercise(userId, selectedExercise.id); - setLastSet(set); - - if (set) { - setWeight(set.weight?.toString() || ''); - setReps(set.reps?.toString() || ''); - setDuration(set.durationSeconds?.toString() || ''); - setDistance(set.distanceMeters?.toString() || ''); - setHeight(set.height?.toString() || ''); - } else { - setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight(''); - } - - // Clear fields not relevant to the selected exercise type - if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT) { - setWeight(''); - } - if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT && selectedExercise.type !== ExerciseType.PLYOMETRIC) { - setReps(''); - } - if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.STATIC) { - setDuration(''); - } - if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.LONG_JUMP) { - setDistance(''); - } - if (selectedExercise.type !== ExerciseType.HIGH_JUMP) { - setHeight(''); - } + await form.updateFormFromLastSet(selectedExercise.id, selectedExercise.type, selectedExercise.bodyWeightPercentage); } else { - setSearchQuery(''); // Clear search query if no exercise is selected + setSearchQuery(''); } }; updateSelection(); }, [selectedExercise, userId]); - const filteredExercises = searchQuery === '' ? exercises : exercises.filter(ex => @@ -229,58 +123,23 @@ export const useTracker = ({ const handleStart = (plan?: WorkoutPlan) => { if (plan && plan.description) { - setShowPlanPrep(plan); + planExec.setShowPlanPrep(plan); } else { onSessionStart(plan, parseFloat(userBodyWeight)); } }; const confirmPlanStart = () => { - if (showPlanPrep) { - onSessionStart(showPlanPrep, parseFloat(userBodyWeight)); - setShowPlanPrep(null); + if (planExec.showPlanPrep) { + onSessionStart(planExec.showPlanPrep, parseFloat(userBodyWeight)); + planExec.setShowPlanPrep(null); } } const handleAddSet = async () => { if (!activeSession || !selectedExercise) return; - const setData: Partial = { - exerciseId: selectedExercise.id, - }; - - if (selectedExercise.isUnilateral) { - setData.side = unilateralSide; - } - - switch (selectedExercise.type) { - case ExerciseType.STRENGTH: - if (weight) setData.weight = parseFloat(weight); - if (reps) setData.reps = parseInt(reps); - break; - case ExerciseType.BODYWEIGHT: - if (weight) setData.weight = parseFloat(weight); - if (reps) setData.reps = parseInt(reps); - setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; - break; - case ExerciseType.CARDIO: - if (duration) setData.durationSeconds = parseInt(duration); - if (distance) setData.distanceMeters = parseFloat(distance); - break; - case ExerciseType.STATIC: - if (duration) setData.durationSeconds = parseInt(duration); - setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; - break; - case ExerciseType.HIGH_JUMP: - if (height) setData.height = parseFloat(height); - break; - case ExerciseType.LONG_JUMP: - if (distance) setData.distanceMeters = parseFloat(distance); - break; - case ExerciseType.PLYOMETRIC: - if (reps) setData.reps = parseInt(reps); - break; - } + const setData = form.prepareSetData(selectedExercise); try { const response = await api.post('/sessions/active/log-set', setData); @@ -291,11 +150,10 @@ export const useTracker = ({ if (activePlan && activeExerciseId) { const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId); if (nextStepIndex !== -1) { - setCurrentStepIndex(nextStepIndex); + planExec.setCurrentStepIndex(nextStepIndex); } } else if (activePlan && !activeExerciseId) { - // Plan is finished - setCurrentStepIndex(activePlan.steps.length); + planExec.setCurrentStepIndex(activePlan.steps.length); } } } catch (error) { @@ -305,62 +163,15 @@ export const useTracker = ({ const handleLogSporadicSet = async () => { if (!selectedExercise) return; - - const setData: any = { - exerciseId: selectedExercise.id, - }; - - if (selectedExercise.isUnilateral) { - setData.side = unilateralSide; - } - - switch (selectedExercise.type) { - case ExerciseType.STRENGTH: - if (weight) setData.weight = parseFloat(weight); - if (reps) setData.reps = parseInt(reps); - break; - case ExerciseType.BODYWEIGHT: - if (weight) setData.weight = parseFloat(weight); - if (reps) setData.reps = parseInt(reps); - setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; - break; - case ExerciseType.CARDIO: - if (duration) setData.durationSeconds = parseInt(duration); - if (distance) setData.distanceMeters = parseFloat(distance); - break; - case ExerciseType.STATIC: - if (duration) setData.durationSeconds = parseInt(duration); - setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; - break; - case ExerciseType.HIGH_JUMP: - if (height) setData.height = parseFloat(height); - break; - case ExerciseType.LONG_JUMP: - if (distance) setData.distanceMeters = parseFloat(distance); - break; - case ExerciseType.PLYOMETRIC: - if (reps) setData.reps = parseInt(reps); - break; - } + const setData = form.prepareSetData(selectedExercise); try { const response = await api.post('/sessions/quick-log/set', setData); if (response.success) { setSporadicSuccess(true); setTimeout(() => setSporadicSuccess(false), 2000); - - // Refresh quick log session - const sessionRes = await api.get('/sessions/quick-log'); - if (sessionRes.success && sessionRes.session) { - setQuickLogSession(sessionRes.session); - } - - // Reset form - setWeight(''); - setReps(''); - setDuration(''); - setDistance(''); - setHeight(''); + loadQuickLogSession(); + form.resetForm(); if (onSporadicSetAdded) onSporadicSetAdded(); } } catch (error) { @@ -376,44 +187,14 @@ export const useTracker = ({ setIsCreating(false); }; - const handleEditSet = (set: WorkoutSet) => { - setEditingSetId(set.id); - setEditWeight(set.weight?.toString() || ''); - setEditReps(set.reps?.toString() || ''); - setEditDuration(set.durationSeconds?.toString() || ''); - setEditDistance(set.distanceMeters?.toString() || ''); - setEditHeight(set.height?.toString() || ''); - }; - - const handleSaveEdit = (set: WorkoutSet) => { - const updatedSet: WorkoutSet = { - ...set, - ...(editWeight && { weight: parseFloat(editWeight) }), - ...(editReps && { reps: parseInt(editReps) }), - ...(editDuration && { durationSeconds: parseInt(editDuration) }), - ...(editDistance && { distanceMeters: parseFloat(editDistance) }), - ...(editHeight && { height: parseFloat(editHeight) }) - }; - onUpdateSet(updatedSet); - setEditingSetId(null); - }; - - const handleCancelEdit = () => { - setEditingSetId(null); - }; - - const jumpToStep = (index: number) => { - if (!activePlan) return; - setCurrentStepIndex(index); - setShowPlanList(false); - }; + // Forwarding form handlers from hook + const handleEditSet = form.startEditing; + const handleSaveEdit = form.saveEdit; + const handleCancelEdit = form.cancelEdit; + // Reset override const resetForm = () => { - setWeight(''); - setReps(''); - setDuration(''); - setDistance(''); - setHeight(''); + form.resetForm(); setSelectedExercise(null); setSearchQuery(''); setSporadicSuccess(false); @@ -431,46 +212,37 @@ export const useTracker = ({ showSuggestions, setShowSuggestions, elapsedTime, - weight, - setWeight, - reps, - setReps, - duration, - setDuration, - distance, - setDistance, - height, - setHeight, - bwPercentage, - setBwPercentage, - userBodyWeight, - setUserBodyWeight, - isCreating, - setIsCreating, - currentStepIndex, - showPlanPrep, - setShowPlanPrep, - showPlanList, - setShowPlanList, - showFinishConfirm, - setShowFinishConfirm, - showQuitConfirm, - setShowQuitConfirm, - showMenu, - setShowMenu, - editingSetId, - editWeight, - setEditWeight, - editReps, - setEditReps, - editDuration, - setEditDuration, - editDistance, - setEditDistance, - editHeight, - setEditHeight, - isSporadicMode, - setIsSporadicMode, + // Form Props + weight: form.weight, setWeight: form.setWeight, + reps: form.reps, setReps: form.setReps, + duration: form.duration, setDuration: form.setDuration, + distance: form.distance, setDistance: form.setDistance, + height: form.height, setHeight: form.setHeight, + bwPercentage: form.bwPercentage, setBwPercentage: form.setBwPercentage, + unilateralSide: form.unilateralSide, setUnilateralSide: form.setUnilateralSide, + + userBodyWeight, setUserBodyWeight, + isCreating, setIsCreating, + + // Plan Execution Props + currentStepIndex: planExec.currentStepIndex, + showPlanPrep: planExec.showPlanPrep, setShowPlanPrep: planExec.setShowPlanPrep, + showPlanList: planExec.showPlanList, setShowPlanList: planExec.setShowPlanList, + jumpToStep: planExec.jumpToStep, + + showFinishConfirm, setShowFinishConfirm, + showQuitConfirm, setShowQuitConfirm, + showMenu, setShowMenu, + + // Editing + editingSetId: form.editingSetId, + editWeight: form.editWeight, setEditWeight: form.setEditWeight, + editReps: form.editReps, setEditReps: form.setEditReps, + editDuration: form.editDuration, setEditDuration: form.setEditDuration, + editDistance: form.editDistance, setEditDistance: form.setEditDistance, + editHeight: form.editHeight, setEditHeight: form.setEditHeight, + + isSporadicMode, setIsSporadicMode, sporadicSuccess, filteredExercises, handleStart, @@ -481,11 +253,8 @@ export const useTracker = ({ handleEditSet, handleSaveEdit, handleCancelEdit, - jumpToStep, resetForm, - unilateralSide, - setUnilateralSide, - quickLogSession, // Export this - loadQuickLogSession, // Export reload function + quickLogSession, + loadQuickLogSession }; }; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..7db8cb5 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { User } from '../types'; +import { getMe } from '../services/auth'; + +interface AuthContextType { + currentUser: User | null; + setCurrentUser: (user: User | null) => void; + isLoading: boolean; + logout: () => void; + updateUser: (user: User) => void; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [currentUser, setCurrentUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const restoreSession = async () => { + const token = localStorage.getItem('token'); + if (token) { + try { + const res = await getMe(); + if (res.success && res.user) { + setCurrentUser(res.user); + } else { + localStorage.removeItem('token'); + } + } catch (e) { + localStorage.removeItem('token'); + } + } + setIsLoading(false); + }; + restoreSession(); + }, []); + + const logout = () => { + localStorage.removeItem('token'); + setCurrentUser(null); + }; + + const updateUser = (user: User) => { + setCurrentUser(user); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/context/DataContext.tsx b/src/context/DataContext.tsx new file mode 100644 index 0000000..e6e0f21 --- /dev/null +++ b/src/context/DataContext.tsx @@ -0,0 +1,208 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { WorkoutSession, WorkoutPlan, WorkoutSet } from '../types'; +import { useAuth } from './AuthContext'; +import { + getSessions, + getPlans, + getActiveSession, + saveSession, + deleteSession, + updateActiveSession, + deleteActiveSession, + deleteSetFromActiveSession, + updateSetInActiveSession +} from '../services/storage'; +import { getCurrentUserProfile, getMe } from '../services/auth'; +import { generateId } from '../utils/uuid'; +import { logWeight } from '../services/weight'; +import { useNavigate } from 'react-router-dom'; + +interface DataContextType { + sessions: WorkoutSession[]; + plans: WorkoutPlan[]; + activeSession: WorkoutSession | null; + activePlan: WorkoutPlan | null; + startSession: (plan?: WorkoutPlan, startWeight?: number) => Promise; + endSession: () => Promise; + quitSession: () => Promise; + addSet: (set: WorkoutSet) => void; + removeSet: (setId: string) => Promise; + updateSet: (updatedSet: WorkoutSet) => Promise; + updateSession: (updatedSession: WorkoutSession) => void; + deleteSessionById: (sessionId: string) => void; + refreshData: () => Promise; +} + +const DataContext = createContext(undefined); + +export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { currentUser, updateUser } = useAuth(); + const navigate = useNavigate(); + + const [sessions, setSessions] = useState([]); + const [plans, setPlans] = useState([]); + const [activeSession, setActiveSession] = useState(null); + const [activePlan, setActivePlan] = useState(null); + + const refreshData = async () => { + if (currentUser) { + const s = await getSessions(currentUser.id); + setSessions(s); + const p = await getPlans(currentUser.id); + setPlans(p); + } else { + setSessions([]); + setPlans([]); + } + }; + + useEffect(() => { + refreshData(); + }, [currentUser]); + + // Restore active session + useEffect(() => { + const restoreActive = async () => { + if (currentUser) { + const session = await getActiveSession(currentUser.id); + if (session) { + setActiveSession(session); + if (session.planId) { + // Ensure plans are loaded or fetch specifically + const currentPlans = plans.length > 0 ? plans : await getPlans(currentUser.id); + const plan = currentPlans.find(p => p.id === session.planId); + if (plan) setActivePlan(plan); + } + } + } + }; + restoreActive(); + }, [currentUser]); // Dependency logic might need tuning, but this matches App.tsx roughly + + const startSession = async (plan?: WorkoutPlan, startWeight?: number) => { + if (!currentUser || activeSession) return; + + const profile = getCurrentUserProfile(currentUser.id); + const currentWeight = startWeight || profile?.weight || 70; + + const newSession: WorkoutSession = { + id: generateId(), + startTime: Date.now(), + type: 'STANDARD', + userBodyWeight: currentWeight, + sets: [], + planId: plan?.id, + planName: plan?.name + }; + + setActivePlan(plan || null); + setActiveSession(newSession); + navigate('/'); + + await saveSession(currentUser.id, newSession); + + if (startWeight) { + await logWeight(startWeight); + } + }; + + const endSession = async () => { + if (activeSession && currentUser) { + const finishedSession = { ...activeSession, endTime: Date.now() }; + await updateActiveSession(currentUser.id, finishedSession); + setSessions(prev => [finishedSession, ...prev]); + setActiveSession(null); + setActivePlan(null); + + const res = await getMe(); + if (res.success && res.user) { + updateUser(res.user); + } + } + }; + + const quitSession = async () => { + if (currentUser) { + await deleteActiveSession(currentUser.id); + setActiveSession(null); + setActivePlan(null); + } + }; + + const addSet = (set: WorkoutSet) => { + if (activeSession) { + const updatedSession = { ...activeSession, sets: [...activeSession.sets, set] }; + setActiveSession(updatedSession); + // Context update is optimistic, actual save usually happens in hooks or components? + // In App.tsx handleAddSet only updated local state. + // Wait, useTracker usually handles saving sets via API? + // In App.tsx: handleAddSet just set state. + // useTracker.ts calls onSetAdded, but ALSO calls api to save it? + // Let's look at useTracker.ts. + // handleLogSet in useTracker calls API then onSetAdded. + // So this state update is mainly for UI sync in App. + } + }; + + const removeSet = async (setId: string) => { + if (activeSession && currentUser) { + await deleteSetFromActiveSession(currentUser.id, setId); + const updatedSession = { + ...activeSession, + sets: activeSession.sets.filter(s => s.id !== setId) + }; + setActiveSession(updatedSession); + } + }; + + const updateSet = async (updatedSet: WorkoutSet) => { + if (activeSession && currentUser) { + const response = await updateSetInActiveSession(currentUser.id, updatedSet.id, updatedSet); + const updatedSession = { + ...activeSession, + sets: activeSession.sets.map(s => s.id === updatedSet.id ? response : s) + }; + setActiveSession(updatedSession); + } + }; + + const updateSession = (updatedSession: WorkoutSession) => { + if (!currentUser) return; + saveSession(currentUser.id, updatedSession); + setSessions(prev => prev.map(s => s.id === updatedSession.id ? updatedSession : s)); + }; + + const deleteSessionById = (sessionId: string) => { + if (!currentUser) return; + deleteSession(currentUser.id, sessionId); + setSessions(prev => prev.filter(s => s.id !== sessionId)); + }; + + return ( + + {children} + + ); +}; + +export const useData = () => { + const context = useContext(DataContext); + if (context === undefined) { + throw new Error('useData must be used within a DataProvider'); + } + return context; +}; diff --git a/src/hooks/usePlanExecution.ts b/src/hooks/usePlanExecution.ts new file mode 100644 index 0000000..86962a1 --- /dev/null +++ b/src/hooks/usePlanExecution.ts @@ -0,0 +1,65 @@ +import { useState, useEffect } from 'react'; +import { WorkoutSession, WorkoutPlan, ExerciseDef } from '../types'; + +interface UsePlanExecutionProps { + activeSession: WorkoutSession | null; + activePlan: WorkoutPlan | null; + exercises: ExerciseDef[]; +} + +export const usePlanExecution = ({ activeSession, activePlan, exercises }: UsePlanExecutionProps) => { + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [showPlanPrep, setShowPlanPrep] = useState(null); + const [showPlanList, setShowPlanList] = useState(false); + + // Automatically determine current step based on logged sets vs plan + useEffect(() => { + if (activeSession && activePlan) { + const performedCounts = new Map(); + for (const set of activeSession.sets) { + performedCounts.set(set.exerciseId, (performedCounts.get(set.exerciseId) || 0) + 1); + } + + let nextStepIndex = activePlan.steps.length; // Default to finished + const plannedCounts = new Map(); + for (let i = 0; i < activePlan.steps.length; i++) { + const step = activePlan.steps[i]; + const exerciseId = step.exerciseId; + plannedCounts.set(exerciseId, (plannedCounts.get(exerciseId) || 0) + 1); + const performedCount = performedCounts.get(exerciseId) || 0; + + if (performedCount < plannedCounts.get(exerciseId)!) { + nextStepIndex = i; + break; + } + } + setCurrentStepIndex(nextStepIndex); + } + }, [activeSession, activePlan]); + + const getCurrentStep = () => { + if (activeSession && activePlan && activePlan.steps.length > 0) { + if (currentStepIndex < activePlan.steps.length) { + return activePlan.steps[currentStepIndex]; + } + } + return null; + }; + + const jumpToStep = (index: number) => { + if (!activePlan) return; + setCurrentStepIndex(index); + setShowPlanList(false); + }; + + return { + currentStepIndex, + setCurrentStepIndex, + showPlanPrep, + setShowPlanPrep, + showPlanList, + setShowPlanList, + getCurrentStep, + jumpToStep + }; +}; diff --git a/src/hooks/useSessionTimer.ts b/src/hooks/useSessionTimer.ts new file mode 100644 index 0000000..80002fe --- /dev/null +++ b/src/hooks/useSessionTimer.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from 'react'; +import { WorkoutSession } from '../types'; + +export const useSessionTimer = (activeSession: WorkoutSession | null) => { + const [elapsedTime, setElapsedTime] = useState('00:00:00'); + + useEffect(() => { + let interval: number; + if (activeSession) { + const updateTimer = () => { + const diff = Math.floor((Date.now() - activeSession.startTime) / 1000); + const h = Math.floor(diff / 3600); + const m = Math.floor((diff % 3600) / 60); + const s = diff % 60; + setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`); + }; + + updateTimer(); + interval = window.setInterval(updateTimer, 1000); + } else { + setElapsedTime('00:00:00'); + } + return () => clearInterval(interval); + }, [activeSession]); + + return elapsedTime; +}; diff --git a/src/hooks/useWorkoutForm.ts b/src/hooks/useWorkoutForm.ts new file mode 100644 index 0000000..f9f5c41 --- /dev/null +++ b/src/hooks/useWorkoutForm.ts @@ -0,0 +1,147 @@ +import { useState, useEffect } from 'react'; +import { WorkoutSet, ExerciseDef, ExerciseType } from '../types'; +import { getLastSetForExercise } from '../services/storage'; + +interface UseWorkoutFormProps { + userId: string; + onSetAdded?: (set: WorkoutSet) => void; + onUpdateSet?: (set: WorkoutSet) => void; +} + +export const useWorkoutForm = ({ userId, onSetAdded, onUpdateSet }: UseWorkoutFormProps) => { + const [weight, setWeight] = useState(''); + const [reps, setReps] = useState(''); + const [duration, setDuration] = useState(''); + const [distance, setDistance] = useState(''); + const [height, setHeight] = useState(''); + const [bwPercentage, setBwPercentage] = useState('100'); + + // Unilateral State + const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT'); + + // Editing State + const [editingSetId, setEditingSetId] = useState(null); + const [editWeight, setEditWeight] = useState(''); + const [editReps, setEditReps] = useState(''); + const [editDuration, setEditDuration] = useState(''); + const [editDistance, setEditDistance] = useState(''); + const [editHeight, setEditHeight] = useState(''); + + const resetForm = () => { + setWeight(''); + setReps(''); + setDuration(''); + setDistance(''); + setHeight(''); + }; + + const updateFormFromLastSet = async (exerciseId: string, exerciseType: ExerciseType, bodyWeightPercentage?: number) => { + setBwPercentage(bodyWeightPercentage ? bodyWeightPercentage.toString() : '100'); + + const set = await getLastSetForExercise(userId, exerciseId); + if (set) { + setWeight(set.weight?.toString() || ''); + setReps(set.reps?.toString() || ''); + setDuration(set.durationSeconds?.toString() || ''); + setDistance(set.distanceMeters?.toString() || ''); + setHeight(set.height?.toString() || ''); + } else { + resetForm(); + } + + // Clear irrelevant fields + if (exerciseType !== ExerciseType.STRENGTH && exerciseType !== ExerciseType.BODYWEIGHT) setWeight(''); + if (exerciseType !== ExerciseType.STRENGTH && exerciseType !== ExerciseType.BODYWEIGHT && exerciseType !== ExerciseType.PLYOMETRIC) setReps(''); + if (exerciseType !== ExerciseType.CARDIO && exerciseType !== ExerciseType.STATIC) setDuration(''); + if (exerciseType !== ExerciseType.CARDIO && exerciseType !== ExerciseType.LONG_JUMP) setDistance(''); + if (exerciseType !== ExerciseType.HIGH_JUMP) setHeight(''); + }; + + const prepareSetData = (selectedExercise: ExerciseDef, isSporadic: boolean = false) => { + const setData: Partial = { + exerciseId: selectedExercise.id, + }; + + if (selectedExercise.isUnilateral) { + setData.side = unilateralSide; + } + + switch (selectedExercise.type) { + case ExerciseType.STRENGTH: + if (weight) setData.weight = parseFloat(weight); + if (reps) setData.reps = parseInt(reps); + break; + case ExerciseType.BODYWEIGHT: + if (weight) setData.weight = parseFloat(weight); + if (reps) setData.reps = parseInt(reps); + setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; + break; + case ExerciseType.CARDIO: + if (duration) setData.durationSeconds = parseInt(duration); + if (distance) setData.distanceMeters = parseFloat(distance); + break; + case ExerciseType.STATIC: + if (duration) setData.durationSeconds = parseInt(duration); + setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; + break; + case ExerciseType.HIGH_JUMP: + if (height) setData.height = parseFloat(height); + break; + case ExerciseType.LONG_JUMP: + if (distance) setData.distanceMeters = parseFloat(distance); + break; + case ExerciseType.PLYOMETRIC: + if (reps) setData.reps = parseInt(reps); + break; + } + return setData; + }; + + const startEditing = (set: WorkoutSet) => { + setEditingSetId(set.id); + setEditWeight(set.weight?.toString() || ''); + setEditReps(set.reps?.toString() || ''); + setEditDuration(set.durationSeconds?.toString() || ''); + setEditDistance(set.distanceMeters?.toString() || ''); + setEditHeight(set.height?.toString() || ''); + }; + + const saveEdit = (set: WorkoutSet) => { + const updatedSet: WorkoutSet = { + ...set, + ...(editWeight && { weight: parseFloat(editWeight) }), + ...(editReps && { reps: parseInt(editReps) }), + ...(editDuration && { durationSeconds: parseInt(editDuration) }), + ...(editDistance && { distanceMeters: parseFloat(editDistance) }), + ...(editHeight && { height: parseFloat(editHeight) }) + }; + if (onUpdateSet) onUpdateSet(updatedSet); + setEditingSetId(null); + }; + + const cancelEdit = () => { + setEditingSetId(null); + }; + + return { + weight, setWeight, + reps, setReps, + duration, setDuration, + distance, setDistance, + height, setHeight, + bwPercentage, setBwPercentage, + unilateralSide, setUnilateralSide, + editingSetId, + editWeight, setEditWeight, + editReps, setEditReps, + editDuration, setEditDuration, + editDistance, setEditDistance, + editHeight, setEditHeight, + resetForm, + updateFormFromLastSet, + prepareSetData, + startEditing, + saveEdit, + cancelEdit + }; +}; diff --git a/src/index.tsx b/src/index.tsx index 7e3fd18..9f23c20 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext'; +import { DataProvider } from './context/DataContext'; import App from './App'; import './index.css'; @@ -11,6 +14,12 @@ if (!rootElement) { const root = ReactDOM.createRoot(rootElement); root.render( - + + + + + + + ); \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts index ddbf0ef..cbd3466 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -13,12 +13,12 @@ const headers = () => { }; export const api = { - get: async (endpoint: string) => { + get: async (endpoint: string): Promise => { const res = await fetch(`${API_URL}${endpoint}`, { headers: headers() }); if (!res.ok) throw new Error(await res.text()); return res.json(); }, - post: async (endpoint: string, data: any) => { + post: async (endpoint: string, data: any): Promise => { const res = await fetch(`${API_URL}${endpoint}`, { method: 'POST', headers: headers(), @@ -27,7 +27,7 @@ export const api = { if (!res.ok) throw new Error(await res.text()); return res.json(); }, - put: async (endpoint: string, data: any) => { + put: async (endpoint: string, data: any): Promise => { const res = await fetch(`${API_URL}${endpoint}`, { method: 'PUT', headers: headers(), @@ -36,7 +36,7 @@ export const api = { if (!res.ok) throw new Error(await res.text()); return res.json(); }, - delete: async (endpoint: string) => { + delete: async (endpoint: string): Promise => { const res = await fetch(`${API_URL}${endpoint}`, { method: 'DELETE', headers: headers() @@ -44,7 +44,7 @@ export const api = { if (!res.ok) throw new Error(await res.text()); return res.json(); }, - patch: async (endpoint: string, data: any) => { + patch: async (endpoint: string, data: any): Promise => { const res = await fetch(`${API_URL}${endpoint}`, { method: 'PATCH', headers: headers(), diff --git a/src/services/exercises.ts b/src/services/exercises.ts new file mode 100644 index 0000000..dd16f6a --- /dev/null +++ b/src/services/exercises.ts @@ -0,0 +1,27 @@ +import { ExerciseDef, WorkoutSet } from '../types'; +import { api } from './api'; + +export const getExercises = async (userId: string): Promise => { + try { + return await api.get('/exercises'); + } catch { + return []; + } +}; + +export const saveExercise = async (userId: string, exercise: ExerciseDef): Promise => { + await api.post('/exercises', exercise); +}; + +export const getLastSetForExercise = async (userId: string, exerciseId: string): Promise => { + try { + const response = await api.get<{ success: boolean; set?: WorkoutSet }>(`/exercises/${exerciseId}/last-set`); + if (response.success && response.set) { + return response.set; + } + return undefined; + } catch (error) { + console.error("Failed to fetch last set:", error); + return undefined; + } +}; diff --git a/src/services/plans.ts b/src/services/plans.ts new file mode 100644 index 0000000..836612e --- /dev/null +++ b/src/services/plans.ts @@ -0,0 +1,18 @@ +import { WorkoutPlan } from '../types'; +import { api } from './api'; + +export const getPlans = async (userId: string): Promise => { + try { + return await api.get('/plans'); + } catch { + return []; + } +}; + +export const savePlan = async (userId: string, plan: WorkoutPlan): Promise => { + await api.post('/plans', plan); +}; + +export const deletePlan = async (userId: string, id: string): Promise => { + await api.delete(`/plans/${id}`); +}; diff --git a/src/services/sessions.ts b/src/services/sessions.ts new file mode 100644 index 0000000..9f2fa4c --- /dev/null +++ b/src/services/sessions.ts @@ -0,0 +1,85 @@ +import { WorkoutSession, WorkoutSet, ExerciseType } from '../types'; +import { api } from './api'; + +// Define the shape of session coming from API (Prisma include) +interface ApiSession extends Omit { + startTime: string | number; // JSON dates are strings + endTime?: string | number; + sets: (Omit & { + exercise?: { + name: string; + type: ExerciseType; + } + })[]; +} + +export const getSessions = async (userId: string): Promise => { + try { + const sessions = await api.get('/sessions'); + // Convert ISO date strings to timestamps + return sessions.map((session) => ({ + ...session, + startTime: new Date(session.startTime).getTime(), + endTime: session.endTime ? new Date(session.endTime).getTime() : undefined, + sets: session.sets.map((set) => ({ + ...set, + exerciseName: set.exercise?.name || 'Unknown', + type: set.exercise?.type || ExerciseType.STRENGTH + })) as WorkoutSet[] + })); + } catch { + return []; + } +}; + +export const saveSession = async (userId: string, session: WorkoutSession): Promise => { + await api.post('/sessions', session); +}; + +export const getActiveSession = async (userId: string): Promise => { + try { + const response = await api.get<{ success: boolean; session?: ApiSession }>('/sessions/active'); + if (!response.success || !response.session) { + return null; + } + const session = response.session; + // Convert ISO date strings to timestamps + return { + ...session, + startTime: new Date(session.startTime).getTime(), + endTime: session.endTime ? new Date(session.endTime).getTime() : undefined, + sets: session.sets.map((set) => ({ + ...set, + exerciseName: set.exercise?.name || 'Unknown', + type: set.exercise?.type || ExerciseType.STRENGTH + })) as WorkoutSet[] + }; + } catch { + return null; + } +}; + +export const updateActiveSession = async (userId: string, session: WorkoutSession): Promise => { + await api.put('/sessions/active', session); +}; + +export const deleteSetFromActiveSession = async (userId: string, setId: string): Promise => { + await api.delete(`/sessions/active/set/${setId}`); +}; + +export const updateSetInActiveSession = async (userId: string, setId: string, setData: Partial): Promise => { + const response = await api.put<{ success: boolean; updatedSet: WorkoutSet }>(`/sessions/active/set/${setId}`, setData); + return response.updatedSet; +}; + +export const deleteActiveSession = async (userId: string): Promise => { + await api.delete('/sessions/active'); +}; + +export const deleteSession = async (userId: string, id: string): Promise => { + await api.delete(`/sessions/${id}`); +}; + +export const deleteAllUserData = (userId: string) => { + // Not implemented in frontend +}; diff --git a/src/services/storage.ts b/src/services/storage.ts index 3d51e4b..fa38cc4 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,114 +1,3 @@ -import { WorkoutSession, ExerciseDef, ExerciseType, WorkoutSet, WorkoutPlan } from '../types'; -import { api } from './api'; - -export const getSessions = async (userId: string): Promise => { - try { - const sessions = await api.get('/sessions'); - // Convert ISO date strings to timestamps - return sessions.map((session: any) => ({ - ...session, - startTime: new Date(session.startTime).getTime(), - endTime: session.endTime ? new Date(session.endTime).getTime() : undefined, - sets: session.sets.map((set: any) => ({ - ...set, - exerciseName: set.exercise?.name || 'Unknown', - type: set.exercise?.type || 'STRENGTH' - })) - })); - } catch { - return []; - } -}; - -export const saveSession = async (userId: string, session: WorkoutSession): Promise => { - await api.post('/sessions', session); -}; - -export const getActiveSession = async (userId: string): Promise => { - try { - const response = await api.get('/sessions/active'); - if (!response.success || !response.session) { - return null; - } - const session = response.session; - // Convert ISO date strings to timestamps - return { - ...session, - startTime: new Date(session.startTime).getTime(), - endTime: session.endTime ? new Date(session.endTime).getTime() : undefined, - sets: session.sets.map((set: any) => ({ - ...set, - exerciseName: set.exercise?.name || 'Unknown', - type: set.exercise?.type || 'STRENGTH' - })) - }; - } catch { - return null; - } -}; - -export const updateActiveSession = async (userId: string, session: WorkoutSession): Promise => { - await api.put('/sessions/active', session); -}; - -export const deleteSetFromActiveSession = async (userId: string, setId: string): Promise => { - await api.delete(`/sessions/active/set/${setId}`); -}; - -export const updateSetInActiveSession = async (userId: string, setId: string, setData: Partial): Promise => { - const response = await api.put(`/sessions/active/set/${setId}`, setData); - return response.updatedSet; -}; - -export const deleteActiveSession = async (userId: string): Promise => { - await api.delete('/sessions/active'); -}; - -export const deleteSession = async (userId: string, id: string): Promise => { - await api.delete(`/sessions/${id}`); -}; - -export const deleteAllUserData = (userId: string) => { - // Not implemented in frontend -}; - -export const getExercises = async (userId: string): Promise => { - try { - return await api.get('/exercises'); - } catch { - return []; - } -}; - -export const saveExercise = async (userId: string, exercise: ExerciseDef): Promise => { - await api.post('/exercises', exercise); -}; - -export const getLastSetForExercise = async (userId: string, exerciseId: string): Promise => { - try { - const response = await api.get(`/exercises/${exerciseId}/last-set`); - if (response.success && response.set) { - return response.set; - } - return undefined; - } catch (error) { - console.error("Failed to fetch last set:", error); - return undefined; - } -}; - -export const getPlans = async (userId: string): Promise => { - try { - return await api.get('/plans'); - } catch { - return []; - } -}; - -export const savePlan = async (userId: string, plan: WorkoutPlan): Promise => { - await api.post('/plans', plan); -}; - -export const deletePlan = async (userId: string, id: string): Promise => { - await api.delete(`/plans/${id}`); -}; \ No newline at end of file +export * from './exercises'; +export * from './sessions'; +export * from './plans'; \ No newline at end of file