diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b740d3a1..f90e8147 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,183 +1,71 @@ -# RepTrack – Copilot Instructions +# RepTrack – Agent Instructions -RepTrack is a full-stack strength-training tracker. The client is React + Vite (TypeScript), the server is FastAPI (Python 3.14+), and the database is PostgreSQL via SQLAlchemy 2.0 + AsyncPG. +RepTrack is a full-stack strength-training tracker: ---- +- `client/`: React 19 + Vite + TypeScript +- `server/`: FastAPI + SQLAlchemy 2.0 async stack +- DB: PostgreSQL (AsyncPG) -## Repository Layout +## Build, Test, and Lint Commands -``` -client/ React + Vite frontend -server/ FastAPI backend -config/env/ Environment files (.env.example → .env) -config/infra/ Docker Compose for dev/prod -scripts/ Dev bootstrap (dev.sh) and API generation -``` - ---- +### Root (monorepo) -## Commands +- Format all: `npm run format` +- Lint/typecheck all configured checks: `npm run lint` +- Server typecheck only: `npm run check:py` ### Server (`cd server`) -| Task | Command | -| -------------------------- | --------------------------------------------------------------- | -| Start dev server | `make dev` | -| Run all tests | `make test` | -| Run tests with coverage | `make cov` | -| Run a single test file | `uv run pytest app/tests/api/auth/test_login.py -v` | -| Run a single test function | `uv run pytest app/tests/api/auth/test_login.py::test_login -v` | -| Type check | `make check` | -| Generate migration | `make auto_migration msg="description"` | -| Apply migrations | `make migrate` | -| Verify migrations | `make check_migrations` | +- Dev server: `make dev` +- Typecheck: `make check` +- Test suite: `make test` +- Tests with coverage: `make cov` +- Single test file: `uv run pytest app/tests/api/auth/test_login.py -v` +- Single test function: `uv run pytest app/tests/api/auth/test_login.py::test_login -v` +- Alembic check: `make check_migrations` +- Apply migrations: `make migrate` ### Client (`cd client`) -| Task | Command | -| --------------------- | ---------------------- | -| Start dev server | `npm run dev` | -| Build | `npm run build` | -| Lint (auto-fix) | `npm run lint` | -| Regenerate API client | `npm run generate-api` | - -### Root (monorepo) - -| Task | Command | -| ----------------- | ------------------ | -| Format all | `npm run format` | -| Lint all | `npm run lint` | -| Type check server | `npm run check:py` | - ---- - -## Dev Environment - -```bash -cp config/env/.env.example config/env/.env # fill in values -./scripts/dev.sh # install deps + start Docker Compose -./scripts/dev.sh -s # skip install, just start -./scripts/dev.sh -o # omit client/server containers -``` +- Dev server: `npm run dev` +- Build: `npm run build` +- Lint (auto-fix): `npm run lint` +- Regenerate OpenAPI client: `npm run generate-api` -Docker Compose runs postgres, adminer, migrations, server (uvicorn), and client (Vite) with hot reload. +## High-Level Architecture ---- +### Server request path -## Architecture +- `server/app/__init__.py` owns app assembly (`create_app`): logging setup, global exception handlers, `/api` router mounting, Swagger UI install in non-prod-like envs, CORS wrapping. +- `server/app/main.py` just instantiates and exports `fastapi_app, app`. +- `server/app/api/router.py` composes domain routers from `server/app/api/endpoints/*.py`. +- Endpoints are intentionally thin and delegate business logic to `server/app/services/*.py`. -### Server +### Auth/session architecture across backend + frontend -- **Entry point:** `server/app/main.py` — creates the FastAPI app, registers routers, exception handlers, and lifespan -- **Routing:** `server/app/api/router.py` composes all routers under `/api`. Each domain lives in `server/app/api/endpoints/{domain}.py` -- **Services:** `server/app/services/{domain}.py` — business logic, called by endpoints. Keep endpoints thin. -- **Database models:** `server/app/models/database/{entity}.py` — SQLAlchemy 2.0 declarative models -- **Schemas:** `server/app/models/schemas/{domain}.py` — Pydantic request/response models -- **Config:** `server/app/core/config.py` — `Settings` loaded via `pydantic-settings` with `__` as nested delimiter (e.g., `DB__HOST`) -- **Auth:** HTTP-only JWT cookies (`ACCESS_JWT_KEY`, `REFRESH_JWT_KEY`). `get_current_user` and `get_current_admin` are FastAPI dependencies in `server/app/core/security.py` -- **Errors:** Custom `HTTPError` subclasses in `server/app/models/errors.py`; raise them directly (e.g., `raise InvalidCredentials()`) +- Backend auth uses HTTP-only cookies (`access_token`, `refresh_token`) from `auth` endpoints. +- JWT verification and current-user/admin dependencies are in `server/app/core/dependencies.py` and `server/app/core/security.py`. +- Frontend API client (`client/src/api/axios.ts`) queues concurrent 401s and performs one refresh-token request, then retries queued calls. +- `SessionProvider` loads `/api/users/current` to determine authenticated state; `RequireAuth` / `RequireGuest` gate routes in `client/src/AppRoutes.tsx`. -### Client +### OpenAPI client generation pipeline -- **API client:** Auto-generated from OpenAPI spec via `@hey-api/openapi-ts`. Run `npm run generate-api` after server changes. Do **not** hand-edit `src/api/`. -- **Auth guards:** `RequireAuth` / `RequireGuest` components wrap routes in `src/router/` -- **Axios interceptors:** Handle 401 auto-refresh with request queueing (`src/lib/axios.ts`) - ---- +- Server OpenAPI spec is generated by `scripts/generate_api.sh`. +- Client generation uses `client/openapi-ts.config.ts` with `@hey-api/openapi-ts`. +- SDK generation strategy is `byTags` + `operationId` nesting, so endpoint `operation_id` values drive generated method names. +- Generated files live in `client/src/api/generated/` and should not be hand-edited. ## Key Conventions -### Endpoints - -```python -api_router = APIRouter(prefix="/auth", tags=["Auth"]) - -@api_router.post( - "/login", - operation_id="login", # required — used for OpenAPI client generation - status_code=status.HTTP_204_NO_CONTENT, - responses={status.HTTP_401_UNAUTHORIZED: ErrorResponseModel}, -) -async def login_endpoint( - req: LoginRequest, - db: Annotated[AsyncSession, Depends(get_db)], - settings: Annotated[Settings, Depends(get_settings)], - res: Response, -): - result = await login(...) # delegate to service - res.set_cookie(...) -``` - -Always set `operation_id` — it drives the generated TypeScript client method name. - -### Schemas - -- Response schemas: `{Entity}Public` (e.g., `UserPublic`) -- Request schemas: `{Action}Request` (e.g., `LoginRequest`, `RegisterRequest`) -- Convert ORM → schema with `Model.model_validate(orm_obj, from_attributes=True)` -- Shared field type aliases (`Name`, `Username`, `Password`, `Email`) live in `server/app/models/schemas/types.py` - -### Database Models - -```python -class User(Base): - __tablename__ = "users" - - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) -``` - -- Use SQLAlchemy 2.0 `Mapped` annotations throughout -- All models include `created_at` / `updated_at` with `server_default=func.now()` -- Alembic bulk updates must explicitly set `updated_at` (no ORM trigger) -- Table names: lowercase plural; index names: `ix_{table}_{column}` - -### Auth in Routes - -```python -# Authenticated route -async def endpoint(user: Annotated[UserPublic, Depends(get_current_user)], ...): - -# Admin-only route -async def endpoint(user: Annotated[UserPublic, Depends(get_current_admin)], ...): -``` - -### Error Handling - -Define errors as `HTTPError` subclasses; raise without arguments: - -```python -class InvalidCredentials(HTTPError): - status_code = status.HTTP_401_UNAUTHORIZED - code = "invalid_credentials" - detail = "Invalid credentials" - -raise InvalidCredentials() -``` - -### Tests - -- Fixtures are in `server/app/tests/fixtures/` and registered in `server/conftest.py` -- Shared helpers go in `server/app/tests/{scope}/utilities.py`, not per-test-file -- Module-private helpers and constants are prefixed with `_` -- Tests use `AsyncClient` from `httpx` against a savepoint-wrapped test DB -- Test settings use `console` email/GitHub backends; admin credentials: `admin` / `admin@example.com` / `password` -- `RegisterRequest` rejects usernames that are email addresses (enforced by `@field_validator`) -- `get_user_by_identifier` resolves login by email OR username - ---- - -## Configuration - -Settings use Pydantic nested models with `__` as the env delimiter: - -``` -DB__HOST, DB__PORT, DB__NAME, DB__USER, DB__PASSWORD -JWT__SECRET_KEY, JWT__ALGORITHM, JWT__ACCESS_TOKEN_EXPIRE_MINUTES -ADMIN__USERNAME, ADMIN__EMAIL, ADMIN__PASSWORD -EMAIL__BACKEND=smtp|local|console|disabled -GH__BACKEND=api|console -``` - -Production requires `EMAIL__BACKEND=smtp` and `GH__BACKEND=api` (validated at startup). +- Always set explicit `operation_id` on FastAPI endpoints. Missing or unstable IDs cause churn/breakage in generated TS SDK method names. +- Route handlers keep orchestration only; core business logic belongs in `server/app/services/`. +- Error handling uses `HTTPError` subclasses in `server/app/models/errors.py`; raise typed errors directly (e.g., `raise InvalidCredentials()`), not ad-hoc `HTTPException` payloads. +- Settings come from nested env vars with `__` delimiter (see `server/app/core/config.py`, e.g. `DB__HOST`, `JWT__SECRET_KEY`). In prod-like envs, config validators enforce `EMAIL__BACKEND=smtp` and `GH__BACKEND=api`. +- SQLAlchemy models use `Mapped[...]` + `mapped_column(...)`; models include `created_at`/`updated_at` timestamps. Alembic/bulk updates must set `updated_at` explicitly. +- Client lint rules enforce wrapper usage over direct imports: + - Prefer app override components like `@/components/ui/overrides/button` instead of base shadcn paths. + - Prefer `@/` alias imports over deep relative paths. + - Prefer `@/lib/notify` over direct `toast` import from `sonner`. +- Test stack uses pytest + async fixtures with a Postgres Testcontainers instance (`postgres:18`) and savepoint-based rollback per test (`server/app/tests/fixtures/database.py`). +- Shared test helpers belong in `utilities.py` under the relevant test scope; module-private test helpers/constants are prefixed with `_`. +- Auth schema rule: usernames cannot be email-shaped strings (`RegisterRequest` validator in `server/app/models/schemas/auth.py`); login accepts username or email identifier. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..02dd1341 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/README.md b/README.md index 24b96f4c..5bcb0cf0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # RepTrack -## Local Development +## Development Copy `.env.example` to `.env` & populate variables @@ -27,7 +27,7 @@ Start containers: ./scripts/dev.sh ``` -## Local GitHub Actions Testing +## Testing GitHub Actions Workflows Use `act` to run workflows locally for quick validation. @@ -43,13 +43,7 @@ Run a specific job: 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 Components shadcn adds components under `client/src/components/ui/` @@ -58,3 +52,15 @@ To ensure custom styles & behavior survive component updates, follow these conve - 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 + +## Database + +### Conventions + +All writes should go through SQLAlchemy + +Alembic updates & bulk SQLAlchemy updates must explicitly set `updated_at` + +### Database ER Diagram + +![diagram](db_erd.png 'Database ER Diagram') diff --git a/client/eslint.config.js b/client/eslint.config.js index 8cf2ff68..60b2b9a5 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -51,6 +51,11 @@ export default defineConfig([ message: 'Use Button from @/components/ui/overrides/button', }, + { + name: '@/components/ui/tooltip', + message: + 'Use Tooltip from @/components/ui/overrides/tooltip', + }, { name: 'sonner', importNames: ['toast'], diff --git a/client/src/api/generated/index.ts b/client/src/api/generated/index.ts index 67f0b57c..535bad71 100644 --- a/client/src/api/generated/index.ts +++ b/client/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { AdminService, AuthService, ExercisesService, FeedbackService, HealthService, MuscleGroupsService, type Options, UserService } from './sdk.gen'; -export type { AccessRequestPublic, AccessRequestStatus, ClientOptions, CreateExerciseData, CreateExerciseError, CreateExerciseErrors, CreateExerciseRequest, CreateExerciseResponse, CreateExerciseResponses, CreateFeedbackData, CreateFeedbackError, CreateFeedbackErrors, CreateFeedbackRequest, CreateFeedbackResponses, DeleteExerciseData, DeleteExerciseError, DeleteExerciseErrors, DeleteExerciseResponse, DeleteExerciseResponses, ErrorResponse, ExercisePublic, FeedbackType, ForgotPasswordData, ForgotPasswordError, ForgotPasswordErrors, ForgotPasswordRequest, ForgotPasswordResponse, ForgotPasswordResponses, GetAccessRequestsData, GetAccessRequestsError, GetAccessRequestsErrors, GetAccessRequestsResponse, GetAccessRequestsResponses, GetCurrentUserData, GetCurrentUserError, GetCurrentUserErrors, GetCurrentUserResponse, GetCurrentUserResponses, GetDbHealthData, GetDbHealthResponse, GetDbHealthResponses, GetExerciseData, GetExerciseError, GetExerciseErrors, GetExerciseResponse, GetExerciseResponses, GetExercisesData, GetExercisesError, GetExercisesErrors, GetExercisesResponse, GetExercisesResponses, GetHealthData, GetHealthResponse, GetHealthResponses, GetMuscleGroupsData, GetMuscleGroupsError, GetMuscleGroupsErrors, GetMuscleGroupsResponse, GetMuscleGroupsResponses, GetUsersData, GetUsersError, GetUsersErrors, GetUsersResponse, GetUsersResponses, HttpValidationError, LoginData, LoginError, LoginErrors, LoginRequest, LoginResponse, LoginResponses, LogoutData, LogoutResponse, LogoutResponses, MuscleGroupPublic, RefreshTokenData, RefreshTokenError, RefreshTokenErrors, RefreshTokenResponse, RefreshTokenResponses, RegisterData, RegisterError, RegisterErrors, RegisterRequest, RegisterResponse, RegisterResponses, RequestAccessData, RequestAccessError, RequestAccessErrors, RequestAccessRequest, RequestAccessResponse, RequestAccessResponses, ResetPasswordData, ResetPasswordError, ResetPasswordErrors, ResetPasswordRequest, ResetPasswordResponse, ResetPasswordResponses, ReviewerPublic, UpdateAccessRequestStatusData, UpdateAccessRequestStatusError, UpdateAccessRequestStatusErrors, UpdateAccessRequestStatusRequest, UpdateAccessRequestStatusResponse, UpdateAccessRequestStatusResponses, UpdateExerciseData, UpdateExerciseError, UpdateExerciseErrors, UpdateExerciseRequest, UpdateExerciseResponse, UpdateExerciseResponses, UserPublic, ValidationError } from './types.gen'; +export { AdminService, AuthService, ExercisesService, FeedbackService, HealthService, MuscleGroupsService, type Options, UserService, WorkoutsService } from './sdk.gen'; +export type { AccessRequestPublic, AccessRequestStatus, ClientOptions, CreateExerciseData, CreateExerciseError, CreateExerciseErrors, CreateExerciseRequest, CreateExerciseResponse, CreateExerciseResponses, CreateFeedbackData, CreateFeedbackError, CreateFeedbackErrors, CreateFeedbackRequest, CreateFeedbackResponses, CreateWorkoutData, CreateWorkoutError, CreateWorkoutErrors, CreateWorkoutRequest, CreateWorkoutResponse, CreateWorkoutResponses, DeleteExerciseData, DeleteExerciseError, DeleteExerciseErrors, DeleteExerciseResponse, DeleteExerciseResponses, DeleteWorkoutData, DeleteWorkoutError, DeleteWorkoutErrors, DeleteWorkoutResponse, DeleteWorkoutResponses, ErrorResponse, ExerciseBase, ExercisePublic, FeedbackType, ForgotPasswordData, ForgotPasswordError, ForgotPasswordErrors, ForgotPasswordRequest, ForgotPasswordResponse, ForgotPasswordResponses, GetAccessRequestsData, GetAccessRequestsError, GetAccessRequestsErrors, GetAccessRequestsResponse, GetAccessRequestsResponses, GetCurrentUserData, GetCurrentUserError, GetCurrentUserErrors, GetCurrentUserResponse, GetCurrentUserResponses, GetDbHealthData, GetDbHealthResponse, GetDbHealthResponses, GetExerciseData, GetExerciseError, GetExerciseErrors, GetExerciseResponse, GetExerciseResponses, GetExercisesData, GetExercisesError, GetExercisesErrors, GetExercisesResponse, GetExercisesResponses, GetHealthData, GetHealthResponse, GetHealthResponses, GetMuscleGroupsData, GetMuscleGroupsError, GetMuscleGroupsErrors, GetMuscleGroupsResponse, GetMuscleGroupsResponses, GetUsersData, GetUsersError, GetUsersErrors, GetUsersResponse, GetUsersResponses, GetWorkoutData, GetWorkoutError, GetWorkoutErrors, GetWorkoutResponse, GetWorkoutResponses, GetWorkoutsData, GetWorkoutsError, GetWorkoutsErrors, GetWorkoutsResponse, GetWorkoutsResponses, HttpValidationError, LoginData, LoginError, LoginErrors, LoginRequest, LoginResponse, LoginResponses, LogoutData, LogoutResponse, LogoutResponses, MuscleGroupPublic, RefreshTokenData, RefreshTokenError, RefreshTokenErrors, RefreshTokenResponse, RefreshTokenResponses, RegisterData, RegisterError, RegisterErrors, RegisterRequest, RegisterResponse, RegisterResponses, RequestAccessData, RequestAccessError, RequestAccessErrors, RequestAccessRequest, RequestAccessResponse, RequestAccessResponses, ResetPasswordData, ResetPasswordError, ResetPasswordErrors, ResetPasswordRequest, ResetPasswordResponse, ResetPasswordResponses, ReviewerPublic, SetPublic, UpdateAccessRequestStatusData, UpdateAccessRequestStatusError, UpdateAccessRequestStatusErrors, UpdateAccessRequestStatusRequest, UpdateAccessRequestStatusResponse, UpdateAccessRequestStatusResponses, UpdateExerciseData, UpdateExerciseError, UpdateExerciseErrors, UpdateExerciseRequest, UpdateExerciseResponse, UpdateExerciseResponses, UpdateWorkoutData, UpdateWorkoutError, UpdateWorkoutErrors, UpdateWorkoutRequest, UpdateWorkoutResponse, UpdateWorkoutResponses, UserPublic, ValidationError, WorkoutBase, WorkoutExercisePublic, WorkoutPublic } from './types.gen'; diff --git a/client/src/api/generated/schemas.gen.ts b/client/src/api/generated/schemas.gen.ts index 7222dd04..dbe0ed47 100644 --- a/client/src/api/generated/schemas.gen.ts +++ b/client/src/api/generated/schemas.gen.ts @@ -155,6 +155,48 @@ export const CreateFeedbackRequestSchema = { title: 'CreateFeedbackRequest' } as const; +export const CreateWorkoutRequestSchema = { + properties: { + started_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Started At' + }, + ended_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Ended At' + }, + notes: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Notes' + } + }, + type: 'object', + title: 'CreateWorkoutRequest' +} as const; + export const ErrorResponseSchema = { properties: { detail: { @@ -174,7 +216,7 @@ export const ErrorResponseSchema = { title: 'ErrorResponse' } as const; -export const ExercisePublicSchema = { +export const ExerciseBaseSchema = { properties: { id: { type: 'integer', @@ -206,13 +248,6 @@ export const ExercisePublicSchema = { ], title: 'Description' }, - muscle_groups: { - items: { - $ref: '#/components/schemas/MuscleGroupPublic' - }, - type: 'array', - title: 'Muscle Groups' - }, created_at: { type: 'string', format: 'date-time', @@ -230,10 +265,72 @@ export const ExercisePublicSchema = { 'user_id', 'name', 'description', - 'muscle_groups', 'created_at', 'updated_at' ], + title: 'ExerciseBase' +} as const; + +export const ExercisePublicSchema = { + properties: { + id: { + type: 'integer', + title: 'Id' + }, + user_id: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'User Id' + }, + name: { + type: 'string', + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + muscle_groups: { + items: { + $ref: '#/components/schemas/MuscleGroupPublic' + }, + type: 'array', + title: 'Muscle Groups' + } + }, + type: 'object', + required: [ + 'id', + 'user_id', + 'name', + 'description', + 'created_at', + 'updated_at', + 'muscle_groups' + ], title: 'ExercisePublic' } as const; @@ -444,6 +541,90 @@ export const ReviewerPublicSchema = { title: 'ReviewerPublic' } as const; +export const SetPublicSchema = { + properties: { + id: { + type: 'integer', + title: 'Id' + }, + workout_exercise_id: { + type: 'integer', + title: 'Workout Exercise Id' + }, + set_number: { + type: 'integer', + title: 'Set Number' + }, + reps: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Reps' + }, + weight: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Weight' + }, + unit: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Unit' + }, + notes: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: [ + 'id', + 'workout_exercise_id', + 'set_number', + 'reps', + 'weight', + 'unit', + 'notes', + 'created_at', + 'updated_at' + ], + title: 'SetPublic' +} as const; + export const UpdateAccessRequestStatusRequestSchema = { properties: { status: { @@ -507,6 +688,48 @@ export const UpdateExerciseRequestSchema = { title: 'UpdateExerciseRequest' } as const; +export const UpdateWorkoutRequestSchema = { + properties: { + started_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Started At' + }, + ended_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Ended At' + }, + notes: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Notes' + } + }, + type: 'object', + title: 'UpdateWorkoutRequest' +} as const; + export const UserPublicSchema = { properties: { id: { @@ -591,3 +814,200 @@ export const ValidationErrorSchema = { ], title: 'ValidationError' } as const; + +export const WorkoutBaseSchema = { + properties: { + id: { + type: 'integer', + title: 'Id' + }, + user_id: { + type: 'integer', + title: 'User Id' + }, + started_at: { + type: 'string', + format: 'date-time', + title: 'Started At' + }, + ended_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Ended At' + }, + notes: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: [ + 'id', + 'user_id', + 'started_at', + 'ended_at', + 'notes', + 'created_at', + 'updated_at' + ], + title: 'WorkoutBase' +} as const; + +export const WorkoutExercisePublicSchema = { + properties: { + id: { + type: 'integer', + title: 'Id' + }, + workout_id: { + type: 'integer', + title: 'Workout Id' + }, + exercise_id: { + type: 'integer', + title: 'Exercise Id' + }, + position: { + type: 'integer', + title: 'Position' + }, + notes: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + exercise: { + $ref: '#/components/schemas/ExerciseBase' + }, + sets: { + items: { + $ref: '#/components/schemas/SetPublic' + }, + type: 'array', + title: 'Sets' + } + }, + type: 'object', + required: [ + 'id', + 'workout_id', + 'exercise_id', + 'position', + 'notes', + 'created_at', + 'updated_at', + 'exercise', + 'sets' + ], + title: 'WorkoutExercisePublic' +} as const; + +export const WorkoutPublicSchema = { + properties: { + id: { + type: 'integer', + title: 'Id' + }, + user_id: { + type: 'integer', + title: 'User Id' + }, + started_at: { + type: 'string', + format: 'date-time', + title: 'Started At' + }, + ended_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Ended At' + }, + notes: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + exercises: { + items: { + $ref: '#/components/schemas/WorkoutExercisePublic' + }, + type: 'array', + title: 'Exercises' + } + }, + type: 'object', + required: [ + 'id', + 'user_id', + 'started_at', + 'ended_at', + 'notes', + 'created_at', + 'updated_at', + 'exercises' + ], + title: 'WorkoutPublic' +} as const; diff --git a/client/src/api/generated/sdk.gen.ts b/client/src/api/generated/sdk.gen.ts index 151c2700..9bcb870c 100644 --- a/client/src/api/generated/sdk.gen.ts +++ b/client/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { client } from './client.gen'; -import type { CreateExerciseData, CreateExerciseErrors, CreateExerciseResponses, CreateFeedbackData, CreateFeedbackErrors, CreateFeedbackResponses, DeleteExerciseData, DeleteExerciseErrors, DeleteExerciseResponses, ForgotPasswordData, ForgotPasswordErrors, ForgotPasswordResponses, GetAccessRequestsData, GetAccessRequestsErrors, GetAccessRequestsResponses, GetCurrentUserData, GetCurrentUserErrors, GetCurrentUserResponses, GetDbHealthData, GetDbHealthResponses, GetExerciseData, GetExerciseErrors, GetExerciseResponses, GetExercisesData, GetExercisesErrors, GetExercisesResponses, GetHealthData, GetHealthResponses, GetMuscleGroupsData, GetMuscleGroupsErrors, GetMuscleGroupsResponses, GetUsersData, GetUsersErrors, GetUsersResponses, LoginData, LoginErrors, LoginResponses, LogoutData, LogoutResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterResponses, RequestAccessData, RequestAccessErrors, RequestAccessResponses, ResetPasswordData, ResetPasswordErrors, ResetPasswordResponses, UpdateAccessRequestStatusData, UpdateAccessRequestStatusErrors, UpdateAccessRequestStatusResponses, UpdateExerciseData, UpdateExerciseErrors, UpdateExerciseResponses } from './types.gen'; +import type { CreateExerciseData, CreateExerciseErrors, CreateExerciseResponses, CreateFeedbackData, CreateFeedbackErrors, CreateFeedbackResponses, CreateWorkoutData, CreateWorkoutErrors, CreateWorkoutResponses, DeleteExerciseData, DeleteExerciseErrors, DeleteExerciseResponses, DeleteWorkoutData, DeleteWorkoutErrors, DeleteWorkoutResponses, ForgotPasswordData, ForgotPasswordErrors, ForgotPasswordResponses, GetAccessRequestsData, GetAccessRequestsErrors, GetAccessRequestsResponses, GetCurrentUserData, GetCurrentUserErrors, GetCurrentUserResponses, GetDbHealthData, GetDbHealthResponses, GetExerciseData, GetExerciseErrors, GetExerciseResponses, GetExercisesData, GetExercisesErrors, GetExercisesResponses, GetHealthData, GetHealthResponses, GetMuscleGroupsData, GetMuscleGroupsErrors, GetMuscleGroupsResponses, GetUsersData, GetUsersErrors, GetUsersResponses, GetWorkoutData, GetWorkoutErrors, GetWorkoutResponses, GetWorkoutsData, GetWorkoutsErrors, GetWorkoutsResponses, LoginData, LoginErrors, LoginResponses, LogoutData, LogoutResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterResponses, RequestAccessData, RequestAccessErrors, RequestAccessResponses, ResetPasswordData, ResetPasswordErrors, ResetPasswordResponses, UpdateAccessRequestStatusData, UpdateAccessRequestStatusErrors, UpdateAccessRequestStatusResponses, UpdateExerciseData, UpdateExerciseErrors, UpdateExerciseResponses, UpdateWorkoutData, UpdateWorkoutErrors, UpdateWorkoutResponses } from './types.gen'; export type Options = Options2 & { /** @@ -188,7 +188,6 @@ export class ExercisesService { */ public static createExercise(options: Options) { return (options.client ?? client).post({ - responseType: 'json', security: [{ in: 'cookie', name: 'access_token', @@ -336,3 +335,90 @@ export class UserService { }); } } + +export class WorkoutsService { + /** + * Get Workouts Endpoint + */ + public static getWorkouts(options?: Options) { + return (options?.client ?? client).get({ + responseType: 'json', + security: [{ + in: 'cookie', + name: 'access_token', + type: 'apiKey' + }], + url: '/api/workouts', + ...options + }); + } + + /** + * Create Workout Endpoint + */ + public static createWorkout(options: Options) { + return (options.client ?? client).post({ + security: [{ + in: 'cookie', + name: 'access_token', + type: 'apiKey' + }], + url: '/api/workouts', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + /** + * Delete Workout Endpoint + */ + public static deleteWorkout(options: Options) { + return (options.client ?? client).delete({ + security: [{ + in: 'cookie', + name: 'access_token', + type: 'apiKey' + }], + url: '/api/workouts/{workout_id}', + ...options + }); + } + + /** + * Get Workout Endpoint + */ + public static getWorkout(options: Options) { + return (options.client ?? client).get({ + responseType: 'json', + security: [{ + in: 'cookie', + name: 'access_token', + type: 'apiKey' + }], + url: '/api/workouts/{workout_id}', + ...options + }); + } + + /** + * Update Workout Endpoint + */ + public static updateWorkout(options: Options) { + return (options.client ?? client).patch({ + security: [{ + in: 'cookie', + name: 'access_token', + type: 'apiKey' + }], + url: '/api/workouts/{workout_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } +} diff --git a/client/src/api/generated/types.gen.ts b/client/src/api/generated/types.gen.ts index 204d6ed7..223105ec 100644 --- a/client/src/api/generated/types.gen.ts +++ b/client/src/api/generated/types.gen.ts @@ -86,6 +86,24 @@ export type CreateFeedbackRequest = { files?: Array; }; +/** + * CreateWorkoutRequest + */ +export type CreateWorkoutRequest = { + /** + * Started At + */ + started_at?: string | null; + /** + * Ended At + */ + ended_at?: string | null; + /** + * Notes + */ + notes?: string | null; +}; + /** * ErrorResponse */ @@ -101,9 +119,9 @@ export type ErrorResponse = { }; /** - * ExercisePublic + * ExerciseBase */ -export type ExercisePublic = { +export type ExerciseBase = { /** * Id */ @@ -121,9 +139,35 @@ export type ExercisePublic = { */ description: string | null; /** - * Muscle Groups + * Created At */ - muscle_groups: Array; + created_at: string; + /** + * Updated At + */ + updated_at: string; +}; + +/** + * ExercisePublic + */ +export type ExercisePublic = { + /** + * Id + */ + id: number; + /** + * User Id + */ + user_id: number | null; + /** + * Name + */ + name: string; + /** + * Description + */ + description: string | null; /** * Created At */ @@ -132,6 +176,10 @@ export type ExercisePublic = { * Updated At */ updated_at: string; + /** + * Muscle Groups + */ + muscle_groups: Array; }; /** @@ -259,6 +307,48 @@ export type ReviewerPublic = { username: string; }; +/** + * SetPublic + */ +export type SetPublic = { + /** + * Id + */ + id: number; + /** + * Workout Exercise Id + */ + workout_exercise_id: number; + /** + * Set Number + */ + set_number: number; + /** + * Reps + */ + reps: number | null; + /** + * Weight + */ + weight: number | null; + /** + * Unit + */ + unit: string | null; + /** + * Notes + */ + notes: string | null; + /** + * Created At + */ + created_at: string; + /** + * Updated At + */ + updated_at: string; +}; + /** * UpdateAccessRequestStatusRequest */ @@ -287,6 +377,24 @@ export type UpdateExerciseRequest = { muscle_group_ids?: Array | null; }; +/** + * UpdateWorkoutRequest + */ +export type UpdateWorkoutRequest = { + /** + * Started At + */ + started_at?: string | null; + /** + * Ended At + */ + ended_at?: string | null; + /** + * Notes + */ + notes?: string | null; +}; + /** * UserPublic */ @@ -343,6 +451,117 @@ export type ValidationError = { type: string; }; +/** + * WorkoutBase + */ +export type WorkoutBase = { + /** + * Id + */ + id: number; + /** + * User Id + */ + user_id: number; + /** + * Started At + */ + started_at: string; + /** + * Ended At + */ + ended_at: string | null; + /** + * Notes + */ + notes: string | null; + /** + * Created At + */ + created_at: string; + /** + * Updated At + */ + updated_at: string; +}; + +/** + * WorkoutExercisePublic + */ +export type WorkoutExercisePublic = { + /** + * Id + */ + id: number; + /** + * Workout Id + */ + workout_id: number; + /** + * Exercise Id + */ + exercise_id: number; + /** + * Position + */ + position: number; + /** + * Notes + */ + notes: string | null; + /** + * Created At + */ + created_at: string; + /** + * Updated At + */ + updated_at: string; + exercise: ExerciseBase; + /** + * Sets + */ + sets: Array; +}; + +/** + * WorkoutPublic + */ +export type WorkoutPublic = { + /** + * Id + */ + id: number; + /** + * User Id + */ + user_id: number; + /** + * Started At + */ + started_at: string; + /** + * Ended At + */ + ended_at: string | null; + /** + * Notes + */ + notes: string | null; + /** + * Created At + */ + created_at: string; + /** + * Updated At + */ + updated_at: string; + /** + * Exercises + */ + exercises: Array; +}; + export type GetAccessRequestsData = { body?: never; path?: never; @@ -702,7 +921,7 @@ export type CreateExerciseResponses = { /** * Successful Response */ - 201: ExercisePublic; + 204: void; }; export type CreateExerciseResponse = CreateExerciseResponses[keyof CreateExerciseResponses]; @@ -947,3 +1166,173 @@ export type GetCurrentUserResponses = { }; export type GetCurrentUserResponse = GetCurrentUserResponses[keyof GetCurrentUserResponses]; + +export type GetWorkoutsData = { + body?: never; + path?: never; + query?: never; + url: '/api/workouts'; +}; + +export type GetWorkoutsErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse; +}; + +export type GetWorkoutsError = GetWorkoutsErrors[keyof GetWorkoutsErrors]; + +export type GetWorkoutsResponses = { + /** + * Response Getworkouts + * + * Successful Response + */ + 200: Array; +}; + +export type GetWorkoutsResponse = GetWorkoutsResponses[keyof GetWorkoutsResponses]; + +export type CreateWorkoutData = { + body: CreateWorkoutRequest; + path?: never; + query?: never; + url: '/api/workouts'; +}; + +export type CreateWorkoutErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateWorkoutError = CreateWorkoutErrors[keyof CreateWorkoutErrors]; + +export type CreateWorkoutResponses = { + /** + * Successful Response + */ + 204: void; +}; + +export type CreateWorkoutResponse = CreateWorkoutResponses[keyof CreateWorkoutResponses]; + +export type DeleteWorkoutData = { + body?: never; + path: { + /** + * Workout Id + */ + workout_id: number; + }; + query?: never; + url: '/api/workouts/{workout_id}'; +}; + +export type DeleteWorkoutErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse; + /** + * Not Found + */ + 404: ErrorResponse; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteWorkoutError = DeleteWorkoutErrors[keyof DeleteWorkoutErrors]; + +export type DeleteWorkoutResponses = { + /** + * Successful Response + */ + 204: void; +}; + +export type DeleteWorkoutResponse = DeleteWorkoutResponses[keyof DeleteWorkoutResponses]; + +export type GetWorkoutData = { + body?: never; + path: { + /** + * Workout Id + */ + workout_id: number; + }; + query?: never; + url: '/api/workouts/{workout_id}'; +}; + +export type GetWorkoutErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse; + /** + * Not Found + */ + 404: ErrorResponse; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetWorkoutError = GetWorkoutErrors[keyof GetWorkoutErrors]; + +export type GetWorkoutResponses = { + /** + * Successful Response + */ + 200: WorkoutPublic; +}; + +export type GetWorkoutResponse = GetWorkoutResponses[keyof GetWorkoutResponses]; + +export type UpdateWorkoutData = { + body: UpdateWorkoutRequest; + path: { + /** + * Workout Id + */ + workout_id: number; + }; + query?: never; + url: '/api/workouts/{workout_id}'; +}; + +export type UpdateWorkoutErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse; + /** + * Not Found + */ + 404: ErrorResponse; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateWorkoutError = UpdateWorkoutErrors[keyof UpdateWorkoutErrors]; + +export type UpdateWorkoutResponses = { + /** + * Successful Response + */ + 204: void; +}; + +export type UpdateWorkoutResponse = UpdateWorkoutResponses[keyof UpdateWorkoutResponses]; diff --git a/client/src/api/generated/zod.gen.ts b/client/src/api/generated/zod.gen.ts index ba17b547..c7851bd9 100644 --- a/client/src/api/generated/zod.gen.ts +++ b/client/src/api/generated/zod.gen.ts @@ -20,6 +20,15 @@ export const zCreateExerciseRequest = z.object({ muscle_group_ids: z.array(z.int()).optional() }); +/** + * CreateWorkoutRequest + */ +export const zCreateWorkoutRequest = z.object({ + started_at: z.iso.datetime().nullish(), + ended_at: z.iso.datetime().nullish(), + notes: z.string().nullish() +}); + /** * ErrorResponse */ @@ -28,6 +37,18 @@ export const zErrorResponse = z.object({ code: z.string() }); +/** + * ExerciseBase + */ +export const zExerciseBase = z.object({ + id: z.int(), + user_id: z.int().nullable(), + name: z.string(), + description: z.string().nullable(), + created_at: z.iso.datetime(), + updated_at: z.iso.datetime() +}); + /** * FeedbackType */ @@ -77,9 +98,9 @@ export const zExercisePublic = z.object({ user_id: z.int().nullable(), name: z.string(), description: z.string().nullable(), - muscle_groups: z.array(zMuscleGroupPublic), created_at: z.iso.datetime(), - updated_at: z.iso.datetime() + updated_at: z.iso.datetime(), + muscle_groups: z.array(zMuscleGroupPublic) }); /** @@ -131,6 +152,21 @@ export const zAccessRequestPublic = z.object({ updated_at: z.iso.datetime() }); +/** + * SetPublic + */ +export const zSetPublic = z.object({ + id: z.int(), + workout_exercise_id: z.int(), + set_number: z.int(), + reps: z.int().nullable(), + weight: z.number().nullable(), + unit: z.string().nullable(), + notes: z.string().nullable(), + created_at: z.iso.datetime(), + updated_at: z.iso.datetime() +}); + /** * UpdateAccessRequestStatusRequest */ @@ -147,6 +183,15 @@ export const zUpdateExerciseRequest = z.object({ muscle_group_ids: z.array(z.int()).nullish() }); +/** + * UpdateWorkoutRequest + */ +export const zUpdateWorkoutRequest = z.object({ + started_at: z.iso.datetime().nullish(), + ended_at: z.iso.datetime().nullish(), + notes: z.string().nullish() +}); + /** * UserPublic */ @@ -177,6 +222,48 @@ export const zHttpValidationError = z.object({ detail: z.array(zValidationError).optional() }); +/** + * WorkoutBase + */ +export const zWorkoutBase = z.object({ + id: z.int(), + user_id: z.int(), + started_at: z.iso.datetime(), + ended_at: z.iso.datetime().nullable(), + notes: z.string().nullable(), + created_at: z.iso.datetime(), + updated_at: z.iso.datetime() +}); + +/** + * WorkoutExercisePublic + */ +export const zWorkoutExercisePublic = z.object({ + id: z.int(), + workout_id: z.int(), + exercise_id: z.int(), + position: z.int(), + notes: z.string().nullable(), + created_at: z.iso.datetime(), + updated_at: z.iso.datetime(), + exercise: zExerciseBase, + sets: z.array(zSetPublic) +}); + +/** + * WorkoutPublic + */ +export const zWorkoutPublic = z.object({ + id: z.int(), + user_id: z.int(), + started_at: z.iso.datetime(), + ended_at: z.iso.datetime().nullable(), + notes: z.string().nullable(), + created_at: z.iso.datetime(), + updated_at: z.iso.datetime(), + exercises: z.array(zWorkoutExercisePublic) +}); + export const zGetAccessRequestsData = z.object({ body: z.never().optional(), path: z.never().optional(), @@ -317,7 +404,7 @@ export const zCreateExerciseData = z.object({ /** * Successful Response */ -export const zCreateExerciseResponse = zExercisePublic; +export const zCreateExerciseResponse = z.void(); export const zDeleteExerciseData = z.object({ body: z.never().optional(), @@ -413,3 +500,66 @@ export const zGetCurrentUserData = z.object({ * Successful Response */ export const zGetCurrentUserResponse = zUserPublic; + +export const zGetWorkoutsData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional() +}); + +/** + * Response Getworkouts + * + * Successful Response + */ +export const zGetWorkoutsResponse = z.array(zWorkoutBase); + +export const zCreateWorkoutData = z.object({ + body: zCreateWorkoutRequest, + path: z.never().optional(), + query: z.never().optional() +}); + +/** + * Successful Response + */ +export const zCreateWorkoutResponse = z.void(); + +export const zDeleteWorkoutData = z.object({ + body: z.never().optional(), + path: z.object({ + workout_id: z.int() + }), + query: z.never().optional() +}); + +/** + * Successful Response + */ +export const zDeleteWorkoutResponse = z.void(); + +export const zGetWorkoutData = z.object({ + body: z.never().optional(), + path: z.object({ + workout_id: z.int() + }), + query: z.never().optional() +}); + +/** + * Successful Response + */ +export const zGetWorkoutResponse = zWorkoutPublic; + +export const zUpdateWorkoutData = z.object({ + body: zUpdateWorkoutRequest, + path: z.object({ + workout_id: z.int() + }), + query: z.never().optional() +}); + +/** + * Successful Response + */ +export const zUpdateWorkoutResponse = z.void(); diff --git a/client/src/components/FeedbackFormDialog.tsx b/client/src/components/FeedbackFormDialog.tsx index 156a47d2..f0a74fad 100644 --- a/client/src/components/FeedbackFormDialog.tsx +++ b/client/src/components/FeedbackFormDialog.tsx @@ -1,5 +1,6 @@ import { FeedbackService } from '@/api/generated' import { zCreateFeedbackRequest } from '@/api/generated/zod.gen' +import { Field } from '@/components/forms/Field' import { Dialog, DialogClose, @@ -11,7 +12,6 @@ import { DialogTrigger, } 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' @@ -135,8 +135,11 @@ export function FeedbackFormDialog() { void handleSubmit(onSubmit)(e) }} > -
- + - {errors.title && ( -

- {errors.title.message} -

- )} -
-
- + +