diff --git a/.actrc b/.actrc new file mode 100644 index 0000000..61dbaa5 --- /dev/null +++ b/.actrc @@ -0,0 +1,2 @@ +--container-architecture linux/amd64 +-P self-hosted=catthehacker/ubuntu:act-latest diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md index 4a8f0a2..a2881b5 100644 --- a/PROJECT_OVERVIEW.md +++ b/PROJECT_OVERVIEW.md @@ -88,6 +88,11 @@ Basic relationships: - **Exercise Library**: Authenticated users can browse system exercises, create their own exercises, and tag them with muscle groups. - **Feedback**: Authenticated users submit feedback; server stores entry and can open a GitHub issue. +## Current Implementation Status + +- ✅ Exercise API + client CRUD flow is implemented (`/api/exercises`, `/api/muscle-groups`, `/exercises` page). +- 🚧 Workout API + client workflow is the primary remaining feature area. + ## API Surface (Current) - `POST /api/auth/*`: request-access, register, login, refresh-token, logout, forgot/reset-password diff --git a/README.md b/README.md index a7caf45..24b96f4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [](https://github.com/arcot-labs/RepTrack/actions/workflows/build.yml) [](https://github.com/arcot-labs/RepTrack/actions/workflows/deploy.yml) -### Local Development +# RepTrack + +## Local Development Copy `.env.example` to `.env` & populate variables @@ -25,8 +27,34 @@ Start containers: ./scripts/dev.sh ``` -### Database +## Local GitHub Actions Testing + +Use `act` to run workflows locally for quick validation. + +List all workflows: + +```bash +act -l +``` + +Run a specific job: + +```bash +act -j {job-id} +``` + +## Database Conventions All writes should go through SQLAlchemy Alembic updates & bulk SQLAlchemy updates must explicitly set `updated_at` + +## Shadcn Component Conventions + +shadcn adds components under `client/src/components/ui/` + +To ensure custom styles & behavior survive component updates, follow these conventions: + +- Create custom component overrides under `client/src/components/ui/overrides/` +- Import override components in app code instead of generated shadcn components +- Add ESLint rules to prevent direct imports of generated components & point to override paths diff --git a/client/eslint.config.js b/client/eslint.config.js index 39f3d18..8cf2ff6 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -46,6 +46,11 @@ export default defineConfig([ }, ], paths: [ + { + name: '@/components/ui/button', + message: + 'Use Button from @/components/ui/overrides/button', + }, { name: 'sonner', importNames: ['toast'], diff --git a/client/package-lock.json b/client/package-lock.json index 79d960c..dd266a4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,7 +8,7 @@ "name": "client", "version": "0.0.0", "dependencies": { - "@hey-api/openapi-ts": "^0.94.1", + "@hey-api/openapi-ts": "^0.94.2", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -48,14 +48,14 @@ "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0", + "typescript-eslint": "^8.57.1", "vite": "^7.3.1", "vite-tsconfig-paths": "^6.1.1" } @@ -1046,12 +1046,12 @@ "license": "MIT" }, "node_modules/@hey-api/codegen-core": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.2.tgz", - "integrity": "sha512-nWEyUNbc1O7R5FHMwPh24+127jCbIs6vT89ncHqFSprE0tUVNemGO3cZflZCeGw8XhJAk6O18TcruUbMmwv0Rg==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.3.tgz", + "integrity": "sha512-tRxwr8hPbPgETbcfxbg0K4nZZxUM51I0hEHfLEWLmsTbnpY/E4CU25/PyHXD0KdKQS4TwkAL2csEXFQth6AzBQ==", "license": "MIT", "dependencies": { - "@hey-api/types": "0.1.3", + "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" @@ -1061,9 +1061,6 @@ }, "funding": { "url": "https://github.com/sponsors/hey-api" - }, - "peerDependencies": { - "typescript": ">=5.5.3" } }, "node_modules/@hey-api/json-schema-ref-parser": { @@ -1084,18 +1081,19 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.94.1", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.94.1.tgz", - "integrity": "sha512-WffyHMzsB8uVrzVxjK+0zII0msuqLL5JOGaZsWQzRvoZQJsJcGe9+kUaBNKPVDupcghHncSRJCdcC5ZcCdy/sw==", + "version": "0.94.2", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.94.2.tgz", + "integrity": "sha512-k8BmVfRZ3Ntpt99+0wzbw18sssf9mMgCpUFi9hTdUTGCgXEFlCnM6HW6tWZjPtT4CbclNqLVvSrfIABrRE88YA==", "license": "MIT", "dependencies": { - "@hey-api/codegen-core": "0.7.2", + "@hey-api/codegen-core": "0.7.3", "@hey-api/json-schema-ref-parser": "1.3.1", - "@hey-api/shared": "0.2.3", - "@hey-api/types": "0.1.3", + "@hey-api/shared": "0.2.4", + "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "color-support": "1.1.3", - "commander": "14.0.3" + "commander": "14.0.3", + "get-tsconfig": "4.13.6" }, "bin": { "openapi-ts": "bin/run.js" @@ -1107,18 +1105,18 @@ "url": "https://github.com/sponsors/hey-api" }, "peerDependencies": { - "typescript": ">=5.5.3" + "typescript": ">=5.5.3 || 6.0.1-rc" } }, "node_modules/@hey-api/shared": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.2.3.tgz", - "integrity": "sha512-XQsI/VmQeoHJFdZmBshQnMLGRq6kvSIXFgpxsb8k4F8nuKZ+54GAnq0DuTZcgnL6egAO/pN0KBaFbyl/yl9WFg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.2.4.tgz", + "integrity": "sha512-a3YPByL+odEY9QnEh0uhPJZFgLxzB4YEss+8mexU4ZQ3sL3Y/BbEiDPhLT2HI9xXgagYK/KScy8J7Jm14Zma0g==", "license": "MIT", "dependencies": { - "@hey-api/codegen-core": "0.7.2", + "@hey-api/codegen-core": "0.7.3", "@hey-api/json-schema-ref-parser": "1.3.1", - "@hey-api/types": "0.1.3", + "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", "open": "11.0.0", @@ -1129,9 +1127,6 @@ }, "funding": { "url": "https://github.com/sponsors/hey-api" - }, - "peerDependencies": { - "typescript": ">=5.5.3" } }, "node_modules/@hey-api/shared/node_modules/semver": { @@ -1147,13 +1142,10 @@ } }, "node_modules/@hey-api/types": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.3.tgz", - "integrity": "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw==", - "license": "MIT", - "peerDependencies": { - "typescript": ">=5.5.3" - } + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz", + "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", + "license": "MIT" }, "node_modules/@hookform/resolvers": { "version": "5.2.2", @@ -5456,17 +5448,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", - "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/type-utils": "8.57.0", - "@typescript-eslint/utils": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -5479,7 +5471,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.0", + "@typescript-eslint/parser": "^8.57.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -5495,16 +5487,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3" }, "engines": { @@ -5520,14 +5512,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.0", - "@typescript-eslint/types": "^8.57.0", + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", "debug": "^4.4.3" }, "engines": { @@ -5542,14 +5534,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5560,9 +5552,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", "dev": true, "license": "MIT", "engines": { @@ -5577,15 +5569,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", - "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -5602,9 +5594,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", "dev": true, "license": "MIT", "engines": { @@ -5616,16 +5608,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5696,16 +5688,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", - "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0" + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5720,13 +5712,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5767,9 +5759,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { @@ -5784,7 +5776,7 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/acorn": { @@ -7456,9 +7448,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -7577,6 +7569,18 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -10294,6 +10298,15 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -10676,16 +10689,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", - "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0" + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/client/package.json b/client/package.json index 1db7d27..d192fc6 100644 --- a/client/package.json +++ b/client/package.json @@ -9,7 +9,7 @@ "generate-api": "openapi-ts", "lint": "eslint --fix --max-warnings=0", "preview": "vite preview", - "update-components": "for file in src/components/ui/*.tsx; do npx shadcn@latest add -o $(basename \"$file\" .tsx); done" + "update-components": "for file in src/components/ui/*.tsx; do npx shadcn@latest add -o $(basename \"$file\" .tsx); done && cd .. && npx prettier --write client/src/components/ui" }, "lint-staged": { "*": [ @@ -18,7 +18,7 @@ ] }, "dependencies": { - "@hey-api/openapi-ts": "^0.94.1", + "@hey-api/openapi-ts": "^0.94.2", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -58,14 +58,14 @@ "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0", + "typescript-eslint": "^8.57.1", "vite": "^7.3.1", "vite-tsconfig-paths": "^6.1.1" } diff --git a/client/src/AppRoutes.tsx b/client/src/AppRoutes.tsx index ad74c38..c7119d2 100644 --- a/client/src/AppRoutes.tsx +++ b/client/src/AppRoutes.tsx @@ -6,6 +6,7 @@ import { AppLayout } from '@/layout/AppLayout' import { Admin } from '@/pages/Admin' import { Dashboard } from '@/pages/Dashboard' import { Docs } from '@/pages/Docs' +import { Exercises } from '@/pages/Exercises' import { ForgotPassword } from '@/pages/ForgotPassword' import { Login } from '@/pages/Login' import { Register } from '@/pages/Register' @@ -25,6 +26,7 @@ export function AppRoutes() { } > } /> + } /> }> } /> } /> diff --git a/client/src/auth/RequireAuth.tsx b/client/src/auth/RequireAuth.tsx index f7d3942..8adb3f4 100644 --- a/client/src/auth/RequireAuth.tsx +++ b/client/src/auth/RequireAuth.tsx @@ -9,9 +9,9 @@ interface RequireAuthProps { } export function RequireAuth({ children, requireAdmin }: RequireAuthProps) { - const { loading, authenticated, user } = useSession() + const { isLoading, authenticated, user } = useSession() const location = useLocation() - if (loading) return + if (isLoading) return if (!authenticated) return if (requireAdmin && !user?.is_admin) return diff --git a/client/src/auth/RequireGuest.tsx b/client/src/auth/RequireGuest.tsx index a786c05..3101b52 100644 --- a/client/src/auth/RequireGuest.tsx +++ b/client/src/auth/RequireGuest.tsx @@ -5,10 +5,10 @@ import type { JSX } from 'react' import { Navigate, useLocation } from 'react-router-dom' export function RequireGuest({ children }: { children: JSX.Element }) { - const { loading, authenticated } = useSession() + const { isLoading, authenticated } = useSession() const location = useLocation() const state = location.state as LocationState | null - if (loading) return + if (isLoading) return if (authenticated) { const to = state?.from?.pathname ?? '/' return diff --git a/client/src/auth/SessionProvider.tsx b/client/src/auth/SessionProvider.tsx index 49f2b1b..6897567 100644 --- a/client/src/auth/SessionProvider.tsx +++ b/client/src/auth/SessionProvider.tsx @@ -5,10 +5,10 @@ import { type ReactNode, useEffect, useState } from 'react' export function SessionProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null) - const [loading, setLoading] = useState(true) + const [isLoading, setIsLoading] = useState(true) const loadSession = async () => { - setLoading(true) + setIsLoading(true) try { const { data, error } = await UserService.getCurrentUser() if (error) { @@ -18,7 +18,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { logger.info('Fetched current user', data) setUser(data) } finally { - setLoading(false) + setIsLoading(false) } } @@ -30,7 +30,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { >( + const [isLoadingRequestIds, setIsLoadingRequestIds] = useState>( new Set() ) const [confirmDialog, setConfirmDialog] = useState<{ @@ -92,13 +86,7 @@ export function AccessRequestsTable({ action: null, }) - const handleConfirmAction = () => { - if (confirmDialog.request && confirmDialog.action) - void handleUpdateStatus(confirmDialog.request, confirmDialog.action) - setConfirmDialog({ isOpen: false, request: null, action: null }) - } - - const handleShowConfirmDialog = ( + const openConfirmDialog = ( request: AccessRequestPublic, action: 'approved' | 'rejected' ) => { @@ -109,11 +97,25 @@ export function AccessRequestsTable({ }) } + const closeConfirmDialog = () => { + setConfirmDialog({ + isOpen: false, + request: null, + action: null, + }) + } + + const handleConfirmAction = () => { + if (confirmDialog.request && confirmDialog.action) + void handleUpdateStatus(confirmDialog.request, confirmDialog.action) + closeConfirmDialog() + } + const handleUpdateStatus = async ( request: AccessRequestPublic, status: 'approved' | 'rejected' ) => { - setLoadingRequestIds((prev) => new Set(prev).add(request.id)) + setIsLoadingRequestIds((prev) => new Set(prev).add(request.id)) try { const { error } = await AdminService.updateAccessRequestStatus({ path: { @@ -148,7 +150,7 @@ export function AccessRequestsTable({ } onRequestUpdated(updatedRequest) } finally { - setLoadingRequestIds((prev) => { + setIsLoadingRequestIds((prev) => { const next = new Set(prev) next.delete(request.id) return next @@ -161,25 +163,23 @@ export function AccessRequestsTable({ menuItems: (row) => { if (row.status !== 'pending') return [] - const isRowLoading = loadingRequestIds.has(row.id) + const isRowLoading = isLoadingRequestIds.has(row.id) return [ { type: 'action', - label: 'Approve', className: greenText, icon: Check, onSelect: () => { - handleShowConfirmDialog(row, 'approved') + openConfirmDialog(row, 'approved') }, disabled: isRowLoading, }, { type: 'action', className: redText, - label: 'Reject', icon: X, onSelect: () => { - handleShowConfirmDialog(row, 'rejected') + openConfirmDialog(row, 'rejected') }, disabled: isRowLoading, }, @@ -188,7 +188,6 @@ export function AccessRequestsTable({ } const columns: ColumnDef[] = [ - createSelectColumn(), { id: 'name', accessorFn: (row) => `${row.first_name} ${row.last_name}`, @@ -327,31 +326,21 @@ export function AccessRequestsTable({ ? This action is irreversible. - - { - setConfirmDialog({ - ...confirmDialog, - isOpen: false, - }) - }} - > - Cancel - + + Cancel {confirmDialog.action === 'approved' ? 'Approve' : 'Reject'} - + > diff --git a/client/src/components/Feedback.tsx b/client/src/components/FeedbackFormDialog.tsx similarity index 95% rename from client/src/components/Feedback.tsx rename to client/src/components/FeedbackFormDialog.tsx index 08c78b8..156a47d 100644 --- a/client/src/components/Feedback.tsx +++ b/client/src/components/FeedbackFormDialog.tsx @@ -1,6 +1,5 @@ import { FeedbackService } from '@/api/generated' import { zCreateFeedbackRequest } from '@/api/generated/zod.gen' -import { Button } from '@/components/ui/button' import { Dialog, DialogClose, @@ -13,6 +12,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/overrides/button' import { Textarea } from '@/components/ui/textarea' import { handleApiError } from '@/lib/http' import { notify } from '@/lib/notify' @@ -27,7 +27,7 @@ const feedbackFormSchema = zCreateFeedbackRequest.omit({ }) type FeedbackForm = z.infer -export function Feedback() { +export function FeedbackFormDialog() { const [open, setOpen] = useState(false) const [files, setFiles] = useState([]) @@ -136,7 +136,9 @@ export function Feedback() { }} > + Title + Description 0) } diff --git a/client/src/components/Loading.tsx b/client/src/components/Loading.tsx index 38dce60..be5e06a 100644 --- a/client/src/components/Loading.tsx +++ b/client/src/components/Loading.tsx @@ -1,4 +1,4 @@ -import { Button } from '@/components/ui/button' +import { Button } from '@/components/ui/overrides/button' import { Spinner } from '@/components/ui/spinner' export function Loading() { diff --git a/client/src/components/ModeToggle.tsx b/client/src/components/ModeToggle.tsx index 91d6e96..8d6a0d0 100644 --- a/client/src/components/ModeToggle.tsx +++ b/client/src/components/ModeToggle.tsx @@ -3,13 +3,13 @@ import { Moon, Sun } from 'lucide-react' import { useTheme } from 'next-themes' -import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/overrides/button' export function ModeToggle() { const { setTheme } = useTheme() diff --git a/client/src/components/UsersTable.tsx b/client/src/components/UsersTable.tsx index bc37be8..ed4d613 100644 --- a/client/src/components/UsersTable.tsx +++ b/client/src/components/UsersTable.tsx @@ -1,7 +1,6 @@ import type { UserPublic } from '@/api/generated/types.gen' import { DataTable } from '@/components/data-table/DataTable' import { DataTableColumnHeader } from '@/components/data-table/DataTableColumnHeader' -import { createSelectColumn } from '@/components/data-table/DataTableSelectColumn' import { Badge } from '@/components/ui/badge' import { blueText, @@ -30,7 +29,6 @@ function getRoleFilterOptions(): FilterOption[] { } const columns: ColumnDef[] = [ - createSelectColumn(), { id: 'name', accessorFn: (row) => `${row.first_name} ${row.last_name}`, diff --git a/client/src/components/data-table/DataTable.tsx b/client/src/components/data-table/DataTable.tsx index beb2dd7..69794f4 100644 --- a/client/src/components/data-table/DataTable.tsx +++ b/client/src/components/data-table/DataTable.tsx @@ -32,6 +32,9 @@ interface DataTableProps { pageSize?: number isLoading: boolean toolbarConfig?: DataTableToolbarConfig + initialColumnVisibility?: VisibilityState + firstColumnPaddingExcludeIds?: string[] + lastColumnPaddingExcludeIds?: string[] } export function DataTable({ @@ -40,14 +43,46 @@ export function DataTable({ pageSize = 10, isLoading, toolbarConfig, + initialColumnVisibility, + firstColumnPaddingExcludeIds = ['actions'], + lastColumnPaddingExcludeIds = ['actions'], }: DataTableProps) { const [rowSelection, setRowSelection] = useState({}) const [columnVisibility, setColumnVisibility] = useState( - {} + initialColumnVisibility ?? {} ) const [columnFilters, setColumnFilters] = useState([]) const [sorting, setSorting] = useState([]) + const shouldPadFirstColumn = (idx: number, columnId: string) => + idx === 0 && !firstColumnPaddingExcludeIds.includes(columnId) + + const shouldPadLastColumn = ( + idx: number, + lastIdx: number, + columnId: string + ) => idx === lastIdx && !lastColumnPaddingExcludeIds.includes(columnId) + + const getEdgePaddingClassName = ( + idx: number, + lastIdx: number, + columnId: string, + baseClassName?: string + ) => { + const paddingClasses = [] + if (shouldPadFirstColumn(idx, columnId)) { + paddingClasses.push('pl-4') + } + if (shouldPadLastColumn(idx, lastIdx, columnId)) { + paddingClasses.push('pr-4') + } + if (paddingClasses.length === 0) return baseClassName + + return baseClassName + ? `${baseClassName} ${paddingClasses.join(' ')}` + : paddingClasses.join(' ') + } + // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ data, @@ -89,22 +124,31 @@ export function DataTable({ {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column - .columnDef.header, - header.getContext() - )} - - ) - })} + {headerGroup.headers.map( + (header, headerIdx) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef + .header, + header.getContext() + )} + + ) + } + )} ))} @@ -117,17 +161,25 @@ export function DataTable({ row.getIsSelected() && 'selected' } > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} + {row + .getVisibleCells() + .map((cell, cellIdx, cells) => ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ))} )) ) : ( diff --git a/client/src/components/data-table/DataTableColumnHeader.tsx b/client/src/components/data-table/DataTableColumnHeader.tsx index 92ba8cd..1575a46 100644 --- a/client/src/components/data-table/DataTableColumnHeader.tsx +++ b/client/src/components/data-table/DataTableColumnHeader.tsx @@ -1,7 +1,6 @@ import { type Column } from '@tanstack/react-table' import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff, X } from 'lucide-react' -import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, @@ -9,6 +8,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/overrides/button' import { cn } from '@/lib/utils' interface DataTableColumnHeaderProps< diff --git a/client/src/components/data-table/DataTableFacetedFilter.tsx b/client/src/components/data-table/DataTableFacetedFilter.tsx index 467669c..48dddad 100644 --- a/client/src/components/data-table/DataTableFacetedFilter.tsx +++ b/client/src/components/data-table/DataTableFacetedFilter.tsx @@ -3,7 +3,6 @@ import { Check, PlusCircle } from 'lucide-react' import * as React from 'react' import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' import { Command, CommandEmpty, @@ -13,6 +12,7 @@ import { CommandList, CommandSeparator, } from '@/components/ui/command' +import { Button } from '@/components/ui/overrides/button' import { Popover, PopoverContent, diff --git a/client/src/components/data-table/DataTableInlineRowActions.tsx b/client/src/components/data-table/DataTableInlineRowActions.tsx index 8e3bfe0..26b397c 100644 --- a/client/src/components/data-table/DataTableInlineRowActions.tsx +++ b/client/src/components/data-table/DataTableInlineRowActions.tsx @@ -1,6 +1,6 @@ import { type Row } from '@tanstack/react-table' -import { Button } from '@/components/ui/button' +import { Button } from '@/components/ui/overrides/button' import type { DataTableRowActionsConfig } from '@/models/data-table' interface DataTableInlineRowActionsProps { diff --git a/client/src/components/data-table/DataTablePagination.tsx b/client/src/components/data-table/DataTablePagination.tsx index 8a75c24..35eb04b 100644 --- a/client/src/components/data-table/DataTablePagination.tsx +++ b/client/src/components/data-table/DataTablePagination.tsx @@ -6,7 +6,7 @@ import { ChevronsRight, } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { Button } from '@/components/ui/overrides/button' import { Select, SelectContent, diff --git a/client/src/components/data-table/DataTableRowActions.tsx b/client/src/components/data-table/DataTableRowActions.tsx index c1f1b44..5cb9747 100644 --- a/client/src/components/data-table/DataTableRowActions.tsx +++ b/client/src/components/data-table/DataTableRowActions.tsx @@ -1,7 +1,6 @@ import { type Row } from '@tanstack/react-table' import { MoreHorizontal } from 'lucide-react' -import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, @@ -15,6 +14,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/overrides/button' import type { DataTableRowActionsConfig } from '@/models/data-table' interface DataTableRowActionsProps { @@ -81,7 +81,7 @@ export function DataTableRowActions({ onSelect={() => void item.onSelect?.(rowData)} disabled={item.disabled} > - {item.icon && } + {item.icon && } {item.label} {item.shortcut && ( diff --git a/client/src/components/data-table/DataTableToolbar.tsx b/client/src/components/data-table/DataTableToolbar.tsx index 9ebe4b5..81c26e0 100644 --- a/client/src/components/data-table/DataTableToolbar.tsx +++ b/client/src/components/data-table/DataTableToolbar.tsx @@ -3,8 +3,8 @@ import { X } from 'lucide-react' import { DataTableFacetedFilter } from '@/components/data-table/DataTableFacetedFilter' import { DataTableViewOptions } from '@/components/data-table/DataTableViewOptions' -import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/overrides/button' import type { DataTableToolbarConfig } from '@/models/data-table' interface DataTableToolbarProps { @@ -17,6 +17,11 @@ export function DataTableToolbar({ config, }: DataTableToolbarProps) { const isFiltered = table.getState().columnFilters.length > 0 + const searchColumn = config.search + ? table.getColumn(config.search.columnId) + : undefined + const searchValue = + (searchColumn?.getFilterValue() as string | undefined) ?? '' return ( @@ -24,16 +29,9 @@ export function DataTableToolbar({ {config.search && ( - table - // @ts-expect-error searchConfig is defined - .getColumn(config.search.columnId) - ?.setFilterValue(event.target.value) + searchColumn?.setFilterValue(event.target.value) } className={ config.search.className ?? 'h-8 w-37.5 lg:w-62.5' @@ -75,7 +73,7 @@ export function DataTableToolbar({ variant={action.variant ?? 'default'} onClick={() => void action.onClick()} > - {action.icon && } + {action.icon && } {action.label} ))} diff --git a/client/src/components/data-table/DataTableTruncatedCell.tsx b/client/src/components/data-table/DataTableTruncatedCell.tsx new file mode 100644 index 0000000..636b588 --- /dev/null +++ b/client/src/components/data-table/DataTableTruncatedCell.tsx @@ -0,0 +1,47 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { useRef, useState } from 'react' + +interface DataTableTruncatedCellProps { + value: string + className?: string +} + +/** + * Renders text truncated to a single line with a tooltip showing the full + * value on hover. Use `className` to set a max-width (e.g. `max-w-48`). + */ +export function DataTableTruncatedCell({ + value, + className, +}: DataTableTruncatedCellProps) { + const textRef = useRef(null) + const [isTruncated, setIsTruncated] = useState(false) + + const checkTruncation = () => { + const textElement = textRef.current + if (!textElement) return + + setIsTruncated(textElement.offsetWidth < textElement.scrollWidth) + } + + return ( + + + + {value} + + + {isTruncated && {value}} + + ) +} diff --git a/client/src/components/data-table/DataTableViewOptions.tsx b/client/src/components/data-table/DataTableViewOptions.tsx index b34503e..d330c56 100644 --- a/client/src/components/data-table/DataTableViewOptions.tsx +++ b/client/src/components/data-table/DataTableViewOptions.tsx @@ -2,7 +2,6 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { type Table } from '@tanstack/react-table' import { Settings2 } from 'lucide-react' -import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuCheckboxItem, @@ -10,6 +9,18 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/overrides/button' +import { formatIdentifier } from '@/lib/text' + +interface DataTableColumnMeta { + viewLabel?: string + filterOnly?: boolean +} + +function getColumnViewLabel(columnId: string, meta?: unknown): string { + const resolvedMeta = meta as DataTableColumnMeta | undefined + return resolvedMeta?.viewLabel ?? formatIdentifier(columnId) +} export function DataTableViewOptions({ table, @@ -36,19 +47,27 @@ export function DataTableViewOptions({ .filter( (column) => typeof column.accessorFn !== 'undefined' && - column.getCanHide() + column.getCanHide() && + !( + column.columnDef.meta as + | DataTableColumnMeta + | undefined + )?.filterOnly ) .map((column) => { + const columnLabel = getColumnViewLabel( + column.id, + column.columnDef.meta + ) return ( { column.toggleVisibility(value) }} > - {column.id} + {columnLabel} ) })} diff --git a/client/src/components/data-table/README.md b/client/src/components/data-table/README.md index 2c600fb..c6ff4a6 100644 --- a/client/src/components/data-table/README.md +++ b/client/src/components/data-table/README.md @@ -101,6 +101,7 @@ export const columns: ColumnDef[] = [ }, { accessorKey: 'status', + meta: { viewLabel: 'Status' }, header: ({ column }) => ( ), @@ -114,6 +115,17 @@ export const columns: ColumnDef[] = [ cell: ({ row }) => , }, ] + +// To customize labels shown in the "View" column toggle menu, +// set `meta.viewLabel` on each column definition. +// +// To use a column purely for filtering (hidden, not in View options), +// set `meta.filterOnly: true`. +// Pass its id with `false` via `initialColumnVisibility` on . +// +// Example: +// { id: 'role', meta: { filterOnly: true }, accessorFn: ..., filterFn: ... } +// ``` ### 3. Configure Toolbar diff --git a/client/src/components/exercises/ExerciseFormDialog.tsx b/client/src/components/exercises/ExerciseFormDialog.tsx new file mode 100644 index 0000000..a3b34a9 --- /dev/null +++ b/client/src/components/exercises/ExerciseFormDialog.tsx @@ -0,0 +1,410 @@ +import { + ExercisesService, + type ExercisePublic, + type MuscleGroupPublic, +} from '@/api/generated' +import { + zCreateExerciseRequest, + zUpdateExerciseRequest, +} from '@/api/generated/zod.gen' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/overrides/button' +import { handleApiError } from '@/lib/http' +import { notify } from '@/lib/notify' +import { capitalizeWords } from '@/lib/text' +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, type ReactNode } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +type CreateExerciseForm = z.infer +type UpdateExerciseForm = z.infer + +const defaultCreateExerciseFormValues: CreateExerciseForm = { + name: '', + description: '', + muscle_group_ids: [], +} + +const defaultUpdateExerciseFormValues: UpdateExerciseForm = { + name: '', + description: '', + muscle_group_ids: [], +} + +interface FieldProps { + label: string + htmlFor: string + error?: string + children: ReactNode +} + +function Field({ label, htmlFor, error, children }: FieldProps) { + return ( + + {label} + {children} + {error && {error}} + + ) +} + +interface ExerciseFormDialogProps { + open: boolean + mode: 'create' | 'edit' + exercise: ExercisePublic | null + muscleGroups: MuscleGroupPublic[] + isRowLoading: boolean + onOpenChange: (isOpen: boolean) => void + onSuccess: () => Promise + onReloadExercises: () => Promise + onReloadMuscleGroups: () => Promise + onRowLoadingChange: (exerciseId: number, loading: boolean) => void +} + +export function ExerciseFormDialog({ + open, + mode, + exercise, + muscleGroups, + isRowLoading, + onOpenChange, + onSuccess, + onReloadExercises, + onReloadMuscleGroups, + onRowLoadingChange, +}: ExerciseFormDialogProps) { + const { + register: registerCreate, + handleSubmit: handleSubmitCreate, + setValue: setValueCreate, + watch: watchCreate, + formState: { + errors: createErrors, + isDirty: isCreateDirty, + isSubmitting: isCreateSubmitting, + }, + reset: resetCreate, + } = useForm({ + resolver: zodResolver(zCreateExerciseRequest), + defaultValues: defaultCreateExerciseFormValues, + mode: 'onSubmit', + reValidateMode: 'onChange', + }) + + const { + register: registerEdit, + handleSubmit: handleSubmitEdit, + setValue: setValueEdit, + watch: watchEdit, + formState: { + errors: editErrors, + isDirty: isEditDirty, + dirtyFields: editDirtyFields, + isSubmitting: isEditSubmitting, + }, + reset: resetEdit, + } = useForm({ + resolver: zodResolver(zUpdateExerciseRequest), + defaultValues: defaultUpdateExerciseFormValues, + mode: 'onSubmit', + reValidateMode: 'onChange', + }) + + const isCreateMode = mode === 'create' + const isSubmitting = + (isCreateMode ? isCreateSubmitting : isEditSubmitting) || isRowLoading + const isDirty = isCreateMode ? isCreateDirty : isEditDirty + const errors = isCreateMode ? createErrors : editErrors + + const selectedMuscleGroupIds = isCreateMode + ? (watchCreate('muscle_group_ids') ?? []) + : (watchEdit('muscle_group_ids') ?? []) + + useEffect(() => { + if (!open) return + if (isCreateMode) { + resetCreate(defaultCreateExerciseFormValues) + return + } + if (exercise) { + resetEdit({ + name: exercise.name, + description: exercise.description, + muscle_group_ids: exercise.muscle_groups.map((mg) => mg.id), + }) + return + } + resetEdit(defaultUpdateExerciseFormValues) + }, [open, isCreateMode, exercise, resetCreate, resetEdit]) + + const handleAttemptCloseDialog = (e: Event) => { + if (isDirty && !confirm('Discard changes?')) { + e.preventDefault() + } + } + + const closeDialog = () => { + if (isCreateMode) resetCreate(defaultCreateExerciseFormValues) + else resetEdit(defaultUpdateExerciseFormValues) + onOpenChange(false) + } + + const toggleMuscleGroup = (muscleGroupId: number, checked: boolean) => { + const selected = new Set(selectedMuscleGroupIds) + + if (checked) selected.add(muscleGroupId) + else selected.delete(muscleGroupId) + + if (isCreateMode) + setValueCreate('muscle_group_ids', Array.from(selected), { + shouldDirty: true, + shouldValidate: true, + }) + else + setValueEdit('muscle_group_ids', Array.from(selected), { + shouldDirty: true, + shouldValidate: true, + }) + } + + const onSubmitCreateForm = async (form: CreateExerciseForm) => { + const name = form.name.trim() + const desc = form.description?.trim() ?? '' + + const { error } = await ExercisesService.createExercise({ + body: { + name, + description: desc, + muscle_group_ids: form.muscle_group_ids, + }, + }) + if (error) { + await handleApiError(error, { + httpErrorHandlers: { + muscle_group_not_found: async () => { + notify.error( + 'Invalid muscle group selected. Reloading data' + ) + await onReloadMuscleGroups() + }, + exercise_name_conflict: () => { + notify.error( + 'An exercise with that name already exists' + ) + resetCreate({ ...form, name: '' }) + }, + }, + fallbackMessage: 'Failed to create exercise', + }) + return + } + notify.success('Exercise created') + await onSuccess() + closeDialog() + } + + const onSubmitEditForm = async (form: UpdateExerciseForm) => { + if (!exercise) { + notify.error('Exercise data is missing. Try again') + closeDialog() + return + } + + if (!isEditDirty) { + notify.warning('No changes to save') + closeDialog() + return + } + + const body: UpdateExerciseForm = {} + if (editDirtyFields.name) body.name = form.name?.trim() ?? '' + if (editDirtyFields.description) + body.description = form.description?.trim() ?? '' + if (editDirtyFields.muscle_group_ids) + body.muscle_group_ids = form.muscle_group_ids ?? [] + + onRowLoadingChange(exercise.id, true) + try { + const { error } = await ExercisesService.updateExercise({ + path: { exercise_id: exercise.id }, + body, + }) + if (error) { + await handleApiError(error, { + httpErrorHandlers: { + exercise_update_not_allowed: async () => { + notify.error( + 'You cannot update this exercise. Reloading data' + ) + closeDialog() + await onReloadExercises() + }, + exercise_not_found: async () => { + notify.error('Exercise not found. Reloading data') + closeDialog() + await onReloadExercises() + }, + muscle_group_not_found: async () => { + notify.error( + 'Invalid muscle group selected. Reloading data' + ) + await onReloadMuscleGroups() + }, + exercise_name_conflict: () => { + notify.error( + 'An exercise with that name already exists' + ) + resetEdit({ ...form, name: exercise.name }) + }, + }, + fallbackMessage: 'Failed to update exercise', + }) + return + } + notify.success('Exercise updated') + await onSuccess() + closeDialog() + } finally { + onRowLoadingChange(exercise.id, false) + } + } + + const formDialogTitle = isCreateMode ? 'Create Exercise' : 'Edit Exercise' + const formSubmitButtonText = isCreateMode ? 'Create' : 'Save' + const formSubmittingButtonText = isCreateMode ? 'Creating...' : 'Saving...' + const cancelButtonDisabled = isSubmitting || !isDirty + + return ( + { + // only triggered on close (open state controlled by parent) + if (!isSubmitting) closeDialog() + }} + > + { + handleAttemptCloseDialog(e) + }} + showCloseButton={false} + > + + {formDialogTitle} + + Set exercise details and assign target muscle groups + + + { + if (isCreateMode) + void handleSubmitCreate(onSubmitCreateForm)(e) + else void handleSubmitEdit(onSubmitEditForm)(e) + }} + > + + + + + + + + Muscle Groups + + {muscleGroups.map((group) => { + const checked = selectedMuscleGroupIds.includes( + group.id + ) + return ( + + { + toggleMuscleGroup( + group.id, + value === true + ) + }} + disabled={isSubmitting} + /> + + + {capitalizeWords(group.name)} + + + {' '} + — {group.description} + + + + ) + })} + + + + + + { + handleAttemptCloseDialog(e as unknown as Event) + }} + > + Cancel + + + + {isSubmitting + ? formSubmittingButtonText + : formSubmitButtonText} + + + + + ) +} diff --git a/client/src/components/exercises/ExercisesTable.tsx b/client/src/components/exercises/ExercisesTable.tsx new file mode 100644 index 0000000..9ed765b --- /dev/null +++ b/client/src/components/exercises/ExercisesTable.tsx @@ -0,0 +1,385 @@ +import { + ExercisesService, + type ExercisePublic, + type MuscleGroupPublic, +} from '@/api/generated' +import { zExercisePublic } from '@/api/generated/zod.gen' +import { DataTable } from '@/components/data-table/DataTable' +import { DataTableColumnHeader } from '@/components/data-table/DataTableColumnHeader' +import { DataTableInlineRowActions } from '@/components/data-table/DataTableInlineRowActions' +import { DataTableTruncatedCell } from '@/components/data-table/DataTableTruncatedCell' +import { ExerciseFormDialog } from '@/components/exercises/ExerciseFormDialog' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/overrides/button' +import { handleApiError } from '@/lib/http' +import { notify } from '@/lib/notify' +import { blueText, redText } from '@/lib/styles' +import { capitalizeWords } from '@/lib/text' +import type { + DataTableRowActionsConfig, + DataTableToolbarConfig, + FilterOption, +} from '@/models/data-table' +import type { ColumnDef } from '@tanstack/react-table' +import { Lock, Pencil, Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' + +function getTypeFilterOptions(): FilterOption[] { + return [ + { label: 'System', value: 'system' }, + { label: 'Custom', value: 'custom' }, + ] +} + +interface ExercisesTableProps { + exercises: ExercisePublic[] + muscleGroups: MuscleGroupPublic[] + isLoading: boolean + onReloadExercises: () => Promise + onReloadMuscleGroups: () => Promise +} + +export function ExercisesTable({ + exercises, + muscleGroups, + isLoading, + onReloadExercises, + onReloadMuscleGroups, +}: ExercisesTableProps) { + const [isLoadingExerciseIds, setIsLoadingExerciseIds] = useState< + Set + >(new Set()) + const [isDeleting, setIsDeleting] = useState(false) + + const [formDialog, setFormDialog] = useState<{ + isOpen: boolean + mode: 'create' | 'edit' + exercise: ExercisePublic | null + }>({ + isOpen: false, + mode: 'create', + exercise: null, + }) + + const [deleteDialog, setDeleteDialog] = useState<{ + isOpen: boolean + exercise: ExercisePublic | null + }>({ + isOpen: false, + exercise: null, + }) + + const openCreateDialog = () => { + setFormDialog({ isOpen: true, mode: 'create', exercise: null }) + } + + const openEditDialog = (exercise: ExercisePublic) => { + setFormDialog({ isOpen: true, mode: 'edit', exercise }) + } + + const openDeleteDialog = (exercise: ExercisePublic) => { + setDeleteDialog({ isOpen: true, exercise }) + } + + const closeDeleteDialog = () => { + setDeleteDialog({ isOpen: false, exercise: null }) + } + + const setExerciseRowLoading = (exerciseId: number, isLoading: boolean) => { + setIsLoadingExerciseIds((prev) => { + const next = new Set(prev) + if (isLoading) next.add(exerciseId) + else next.delete(exerciseId) + return next + }) + } + + const handleDeleteExercise = async () => { + const exercise = deleteDialog.exercise + if (!exercise) return + + setIsDeleting(true) + setExerciseRowLoading(exercise.id, true) + try { + const { error } = await ExercisesService.deleteExercise({ + path: { exercise_id: exercise.id }, + }) + if (error) { + await handleApiError(error, { + httpErrorHandlers: { + exercise_update_not_allowed: async () => { + notify.error( + 'You cannot delete this exercise. Reloading data' + ) + await onReloadExercises() + }, + exercise_not_found: async () => { + notify.error( + 'Exercise no longer exists. Reloading data' + ) + await onReloadExercises() + }, + }, + fallbackMessage: 'Failed to delete exercise', + }) + closeDeleteDialog() + return + } + notify.success('Exercise deleted') + await onReloadExercises() + closeDeleteDialog() + } finally { + setExerciseRowLoading(exercise.id, false) + setIsDeleting(false) + } + } + + const rowActionsConfig: DataTableRowActionsConfig = { + schema: zExercisePublic, + menuItems: (row) => { + if (row.user_id === null) return [] + + const isRowLoading = isLoadingExerciseIds.has(row.id) + return [ + { + type: 'action', + icon: Pencil, + onSelect: () => { + openEditDialog(row) + }, + disabled: isRowLoading, + }, + { + type: 'action', + className: redText, + icon: Trash2, + onSelect: () => { + openDeleteDialog(row) + }, + }, + ] + }, + } + + const columns: ColumnDef[] = [ + { + id: 'actions', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const menuItems = rowActionsConfig.menuItems(row.original) + return menuItems.length > 0 ? ( + + ) : ( + — + ) + }, + enableHiding: false, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.user_id === null && ( + + )} + + + ), + enableHiding: false, + }, + { + accessorKey: 'description', + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.description ? ( + + ) : ( + '—' + ), + enableHiding: true, + }, + { + id: 'muscle_groups', + meta: { viewLabel: 'Muscle Groups' }, + accessorFn: (row) => + row.muscle_groups + .map((group) => capitalizeWords(group.name)) + .join(', '), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const names = row.original.muscle_groups.map((group) => + capitalizeWords(group.name) + ) + return names.length ? ( + + ) : ( + '—' + ) + }, + filterFn: (row, _id, filterValues: string[]) => { + if (!filterValues.length) return true + const rowGroupIds = new Set( + row.original.muscle_groups.map((group) => String(group.id)) + ) + return filterValues.every((groupId) => rowGroupIds.has(groupId)) + }, + enableHiding: true, + }, + { + id: 'type', + meta: { filterOnly: true }, + accessorFn: (row) => (row.user_id === null ? 'system' : 'custom'), + filterFn: (row, id, filterValues: string[]) => + filterValues.includes(row.getValue(id)), + }, + { + accessorKey: 'updated_at', + meta: { viewLabel: 'Updated At' }, + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.user_id !== null ? ( + + {new Date(row.original.updated_at).toLocaleString()} + + ) : ( + — + ), + enableHiding: true, + }, + ] + + const toolbarConfig: DataTableToolbarConfig = { + search: { + columnId: 'name', + placeholder: 'Filter by name...', + }, + filters: [ + { + columnId: 'type', + title: 'Type', + options: getTypeFilterOptions(), + }, + { + columnId: 'muscle_groups', + title: 'Muscle Groups', + options: muscleGroups.map((group) => ({ + label: capitalizeWords(group.name), + value: String(group.id), + })), + }, + ], + actions: [ + { + label: 'Add Exercise', + icon: Plus, + onClick: () => { + openCreateDialog() + }, + }, + ], + showViewOptions: true, + } + + return ( + <> + + { + setFormDialog((prev) => ({ ...prev, isOpen })) + }} + onSuccess={onReloadExercises} + onReloadExercises={onReloadExercises} + onReloadMuscleGroups={onReloadMuscleGroups} + onRowLoadingChange={setExerciseRowLoading} + /> + { + if (!isDeleting) { + setDeleteDialog((prev) => ({ ...prev, isOpen })) + } + }} + > + + + Delete Exercise + + + Are you sure you want to delete{' '} + + {deleteDialog.exercise?.name} + + ? + This action is irreversible. + + + + Cancel + + void handleDeleteExercise()} + disabled={isDeleting} + > + {isDeleting ? 'Deleting...' : 'Delete'} + + + + + > + ) +} diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx index 01bc4a7..70b7b57 100644 --- a/client/src/components/ui/badge.tsx +++ b/client/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import { cn } from '@/lib/utils' const badgeVariants = cva( - 'inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3', { variants: { variant: { @@ -14,7 +14,7 @@ const badgeVariants = cva( secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', destructive: - 'bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + 'bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90', outline: 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground', diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index e53666a..b0d1d67 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -5,16 +5,16 @@ import * as React from 'react' import { cn } from '@/lib/utils' const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: - 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', @@ -23,7 +23,7 @@ const buttonVariants = cva( size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': diff --git a/client/src/components/ui/dropdown-menu.tsx b/client/src/components/ui/dropdown-menu.tsx index fc34e25..da1bd79 100644 --- a/client/src/components/ui/dropdown-menu.tsx +++ b/client/src/components/ui/dropdown-menu.tsx @@ -78,7 +78,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive", + "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className )} {...props} diff --git a/client/src/components/ui/overrides/button.tsx b/client/src/components/ui/overrides/button.tsx new file mode 100644 index 0000000..eb5e586 --- /dev/null +++ b/client/src/components/ui/overrides/button.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' + +import { Button as UIButton } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +type UIButtonProps = React.ComponentProps + +type ButtonProps = Omit & { + variant?: UIButtonProps['variant'] | 'success' +} + +function Button({ variant = 'default', className, ...props }: ButtonProps) { + if (variant === 'success') { + return ( + + ) + } + + if (variant === 'destructive') { + return ( + + ) + } + + return +} + +export { Button } +export type { ButtonProps as AppButtonProps } diff --git a/client/src/components/ui/tooltip.tsx b/client/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..18f5c5e --- /dev/null +++ b/client/src/components/ui/tooltip.tsx @@ -0,0 +1,55 @@ +import { Tooltip as TooltipPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/client/src/layout/AppLayout.tsx b/client/src/layout/AppLayout.tsx index 6de9386..cbcfaac 100644 --- a/client/src/layout/AppLayout.tsx +++ b/client/src/layout/AppLayout.tsx @@ -1,8 +1,8 @@ import { AuthService } from '@/api/generated' import { useSession } from '@/auth/session' -import { Feedback } from '@/components/Feedback' +import { FeedbackFormDialog } from '@/components/FeedbackFormDialog' import { ModeToggle } from '@/components/ModeToggle' -import { Button } from '@/components/ui/button' +import { Button } from '@/components/ui/overrides/button' import { NavItem } from '@/lib/nav' import { notify } from '@/lib/notify' import { NavLink, Outlet, useNavigate } from 'react-router-dom' @@ -32,6 +32,7 @@ export function AppLayout() { Dashboard + Exercises Docs {user?.is_admin && ( Admin @@ -40,7 +41,7 @@ export function AppLayout() { - + void handleLogout()} diff --git a/client/src/lib/styles.ts b/client/src/lib/styles.ts index f349929..40975ce 100644 --- a/client/src/lib/styles.ts +++ b/client/src/lib/styles.ts @@ -2,26 +2,16 @@ export const lightBlueBackground = 'bg-blue-50 dark:bg-blue-950' -export const blueBackground = 'bg-blue-700 dark:bg-blue-900' - export const blueText = 'text-blue-700 dark:text-blue-300' // green export const lightGreenBackground = 'bg-green-50 dark:bg-green-950' -export const greenBackground = 'bg-green-700 dark:bg-green-900' - -export const greenBackgroundHover = 'hover:bg-green-800 dark:hover:bg-green-800' - export const greenText = 'text-green-700 dark:text-green-300' // red export const lightRedBackground = 'bg-red-50 dark:bg-red-950' -export const redBackground = 'bg-red-700 dark:bg-red-900' - -export const redBackgroundHover = 'hover:bg-red-800 dark:hover:bg-red-800' - export const redText = 'text-red-700 dark:text-red-300' diff --git a/client/src/lib/text.ts b/client/src/lib/text.ts new file mode 100644 index 0000000..96ada11 --- /dev/null +++ b/client/src/lib/text.ts @@ -0,0 +1,12 @@ +export const capitalizeWords = (str: string) => + str.replace(/\b\w/g, (char) => char.toUpperCase()) + +export const formatIdentifier = (str: string) => + capitalizeWords( + str + // split camelCase + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + // replace underscores & hyphens with spaces + .replace(/[_-]+/g, ' ') + .trim() + ) diff --git a/client/src/main.tsx b/client/src/main.tsx index ddc7b42..ec0c646 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,6 +2,7 @@ import { configureApiClient } from '@/api/axios' import { App } from '@/App' import { AppRoutes } from '@/AppRoutes' import { SessionProvider } from '@/auth/SessionProvider' +import { TooltipProvider } from '@/components/ui/tooltip' import { env } from '@/config/env' import '@/index.css' import { ThemeProvider } from 'next-themes' @@ -19,12 +20,14 @@ configureApiClient() // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - + + + + + + + + + ) diff --git a/client/src/models/data-table.ts b/client/src/models/data-table.ts index 1efe73d..325a212 100644 --- a/client/src/models/data-table.ts +++ b/client/src/models/data-table.ts @@ -37,7 +37,7 @@ type MenuItemType = 'separator' | 'radio-group' | 'action' interface MenuItemConfig { type: MenuItemType - label: string + label?: string shortcut?: string value?: string options?: { value: string; label: string }[] diff --git a/client/src/models/session.ts b/client/src/models/session.ts index f2221ca..1c94a69 100644 --- a/client/src/models/session.ts +++ b/client/src/models/session.ts @@ -2,7 +2,7 @@ import type { UserPublic } from '@/api/generated' export interface SessionContextType { user: UserPublic | null - loading: boolean + isLoading: boolean authenticated: boolean refresh: () => Promise } diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx index 87dc652..38ebbae 100644 --- a/client/src/pages/Admin.tsx +++ b/client/src/pages/Admin.tsx @@ -13,11 +13,11 @@ import { useEffect, useState } from 'react' export function Admin() { const [requests, setRequests] = useState([]) const [users, setUsers] = useState([]) - const [loadingRequests, setLoadingRequests] = useState(true) - const [loadingUsers, setLoadingUsers] = useState(true) + const [isLoadingRequests, setIsLoadingRequests] = useState(true) + const [isLoadingUsers, setIsLoadingUsers] = useState(true) const loadAccessRequests = async () => { - setLoadingRequests(true) + setIsLoadingRequests(true) try { const { data, error } = await AdminService.getAccessRequests() if (error) { @@ -30,7 +30,7 @@ export function Admin() { logger.info('Fetched access requests', data) setRequests(data) } finally { - setLoadingRequests(false) + setIsLoadingRequests(false) } } @@ -41,7 +41,7 @@ export function Admin() { } const loadUsers = async () => { - setLoadingUsers(true) + setIsLoadingUsers(true) try { const { data, error } = await AdminService.getUsers() if (error) { @@ -54,7 +54,7 @@ export function Admin() { logger.info('Fetched users', data) setUsers(data) } finally { - setLoadingUsers(false) + setIsLoadingUsers(false) } } @@ -74,7 +74,7 @@ export function Admin() { @@ -87,7 +87,7 @@ export function Admin() { - + diff --git a/client/src/pages/Exercises.tsx b/client/src/pages/Exercises.tsx new file mode 100644 index 0000000..925b59d --- /dev/null +++ b/client/src/pages/Exercises.tsx @@ -0,0 +1,78 @@ +import { + type ExercisePublic, + ExercisesService, + type MuscleGroupPublic, + MuscleGroupsService, +} from '@/api/generated' +import { ExercisesTable } from '@/components/exercises/ExercisesTable' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { handleApiError } from '@/lib/http' +import { logger } from '@/lib/logger' +import { useEffect, useState } from 'react' + +export function Exercises() { + const [exercises, setExercises] = useState([]) + const [muscleGroups, setMuscleGroups] = useState([]) + const [isLoadingExercises, setIsLoadingExercises] = useState(true) + const [isLoadingMuscleGroups, setIsLoadingMuscleGroups] = useState(true) + + const loadExercises = async () => { + setIsLoadingExercises(true) + try { + const { data, error } = await ExercisesService.getExercises() + if (error) { + await handleApiError(error, { + fallbackMessage: 'Failed to fetch exercises', + }) + setExercises([]) + return + } + logger.info('Fetched exercises', data) + setExercises(data) + } finally { + setIsLoadingExercises(false) + } + } + + const loadMuscleGroups = async () => { + setIsLoadingMuscleGroups(true) + try { + const { data, error } = await MuscleGroupsService.getMuscleGroups() + if (error) { + await handleApiError(error, { + fallbackMessage: 'Failed to fetch muscle groups', + }) + setMuscleGroups([]) + return + } + logger.info('Fetched muscle groups', data) + setMuscleGroups(data) + } finally { + setIsLoadingMuscleGroups(false) + } + } + + useEffect(() => { + void loadExercises() + void loadMuscleGroups() + }, []) + + return ( + + + + Exercises + + + + + + + ) +} diff --git a/client/src/pages/ForgotPassword.tsx b/client/src/pages/ForgotPassword.tsx index af930e2..80d2088 100644 --- a/client/src/pages/ForgotPassword.tsx +++ b/client/src/pages/ForgotPassword.tsx @@ -1,6 +1,5 @@ import { AuthService } from '@/api/generated' import { zForgotPasswordRequest } from '@/api/generated/zod.gen' -import { Button } from '@/components/ui/button' import { Card, CardContent, @@ -10,6 +9,7 @@ import { } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/overrides/button' import { handleApiError } from '@/lib/http' import { notify } from '@/lib/notify' import { zodResolver } from '@hookform/resolvers/zod' diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 806f07b..3f590c5 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -1,7 +1,6 @@ import { AuthService } from '@/api/generated' import { zLoginRequest } from '@/api/generated/zod.gen' import { useSession } from '@/auth/session' -import { Button } from '@/components/ui/button' import { Card, CardContent, @@ -11,6 +10,7 @@ import { } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/overrides/button' import { handleApiError } from '@/lib/http' import { notify } from '@/lib/notify' import type { LocationState } from '@/models/location' diff --git a/client/src/pages/Register.tsx b/client/src/pages/Register.tsx index 79a02a9..8f2cab7 100644 --- a/client/src/pages/Register.tsx +++ b/client/src/pages/Register.tsx @@ -1,6 +1,5 @@ import { AuthService } from '@/api/generated' import { zRegisterRequest } from '@/api/generated/zod.gen' -import { Button } from '@/components/ui/button' import { Card, CardContent, @@ -10,6 +9,7 @@ import { } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/overrides/button' import { handleApiError } from '@/lib/http' import { notify } from '@/lib/notify' import { zodResolver } from '@hookform/resolvers/zod' diff --git a/client/src/pages/RequestAccess.tsx b/client/src/pages/RequestAccess.tsx index d458b28..7c01b31 100644 --- a/client/src/pages/RequestAccess.tsx +++ b/client/src/pages/RequestAccess.tsx @@ -1,6 +1,5 @@ import { AuthService } from '@/api/generated' import { zRequestAccessRequest } from '@/api/generated/zod.gen' -import { Button } from '@/components/ui/button' import { Card, CardContent, @@ -10,6 +9,7 @@ import { } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/overrides/button' import { handleApiError } from '@/lib/http' import { notify } from '@/lib/notify' import { zodResolver } from '@hookform/resolvers/zod' diff --git a/client/src/pages/ResetPassword.tsx b/client/src/pages/ResetPassword.tsx index b07f0e5..995a580 100644 --- a/client/src/pages/ResetPassword.tsx +++ b/client/src/pages/ResetPassword.tsx @@ -1,6 +1,5 @@ import { AuthService } from '@/api/generated' import { zResetPasswordRequest } from '@/api/generated/zod.gen' -import { Button } from '@/components/ui/button' import { Card, CardContent, @@ -10,6 +9,7 @@ import { } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/overrides/button' import { handleApiError } from '@/lib/http' import { notify } from '@/lib/notify' import { zodResolver } from '@hookform/resolvers/zod' diff --git a/config/infra/docker-compose.override.yml b/config/infra/docker-compose.override.yml index 3ffa947..1f17fa7 100644 --- a/config/infra/docker-compose.override.yml +++ b/config/infra/docker-compose.override.yml @@ -53,10 +53,6 @@ services: volumes: - ../../server/data:/src/data - ../../server/logs:/src/logs - environment: - - EMAIL__BACKEND=local - - EMAIL__SMTP_PORT=${EMAIL__SMTP_PORT?} - - GH__BACKEND=console develop: watch: diff --git a/config/infra/docker-compose.yml b/config/infra/docker-compose.yml index 1e82841..3e50967 100644 --- a/config/infra/docker-compose.yml +++ b/config/infra/docker-compose.yml @@ -92,15 +92,18 @@ services: - EMAIL__BACKEND=${EMAIL__BACKEND?} - EMAIL__EMAIL_FROM=${EMAIL__EMAIL_FROM?} + # internal host - EMAIL__SMTP_HOST=host.docker.internal - EMAIL__SMTP_PORT=${EMAIL__SMTP_PORT?} - - EMAIL__SMTP_USERNAME=${EMAIL__SMTP_USERNAME?} - - EMAIL__SMTP_PASSWORD=${EMAIL__SMTP_PASSWORD?} + # not required for non-smtp backends + - EMAIL__SMTP_USERNAME=${EMAIL__SMTP_USERNAME} + - EMAIL__SMTP_PASSWORD=${EMAIL__SMTP_PASSWORD} - GH__BACKEND=${GH__BACKEND?} - - GH__REPO_OWNER=${GH__REPO_OWNER?} - - GH__TOKEN=${GH__TOKEN?} - - GH__ISSUE_ASSIGNEE=${GH__ISSUE_ASSIGNEE?} + # not required for console backend + - GH__REPO_OWNER=${GH__REPO_OWNER} + - GH__TOKEN=${GH__TOKEN} + - GH__ISSUE_ASSIGNEE=${GH__ISSUE_ASSIGNEE} command: alembic upgrade head @@ -149,15 +152,18 @@ services: - EMAIL__BACKEND=${EMAIL__BACKEND?} - EMAIL__EMAIL_FROM=${EMAIL__EMAIL_FROM?} + # internal host - EMAIL__SMTP_HOST=host.docker.internal - EMAIL__SMTP_PORT=${EMAIL__SMTP_PORT?} - - EMAIL__SMTP_USERNAME=${EMAIL__SMTP_USERNAME?} - - EMAIL__SMTP_PASSWORD=${EMAIL__SMTP_PASSWORD?} + # not required for non-smtp backends + - EMAIL__SMTP_USERNAME=${EMAIL__SMTP_USERNAME} + - EMAIL__SMTP_PASSWORD=${EMAIL__SMTP_PASSWORD} - GH__BACKEND=${GH__BACKEND?} - - GH__REPO_OWNER=${GH__REPO_OWNER?} - - GH__TOKEN=${GH__TOKEN?} - - GH__ISSUE_ASSIGNEE=${GH__ISSUE_ASSIGNEE?} + # not required for console backend + - GH__REPO_OWNER=${GH__REPO_OWNER} + - GH__TOKEN=${GH__TOKEN} + - GH__ISSUE_ASSIGNEE=${GH__ISSUE_ASSIGNEE} healthcheck: test: diff --git a/package-lock.json b/package-lock.json index 1a110a7..84c81d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ }, "devDependencies": { "husky": "^9.1.7", - "lint-staged": "^16.3.3", + "lint-staged": "^16.4.0", "npm-check-updates": "^19.6.3", "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^4.3.0", @@ -77,19 +77,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -208,19 +195,6 @@ } } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -275,16 +249,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -298,17 +262,17 @@ } }, "node_modules/lint-staged": { - "version": "16.3.3", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.3.tgz", - "integrity": "sha512-RLq2koZ5fGWrx7tcqx2tSTMQj4lRkfNJaebO/li/uunhCJbtZqwTuwPHpgIimAHHi/2nZIiGrkCHDCOeR1onxA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", "dev": true, "license": "MIT", "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", - "micromatch": "^4.0.8", + "picomatch": "^4.0.3", "string-argv": "^0.3.2", - "tinyexec": "^1.0.2", + "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { @@ -359,33 +323,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -710,9 +647,9 @@ } }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", "engines": { @@ -735,19 +672,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index b654d1d..e720ac4 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "devDependencies": { "husky": "^9.1.7", - "lint-staged": "^16.3.3", + "lint-staged": "^16.4.0", "npm-check-updates": "^19.6.3", "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^4.3.0", diff --git a/server/app/migrations/versions/2026_03_16_0438-3fd5430b243e_reseed_muscle_groups.py b/server/app/migrations/versions/2026_03_16_0438-3fd5430b243e_reseed_muscle_groups.py new file mode 100644 index 0000000..7577b65 --- /dev/null +++ b/server/app/migrations/versions/2026_03_16_0438-3fd5430b243e_reseed_muscle_groups.py @@ -0,0 +1,211 @@ +""" +reseed muscle groups + +Revision ID: 3fd5430b243e +Revises: 1d1475c06895 +Create Date: 2026-03-16 04:38:47.690395-05:00 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "3fd5430b243e" +down_revision: str | Sequence[str] | None = "1d1475c06895" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + INSERT INTO muscle_groups (name, description) + VALUES + ('forearms', 'Muscles of the lower arm that control grip and wrist movement.'), + ('biceps', 'Front upper-arm muscles that bend the elbow and help with pulling.'), + ('triceps', 'Back upper-arm muscles that straighten the elbow and assist pressing.'), + ('front delts', 'Front shoulder muscles that lift the arm forward.'), + ('side delts', 'Outer shoulder muscles that raise the arm out to the side.'), + ('rear delts', 'Back shoulder muscles that pull the arm backward and improve posture.'), + ('chest', 'Front torso muscles used in pushing and pressing movements.'), + ('neck', 'Muscles that support head movement and cervical stability.'), + ('upper traps', 'Upper back/neck muscles that elevate and stabilize the shoulders.'), + ('upper back', 'Mid-to-upper posterior muscles that retract the shoulder blades.'), + ('lats', 'Large side-back muscles that pull the arms down and back.'), + ('lower back', 'Spinal support muscles that extend and stabilize the torso.'), + ('abs', 'Front core muscles that flex the spine and brace the trunk.'), + ('obliques', 'Side core muscles that rotate and bend the torso.'), + ('serratus', 'Side ribcage muscles that stabilize and move the shoulder blades.'), + ('glutes', 'Hip muscles that drive extension, power, and pelvic stability.'), + ('quads', 'Front thigh muscles that straighten the knee.'), + ('hamstrings', 'Back thigh muscles that bend the knee and extend the hip.'), + ('calves', 'Lower-leg muscles that point the foot and aid walking/running.'), + ('abductors', 'Outer hip muscles that move the leg away from the body.'), + ('adductors', 'Inner thigh muscles that move the leg toward the body.'), + ('tibialis', 'Front shin muscle that lifts the foot upward.') + ON CONFLICT (name) + DO UPDATE SET + description = EXCLUDED.description, + updated_at = NOW() + """ + ) + + op.execute( + """ + INSERT INTO exercise_muscle_groups (exercise_id, muscle_group_id) + SELECT emg.exercise_id, new_mg.id + FROM exercise_muscle_groups emg + JOIN muscle_groups old_mg + ON old_mg.id = emg.muscle_group_id + JOIN muscle_groups new_mg + ON new_mg.name = CASE old_mg.name + WHEN 'arms' THEN 'triceps' + WHEN 'shoulders' THEN 'side delts' + WHEN 'back' THEN 'lats' + WHEN 'core' THEN 'abs' + WHEN 'legs' THEN 'quads' + END + WHERE old_mg.name IN ('arms', 'shoulders', 'back', 'core', 'legs') + ON CONFLICT (exercise_id, muscle_group_id) DO NOTHING + """ + ) + + op.execute( + """ + DELETE FROM exercise_muscle_groups + WHERE muscle_group_id IN ( + SELECT id + FROM muscle_groups + WHERE name IN ('arms', 'shoulders', 'back', 'core', 'legs') + ) + """ + ) + + op.execute( + """ + DELETE FROM muscle_groups + WHERE name IN ('arms', 'shoulders', 'back', 'core', 'legs') + """ + ) + + +def downgrade() -> None: + op.execute( + """ + INSERT INTO muscle_groups (name, description) + VALUES + ('back', 'Muscles of the posterior torso responsible for pulling movements, spinal support, and posture.'), + ('arms', 'Muscles of the upper limbs responsible for elbow flexion and extension and assisting pushing and pulling movements.'), + ('shoulders', 'Muscles surrounding the shoulder joint responsible for arm abduction, rotation, and stabilization.'), + ('core', 'Muscles of the trunk responsible for spinal stability, posture, and force transfer between upper and lower body.'), + ('legs', 'Muscles of the hips, thighs, and lower legs responsible for locomotion, squatting, and lower-body force production.') + ON CONFLICT (name) + DO UPDATE SET + description = EXCLUDED.description, + updated_at = NOW() + """ + ) + + op.execute( + """ + INSERT INTO exercise_muscle_groups (exercise_id, muscle_group_id) + SELECT emg.exercise_id, old_mg.id + FROM exercise_muscle_groups emg + JOIN muscle_groups new_mg + ON new_mg.id = emg.muscle_group_id + JOIN muscle_groups old_mg + ON old_mg.name = CASE + WHEN new_mg.name IN ('forearms', 'biceps', 'triceps') THEN 'arms' + WHEN new_mg.name IN ('front delts', 'side delts', 'rear delts') THEN 'shoulders' + WHEN new_mg.name IN ('neck', 'upper traps', 'upper back', 'lats', 'lower back') THEN 'back' + WHEN new_mg.name IN ('abs', 'obliques', 'serratus') THEN 'core' + WHEN new_mg.name IN ('glutes', 'quads', 'hamstrings', 'calves', 'abductors', 'adductors', 'tibialis') THEN 'legs' + END + WHERE new_mg.name IN ( + 'forearms', + 'biceps', + 'triceps', + 'front delts', + 'side delts', + 'rear delts', + 'neck', + 'upper traps', + 'upper back', + 'lats', + 'lower back', + 'abs', + 'obliques', + 'serratus', + 'glutes', + 'quads', + 'hamstrings', + 'calves', + 'abductors', + 'adductors', + 'tibialis' + ) + ON CONFLICT (exercise_id, muscle_group_id) DO NOTHING + """ + ) + + op.execute( + """ + DELETE FROM exercise_muscle_groups + WHERE muscle_group_id IN ( + SELECT id + FROM muscle_groups + WHERE name IN ( + 'forearms', + 'biceps', + 'triceps', + 'front delts', + 'side delts', + 'rear delts', + 'neck', + 'upper traps', + 'upper back', + 'lats', + 'lower back', + 'abs', + 'obliques', + 'serratus', + 'glutes', + 'quads', + 'hamstrings', + 'calves', + 'abductors', + 'adductors', + 'tibialis' + ) + ) + """ + ) + + op.execute( + """ + DELETE FROM muscle_groups + WHERE name IN ( + 'forearms', + 'biceps', + 'triceps', + 'front delts', + 'side delts', + 'rear delts', + 'neck', + 'upper traps', + 'upper back', + 'lats', + 'lower back', + 'abs', + 'obliques', + 'serratus', + 'glutes', + 'quads', + 'hamstrings', + 'calves', + 'abductors', + 'adductors', + 'tibialis' + ) + """ + ) diff --git a/server/app/models/database/exercise.py b/server/app/models/database/exercise.py index 37bbb54..000715c 100644 --- a/server/app/models/database/exercise.py +++ b/server/app/models/database/exercise.py @@ -52,4 +52,7 @@ class Exercise(Base): muscle_groups: Mapped[list[ExerciseMuscleGroup]] = relationship( "ExerciseMuscleGroup", + back_populates="exercise", + cascade="all, delete-orphan", + passive_deletes=True, ) diff --git a/server/app/models/database/exercise_muscle_group.py b/server/app/models/database/exercise_muscle_group.py index 035cf9f..00a3b69 100644 --- a/server/app/models/database/exercise_muscle_group.py +++ b/server/app/models/database/exercise_muscle_group.py @@ -8,6 +8,7 @@ from app.core.database import Base if TYPE_CHECKING: + from app.models.database.exercise import Exercise from app.models.database.muscle_group import MuscleGroup @@ -47,6 +48,10 @@ class ExerciseMuscleGroup(Base): nullable=False, ) + exercise: Mapped[Exercise] = relationship( + "Exercise", + back_populates="muscle_groups", + ) muscle_group: Mapped[MuscleGroup] = relationship( "MuscleGroup", ) diff --git a/server/app/services/exercise.py b/server/app/services/exercise.py index b9d824b..a1a2069 100644 --- a/server/app/services/exercise.py +++ b/server/app/services/exercise.py @@ -44,6 +44,10 @@ async def _get_exercises_with_muscle_groups( def _to_exercise_public(exercise: Exercise) -> ExercisePublic: + sorted_muscle_groups = sorted( + exercise.muscle_groups, + key=lambda emg: emg.muscle_group.name, + ) return ExercisePublic( id=exercise.id, user_id=exercise.user_id, @@ -51,7 +55,7 @@ def _to_exercise_public(exercise: Exercise) -> ExercisePublic: description=exercise.description, muscle_groups=[ MuscleGroupPublic.model_validate(emg.muscle_group, from_attributes=True) - for emg in exercise.muscle_groups + for emg in sorted_muscle_groups ], created_at=exercise.created_at, updated_at=exercise.updated_at, diff --git a/server/app/tests/api/exercises/test_update_exercise.py b/server/app/tests/api/exercises/test_update_exercise.py index b10ff72..f1fcf7a 100644 --- a/server/app/tests/api/exercises/test_update_exercise.py +++ b/server/app/tests/api/exercises/test_update_exercise.py @@ -51,7 +51,7 @@ async def test_update_exercise( ): await login_admin(client, settings) created = await create_exercise_via_api(client, name="Old Name") - muscle_group_id = await get_muscle_group_id(session, name="back") + muscle_group_id = await get_muscle_group_id(session, name="chest") resp = await _make_request( client, diff --git a/server/app/tests/services/exercise/test_delete_exercise.py b/server/app/tests/services/exercise/test_delete_exercise.py index bd33260..b95744d 100644 --- a/server/app/tests/services/exercise/test_delete_exercise.py +++ b/server/app/tests/services/exercise/test_delete_exercise.py @@ -3,26 +3,36 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.database.exercise import Exercise +from app.models.database.exercise_muscle_group import ExerciseMuscleGroup from app.models.errors import ExerciseNotFound, ExerciseUpdateNotAllowed from app.services.exercise import delete_exercise -from .utilities import create_exercise, create_user +from .utilities import create_exercise, create_user, get_muscle_group_id async def test_delete_exercise(session: AsyncSession): user = await create_user(session) + mg_id = await get_muscle_group_id(session, name="chest") exercise = await create_exercise( session, name="Bench", user_id=user.id, + muscle_group_ids=[mg_id], ) await delete_exercise(exercise.id, user.id, session) - result = await session.execute( + exercises = await session.execute( select(Exercise).where(Exercise.id == exercise.id), ) - assert result.scalar_one_or_none() is None + assert exercises.scalar_one_or_none() is None + + emgs = await session.execute( + select(ExerciseMuscleGroup).where( + ExerciseMuscleGroup.exercise_id == exercise.id, + ), + ) + assert emgs.scalars().all() == [] async def test_delete_exercise_not_found(session: AsyncSession): diff --git a/server/app/tests/services/exercise/test_get_exercises.py b/server/app/tests/services/exercise/test_get_exercises.py index a58ce2c..1d22ebe 100644 --- a/server/app/tests/services/exercise/test_get_exercises.py +++ b/server/app/tests/services/exercise/test_get_exercises.py @@ -2,10 +2,12 @@ from app.services.exercise import get_exercises -from .utilities import create_exercise, create_user +from .utilities import clear_exercises, create_exercise, create_user async def test_get_exercises(session: AsyncSession): + await clear_exercises(session) + user = await create_user(session) user_2 = await create_user(session, username="user_2") diff --git a/server/app/tests/services/exercise/test_get_exercises_with_muscle_groups.py b/server/app/tests/services/exercise/test_get_exercises_with_muscle_groups.py index 53c7b3a..5c9cce6 100644 --- a/server/app/tests/services/exercise/test_get_exercises_with_muscle_groups.py +++ b/server/app/tests/services/exercise/test_get_exercises_with_muscle_groups.py @@ -7,7 +7,12 @@ _get_exercises_with_muscle_groups, # pyright: ignore[reportPrivateUsage] ) -from .utilities import create_exercise, create_user, get_muscle_group_id +from .utilities import ( + clear_exercises, + create_exercise, + create_user, + get_muscle_group_id, +) logger = logging.getLogger(__name__) @@ -15,6 +20,8 @@ async def test_get_exercises_with_muscle_groups_no_where_clause( session: AsyncSession, ): + await clear_exercises(session) + user_1 = await create_user(session, username="user_1") user_2 = await create_user(session, username="user_2") diff --git a/server/app/tests/services/exercise/test_to_exercise_public.py b/server/app/tests/services/exercise/test_to_exercise_public.py index 5ae95f8..6feeed2 100644 --- a/server/app/tests/services/exercise/test_to_exercise_public.py +++ b/server/app/tests/services/exercise/test_to_exercise_public.py @@ -64,3 +64,28 @@ def test_to_exercise_public_with_muscle_groups() -> None: assert result.muscle_groups[1].id == 2 assert result.muscle_groups[1].name == "triceps" assert result.muscle_groups[1].description == "Triceps muscles" + + +def test_to_exercise_public_muscle_groups_ordering() -> None: + back = MuscleGroup(id=3, name="back", description="Back muscles") + arms = MuscleGroup(id=4, name="arms", description="Arm muscles") + + exercise = Exercise( + id=12, + user_id=None, + name="Row", + description=None, + created_at=datetime(2026, 1, 5, tzinfo=UTC), + updated_at=datetime(2026, 1, 6, tzinfo=UTC), + ) + exercise.muscle_groups = [ + ExerciseMuscleGroup(exercise_id=12, muscle_group_id=3, muscle_group=back), + ExerciseMuscleGroup(exercise_id=12, muscle_group_id=4, muscle_group=arms), + ] + + result = _to_exercise_public(exercise) + + assert [muscle_group.name for muscle_group in result.muscle_groups] == [ + "arms", + "back", + ] diff --git a/server/app/tests/services/exercise/utilities.py b/server/app/tests/services/exercise/utilities.py index fb8b64e..6a16e13 100644 --- a/server/app/tests/services/exercise/utilities.py +++ b/server/app/tests/services/exercise/utilities.py @@ -1,4 +1,4 @@ -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.database.exercise import Exercise @@ -48,6 +48,11 @@ async def create_exercise( return exercise +async def clear_exercises(session: AsyncSession): + await session.execute(delete(Exercise)) + await session.commit() + + async def get_muscle_group_id(session: AsyncSession, name: str) -> int: result = await session.execute( select(MuscleGroup).where(MuscleGroup.name == name), diff --git a/server/app/tests/services/muscle_group/test_get_muscle_groups_by_ids.py b/server/app/tests/services/muscle_group/test_get_muscle_groups_by_ids.py index affb359..a55add7 100644 --- a/server/app/tests/services/muscle_group/test_get_muscle_groups_by_ids.py +++ b/server/app/tests/services/muscle_group/test_get_muscle_groups_by_ids.py @@ -5,11 +5,11 @@ async def test_get_muscle_groups_by_ids(session: AsyncSession): - result = await get_muscle_groups_by_ids([1, 3], session) + result = await get_muscle_groups_by_ids([7, 8], session) assert len(result) == 2 assert all(isinstance(muscle_group, MuscleGroup) for muscle_group in result) - assert {muscle_group.id for muscle_group in result} == {1, 3} + assert {muscle_group.id for muscle_group in result} == {7, 8} async def test_get_muscle_groups_by_ids_missing_ids(session: AsyncSession): diff --git a/server/app/tests/services/muscle_group/test_get_muscle_groups_ordered_by_name.py b/server/app/tests/services/muscle_group/test_get_muscle_groups_ordered_by_name.py index f845f9e..d875110 100644 --- a/server/app/tests/services/muscle_group/test_get_muscle_groups_ordered_by_name.py +++ b/server/app/tests/services/muscle_group/test_get_muscle_groups_ordered_by_name.py @@ -13,7 +13,7 @@ async def test_get_muscle_groups_ordered_by_name(session: AsyncSession): assert item is not None assert isinstance(item, MuscleGroupPublic) assert item.name == "chest" - assert "upper torso" in item.description + assert "pushing and pressing" in item.description async def test_get_muscle_groups_ordered_by_name_ordering(session: AsyncSession): @@ -22,8 +22,8 @@ async def test_get_muscle_groups_ordered_by_name_ordering(session: AsyncSession) names = [mg.name for mg in result] # case-insensitive sorting to match db assert names == sorted(names, key=str.lower) - assert names[0] == "arms" - assert names[-1] == "shoulders" + assert names[0] == "abductors" + assert names[-1] == "upper traps" async def test_read_only(session: AsyncSession): diff --git a/server/makefile b/server/makefile index 1f60f48..1502cca 100644 --- a/server/makefile +++ b/server/makefile @@ -33,3 +33,6 @@ auto_migration: migrate: uv run alembic upgrade head + +downgrade: + uv run alembic downgrade -1 diff --git a/server/uv.lock b/server/uv.lock index 4988361..228e0f6 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -689,11 +689,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [[package]] @@ -884,27 +884,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, - { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] [[package]]
{error}