diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aa20bf4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +**/.git +originals/ +original/ +.migration/ +.venv/ +**/__pycache__/ +**/.pytest_cache/ +**/node_modules/ +**/dist/ +**/.env +**/.env.* +**/data/ +**/*.sqlite* +**/*.db* +migration/ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..26ed036 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,75 @@ +name: App checks + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + tournament: ${{ steps.filter.outputs.tournament }} + shifts: ${{ steps.filter.outputs.shifts }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + tournament: + - 'apps/tournament/**' + - 'packages/crr-brand/**' + - 'packages/crr-python/**' + - 'pyproject.toml' + - 'uv.lock' + - 'Makefile' + - '.dockerignore' + - 'compose.yaml' + - '.github/workflows/**' + shifts: + - 'apps/shifts/**' + - 'packages/crr-brand/**' + - 'packages/crr-python/**' + - 'pyproject.toml' + - 'uv.lock' + - '.python-version' + - 'Makefile' + - '.dockerignore' + - 'compose.yaml' + - '.github/workflows/**' + + tournament: + needs: changes + if: needs.changes.outputs.tournament == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: apps/tournament/package-lock.json + - run: make tournament-install tournament-test tournament-build + - run: docker compose config --quiet + - run: docker build -f apps/tournament/Dockerfile -t crr-tournament . + + shifts: + needs: changes + if: needs.changes.outputs.shifts == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + version: '0.11.31' + enable-cache: true + - run: make shifts-test + - run: docker compose config --quiet + - run: make shifts-build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c880468 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.DS_Store +.migration/ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +node_modules/ +dist/ +.env +.env.* +!.env.example +data/ +*.sqlite +*.sqlite-shm +*.sqlite-wal +*.db +*.db-shm +*.db-wal +!migration/fixtures/*.sqlite +migration/results/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..207c5b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# CRR tools monorepo + +- Follow `MONOREPO_MIGRATION_PLAN.md`; record verified progress in `migration/README.md`. +- `originals/` contains read-only legacy snapshots. Never edit, rename, or delete + their files, including their nested Git repositories. Run baseline checks in + isolated copies to keep dependency installation and build outputs outside them. +- Production code lives in `apps/` and must never import from `originals/`. +- Keep the applications independently runnable, deployable, versioned, and tested. +- Preserve routes, authentication, Portuguese messages, and SQLite data during + structural changes. Keep domain models and authentication app-specific. +- Target Python 3.13, uv, FastAPI, SQLAlchemy 2, Alembic, and Pydantic. +- Only extract shared infrastructure after both applications demonstrate the need. +- Report checks and migration blockers accurately; do not mark a phase complete + until its exit criteria have been verified. diff --git a/MONOREPO_FILE_MAP.tsv b/MONOREPO_FILE_MAP.tsv new file mode 100644 index 0000000..b5c17fd --- /dev/null +++ b/MONOREPO_FILE_MAP.tsv @@ -0,0 +1,122 @@ +Repository Source Action Destination Notes +Tournament Manager original/tournament-manager/.dockerignore merge /.dockerignore Merge Docker ignore rules for root build context +Tournament Manager original/tournament-manager/.env.example copy apps/tournament/.env.example Keep app-specific environment contract +Tournament Manager original/tournament-manager/.gitignore merge /.gitignore Merge repository ignore rules +Tournament Manager original/tournament-manager/AGENTS.md copy+edit apps/tournament/AGENTS.md Keep tournament-specific contributor guidance +Tournament Manager original/tournament-manager/AGENTS.md.bak leave-in-original — Redundant backup after AGENTS.md is migrated; no migrated copy is needed +Tournament Manager original/tournament-manager/Dockerfile rewrite apps/tournament/Dockerfile Replace Node backend runtime with Node frontend build + Python 3.13 FastAPI runtime +Tournament Manager original/tournament-manager/README.md copy+edit apps/tournament/README.md Update monorepo/dev/deploy commands +Tournament Manager original/tournament-manager/compose.yaml merge /compose.yaml Tournament service becomes one root Compose service +Tournament Manager original/tournament-manager/index.html copy apps/tournament/frontend/index.html Vite frontend root +Tournament Manager original/tournament-manager/logo.png copy+canonicalize packages/crr-brand/assets/logo.png Use as canonical high-resolution CRR logo +Tournament Manager original/tournament-manager/package-lock.json copy apps/tournament/frontend/package-lock.json Regenerate after package.json cleanup +Tournament Manager original/tournament-manager/package.json copy+edit apps/tournament/frontend/package.json Remove backend Node dependencies/scripts +Tournament Manager original/tournament-manager/public/favicon.png copy+canonicalize packages/crr-brand/assets/favicon.png Shared favicon +Tournament Manager original/tournament-manager/src/client/App.vue copy+edit apps/tournament/frontend/src/App.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/AdminShell.vue copy+edit apps/tournament/frontend/src/components/AdminShell.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/AppLogo.vue copy+edit apps/tournament/frontend/src/components/AppLogo.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/AppModal.vue copy+edit apps/tournament/frontend/src/components/AppModal.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/ClassificationTable.vue copy+edit apps/tournament/frontend/src/components/ClassificationTable.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/DisplayClassificationPanel.vue copy+edit apps/tournament/frontend/src/components/DisplayClassificationPanel.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/DisplayFit.vue copy+edit apps/tournament/frontend/src/components/DisplayFit.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/DisplayLatestResultsPanel.vue copy+edit apps/tournament/frontend/src/components/DisplayLatestResultsPanel.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/FinalBracket.vue copy+edit apps/tournament/frontend/src/components/FinalBracket.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/MatchResultCard.vue copy+edit apps/tournament/frontend/src/components/MatchResultCard.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/match-cards/FourScores.vue copy+edit apps/tournament/frontend/src/components/match-cards/FourScores.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/match-cards/SingleScore.vue copy+edit apps/tournament/frontend/src/components/match-cards/SingleScore.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/match-cards/SixScores.vue copy+edit apps/tournament/frontend/src/components/match-cards/SixScores.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/components/matchCards.ts copy+edit apps/tournament/frontend/src/components/matchCards.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/displayPanels.ts copy+edit apps/tournament/frontend/src/displayPanels.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/lib/api.ts copy+edit apps/tournament/frontend/src/lib/api.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/lib/format.test.ts copy+edit apps/tournament/frontend/src/lib/format.test.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/lib/format.ts copy+edit apps/tournament/frontend/src/lib/format.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/lib/score.test.ts copy+edit apps/tournament/frontend/src/lib/score.test.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/lib/score.ts copy+edit apps/tournament/frontend/src/lib/score.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/lib/usePublicTournamentState.ts copy+edit apps/tournament/frontend/src/lib/usePublicTournamentState.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/main.ts copy+edit apps/tournament/frontend/src/main.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/router.ts copy+edit apps/tournament/frontend/src/router.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/styles.css copy+edit apps/tournament/frontend/src/styles.css Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/types.ts copy+edit apps/tournament/frontend/src/types.ts Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/CalendarView.vue copy+edit apps/tournament/frontend/src/views/CalendarView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/DisplaySettingsView.vue copy+edit apps/tournament/frontend/src/views/DisplaySettingsView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/DisplayView.vue copy+edit apps/tournament/frontend/src/views/DisplayView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/FinalStageView.vue copy+edit apps/tournament/frontend/src/views/FinalStageView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/LoginView.vue copy+edit apps/tournament/frontend/src/views/LoginView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/MatchCardsView.vue copy+edit apps/tournament/frontend/src/views/MatchCardsView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/PublicResultsView.vue copy+edit apps/tournament/frontend/src/views/PublicResultsView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/ResultsView.vue copy+edit apps/tournament/frontend/src/views/ResultsView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/client/views/TeamsView.vue copy+edit apps/tournament/frontend/src/views/TeamsView.vue Adjust imports/base paths/brand imports as needed +Tournament Manager original/tournament-manager/src/server/app.integration.test.ts port apps/tournament/backend/tests/test_api_compatibility.py Port all observable route/status/error contract tests +Tournament Manager original/tournament-manager/src/server/app.ts port+split apps/tournament/backend/app/{routers,schemas,services}/ Split Fastify monolith into FastAPI routers and Pydantic schemas; preserve API contract +Tournament Manager original/tournament-manager/src/server/auth.ts port+split apps/tournament/backend/app/security/{session.py,rate_limit.py} Preserve cookie name/token format/rate-limit semantics +Tournament Manager original/tournament-manager/src/server/db/database.test.ts port apps/tournament/backend/tests/test_database.py Port schema/default/constraint tests +Tournament Manager original/tournament-manager/src/server/db/database.ts port+split apps/tournament/backend/app/database.py + app/models/ + migrations/versions/0001_baseline.py SQLAlchemy model + Alembic baseline with legacy DB adoption +Tournament Manager original/tournament-manager/src/server/errors.ts port apps/tournament/backend/app/errors.py Preserve JSON error shape +Tournament Manager original/tournament-manager/src/server/events.ts port apps/tournament/backend/app/events.py SSE state-change broker +Tournament Manager original/tournament-manager/src/server/main.ts port apps/tournament/backend/app/main.py FastAPI application entry point +Tournament Manager original/tournament-manager/src/server/services/bracket.test.ts port apps/tournament/backend/tests/services/test_bracket.py +Tournament Manager original/tournament-manager/src/server/services/bracket.ts port apps/tournament/backend/app/services/bracket.py +Tournament Manager original/tournament-manager/src/server/services/calendar.test.ts port apps/tournament/backend/tests/services/test_calendar.py +Tournament Manager original/tournament-manager/src/server/services/calendar.ts port apps/tournament/backend/app/services/calendar.py +Tournament Manager original/tournament-manager/src/server/services/classification.test.ts port apps/tournament/backend/tests/services/test_classification.py +Tournament Manager original/tournament-manager/src/server/services/classification.ts port apps/tournament/backend/app/services/classification.py +Tournament Manager original/tournament-manager/src/server/services/state.ts port apps/tournament/backend/app/services/state.py Translate SQL-backed state assembly to SQLAlchemy while preserving response shape +Tournament Manager original/tournament-manager/src/shared/types/tournament.ts copy+edit apps/tournament/frontend/src/types/tournament.ts Frontend API types remain TypeScript; FastAPI Pydantic schemas mirror this contract +Tournament Manager original/tournament-manager/src/vite-env.d.ts copy apps/tournament/frontend/src/vite-env.d.ts +Tournament Manager original/tournament-manager/tsconfig.client.json copy+rename apps/tournament/frontend/tsconfig.json Update include paths +Tournament Manager original/tournament-manager/tsconfig.server.json leave-in-original — Node/TypeScript backend removed; no migrated copy is needed +Tournament Manager original/tournament-manager/vite.config.ts copy+edit apps/tournament/frontend/vite.config.ts Proxy to FastAPI and read shared brand assets +CRR Shifts original/crr-shifts/.dockerignore merge /.dockerignore Merge Docker ignore rules for root build context +CRR Shifts original/crr-shifts/.env.example copy+edit apps/shifts/.env.example Keep app-specific environment contract; Python 3.13 runtime +CRR Shifts original/crr-shifts/.gitignore merge /.gitignore Merge repository ignore rules +CRR Shifts original/crr-shifts/AGENTS.md copy+edit apps/shifts/AGENTS.md Keep shifts-specific contributor guidance +CRR Shifts original/crr-shifts/Dockerfile rewrite apps/shifts/Dockerfile Python 3.13 + uv; root build context +CRR Shifts original/crr-shifts/Makefile merge /Makefile Convert to root namespaced targets such as shifts-dev/shifts-test +CRR Shifts original/crr-shifts/README.md copy+edit apps/shifts/README.md Update monorepo/dev/deploy commands +CRR Shifts original/crr-shifts/alembic.ini copy+edit apps/shifts/backend/alembic.ini Update script paths for backend subdirectory +CRR Shifts original/crr-shifts/app/__init__.py copy apps/shifts/backend/app/__init__.py +CRR Shifts original/crr-shifts/app/bootstrap.py copy+refactor apps/shifts/backend/app/services/bootstrap.py No behavior change +CRR Shifts original/crr-shifts/app/calendar.py copy+refactor apps/shifts/backend/app/services/calendar_feed.py Avoid name collision with router +CRR Shifts original/crr-shifts/app/cli.py copy+edit apps/shifts/backend/app/cli.py Update imports/paths +CRR Shifts original/crr-shifts/app/config.py copy+edit apps/shifts/backend/app/config.py Keep settings contract +CRR Shifts original/crr-shifts/app/database.py copy+edit apps/shifts/backend/app/database.py Update migration paths; later share proven generic helpers +CRR Shifts original/crr-shifts/app/i18n.py copy apps/shifts/backend/app/i18n.py +CRR Shifts original/crr-shifts/app/main.py copy+edit apps/shifts/backend/app/main.py Include split routers and shared brand StaticFiles mount +CRR Shifts original/crr-shifts/app/models.py split apps/shifts/backend/app/models/{user.py,team.py,schedule.py,assignment.py,swap.py,notification.py,__init__.py} Pure structural split; retain SQLAlchemy schema +CRR Shifts original/crr-shifts/app/notifications.py copy+refactor apps/shifts/backend/app/services/notifications.py +CRR Shifts original/crr-shifts/app/pdf.py copy+refactor apps/shifts/backend/app/services/pdf.py +CRR Shifts original/crr-shifts/app/scheduling.py copy+refactor apps/shifts/backend/app/services/scheduling.py +CRR Shifts original/crr-shifts/app/security.py split apps/shifts/backend/app/security/{passwords.py,csrf.py,__init__.py} Keep auth model independent from Tournament +CRR Shifts original/crr-shifts/app/static/app.css copy+edit apps/shifts/backend/app/static/app.css Remove extracted shared brand tokens/components +CRR Shifts original/crr-shifts/app/static/logo-crr.png reference-shared-copy packages/crr-brand/assets/logo.png Use shared canonical logo; do not keep duplicate +CRR Shifts original/crr-shifts/app/templates/_schedule_export_form.html copy+edit apps/shifts/backend/app/templates/_schedule_export_form.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/account.html copy+edit apps/shifts/backend/app/templates/account.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/admin_assign.html copy+edit apps/shifts/backend/app/templates/admin_assign.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/admin_notifications.html copy+edit apps/shifts/backend/app/templates/admin_notifications.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/admin_schedule.html copy+edit apps/shifts/backend/app/templates/admin_schedule.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/admin_schedules.html copy+edit apps/shifts/backend/app/templates/admin_schedules.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/admin_team_delete.html copy+edit apps/shifts/backend/app/templates/admin_team_delete.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/admin_teams.html copy+edit apps/shifts/backend/app/templates/admin_teams.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/admin_users.html copy+edit apps/shifts/backend/app/templates/admin_users.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/base.html copy+edit apps/shifts/backend/app/templates/base.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/calendar.html copy+edit apps/shifts/backend/app/templates/calendar.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/dashboard.html copy+edit apps/shifts/backend/app/templates/dashboard.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/export_schedule.html copy+edit apps/shifts/backend/app/templates/export_schedule.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/login.html copy+edit apps/shifts/backend/app/templates/login.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/swap_new.html copy+edit apps/shifts/backend/app/templates/swap_new.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/templates/swaps.html copy+edit apps/shifts/backend/app/templates/swaps.html Only update shared-brand/static references as needed +CRR Shifts original/crr-shifts/app/web.py split apps/shifts/backend/app/routers/ Split by auth/account/calendar/swaps/admin while preserving routes/templates +CRR Shifts original/crr-shifts/compose.yaml merge /compose.yaml Shifts service becomes one root Compose service +CRR Shifts original/crr-shifts/docs/ARCHITECTURE.md copy+edit apps/shifts/docs/ARCHITECTURE.md Update paths/architecture notes +CRR Shifts original/crr-shifts/docs/DOMAIN.md copy+edit apps/shifts/docs/DOMAIN.md Update paths/architecture notes +CRR Shifts original/crr-shifts/migrations/README copy+edit apps/shifts/backend/migrations/README +CRR Shifts original/crr-shifts/migrations/env.py copy+edit apps/shifts/backend/migrations/env.py Update imports/path +CRR Shifts original/crr-shifts/migrations/script.py.mako copy apps/shifts/backend/migrations/script.py.mako +CRR Shifts original/crr-shifts/migrations/versions/0001_initial.py copy apps/shifts/backend/migrations/versions/0001_initial.py Preserve migration history unchanged +CRR Shifts original/crr-shifts/pyproject.toml rewrite apps/shifts/backend/pyproject.toml Full uv project metadata + runtime/dev dependencies +CRR Shifts original/crr-shifts/pyrightconfig.json merge /pyrightconfig.json One root Python type-check config for both backends +CRR Shifts original/crr-shifts/requirements-dev.txt leave-in-original — Dependencies move to pyproject.toml/uv.lock; no migrated copy is needed +CRR Shifts original/crr-shifts/requirements.txt leave-in-original — Dependencies move to pyproject.toml/uv.lock; no migrated copy is needed +CRR Shifts original/crr-shifts/tests/.gitkeep copy+edit apps/shifts/backend/tests/.gitkeep Update imports/paths only +CRR Shifts original/crr-shifts/tests/conftest.py copy+edit apps/shifts/backend/tests/conftest.py Update imports/paths only +CRR Shifts original/crr-shifts/tests/test_export_schedule.py copy+edit apps/shifts/backend/tests/test_export_schedule.py Update imports/paths only diff --git a/MONOREPO_MIGRATION_PLAN.md b/MONOREPO_MIGRATION_PLAN.md new file mode 100644 index 0000000..4753048 --- /dev/null +++ b/MONOREPO_MIGRATION_PLAN.md @@ -0,0 +1,569 @@ +# CRR Apps Monorepo Migration Plan + +## Fixed decisions + +- One monorepo, two independently deployable applications. +- Python 3.13 for both backends. +- FastAPI + SQLAlchemy 2 + Alembic + Pydantic. +- `uv` + `pyproject.toml` with one root `uv.lock`. +- Tournament keeps its Vue 3 frontend and its existing `/api/...` contract. +- Shifts remains server-rendered with Jinja. +- Auth models remain app-specific; only proven low-level helpers may be shared later. +- Separate SQLite databases and separate Alembic histories. +- Separate Docker images and independent app versions/releases. +- Shared CRR branding, not shared domain models. +- CI builds/tests only affected apps and packages that can affect them. + +## Original source baseline + +All migration work is sourced from the untouched legacy code under `originals/`: + +```text +originals/ +├── tournment-manager-web/ +└── crr-shifts/ +``` + +The file map and later sections retain the original proposal's source labels. +Resolve `original/tournament-manager/` to `originals/tournment-manager-web/` and +`original/crr-shifts/` to `originals/crr-shifts/` in this workspace. These are path +aliases only; do not rename the snapshots. Verified progress is recorded in +`migration/README.md`. + +Rules for `originals/`: + +- Treat it as read-only reference source throughout the migration. +- Never move, rename, delete, reformat or update files in `originals/` as part of the migration. +- New monorepo code is created by copying, porting or rewriting from `originals/...` into `apps/...` and `packages/...`. +- Behaviour/compatibility checks compare the migrated app against the corresponding code in `originals/`. +- Runtime code must never import from `originals/`. +- Add `originals/` to the root `.dockerignore` so legacy sources are not sent into Docker build contexts. +- CI path filters do not build/deploy from `originals/`; it exists only as the migration baseline. + +## Target repository + +```text +crr-apps/ +├── .dockerignore +├── .gitignore +├── .python-version # 3.13 +├── AGENTS.md # repository-wide rules only +├── README.md +├── Makefile +├── compose.yaml +├── pyproject.toml # uv workspace only +├── pyrightconfig.json +├── uv.lock +│ +├── original/ # read-only legacy baseline +│ ├── tournament-manager/ +│ └── crr-shifts/ +│ +├── apps/ +│ ├── tournament/ +│ │ ├── .env.example +│ │ ├── AGENTS.md +│ │ ├── Dockerfile +│ │ ├── README.md +│ │ ├── backend/ +│ │ │ ├── alembic.ini +│ │ │ ├── pyproject.toml +│ │ │ ├── app/ +│ │ │ │ ├── __init__.py +│ │ │ │ ├── config.py +│ │ │ │ ├── database.py +│ │ │ │ ├── errors.py +│ │ │ │ ├── events.py +│ │ │ │ ├── main.py +│ │ │ │ ├── models/ +│ │ │ │ ├── routers/ +│ │ │ │ │ ├── auth.py +│ │ │ │ │ ├── public.py +│ │ │ │ │ └── admin/ +│ │ │ │ │ ├── settings.py +│ │ │ │ │ ├── display.py +│ │ │ │ │ ├── groups.py +│ │ │ │ │ ├── teams.py +│ │ │ │ │ ├── calendar.py +│ │ │ │ │ ├── matches.py +│ │ │ │ │ └── final_stage.py +│ │ │ │ ├── schemas/ +│ │ │ │ ├── security/ +│ │ │ │ │ ├── session.py +│ │ │ │ │ └── rate_limit.py +│ │ │ │ └── services/ +│ │ │ │ ├── bracket.py +│ │ │ │ ├── calendar.py +│ │ │ │ ├── classification.py +│ │ │ │ └── state.py +│ │ │ ├── migrations/ +│ │ │ │ └── versions/ +│ │ │ │ └── 0001_baseline.py +│ │ │ └── tests/ +│ │ └── frontend/ +│ │ ├── index.html +│ │ ├── package.json +│ │ ├── package-lock.json +│ │ ├── tsconfig.json +│ │ ├── vite.config.ts +│ │ └── src/ +│ │ +│ └── shifts/ +│ ├── .env.example +│ ├── AGENTS.md +│ ├── Dockerfile +│ ├── README.md +│ ├── docs/ +│ └── backend/ +│ ├── alembic.ini +│ ├── pyproject.toml +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── cli.py +│ │ ├── config.py +│ │ ├── database.py +│ │ ├── i18n.py +│ │ ├── main.py +│ │ ├── models/ +│ │ ├── routers/ +│ │ │ ├── auth.py +│ │ │ ├── account.py +│ │ │ ├── calendar.py +│ │ │ ├── dashboard.py +│ │ │ ├── exports.py +│ │ │ ├── swaps.py +│ │ │ └── admin/ +│ │ │ ├── teams.py +│ │ │ ├── users.py +│ │ │ ├── schedules.py +│ │ │ ├── assignments.py +│ │ │ └── notifications.py +│ │ ├── security/ +│ │ ├── services/ +│ │ ├── static/ +│ │ └── templates/ +│ ├── migrations/ +│ └── tests/ +│ +└── packages/ + ├── crr-brand/ + │ ├── README.md + │ ├── assets/ + │ │ ├── logo.png + │ │ └── favicon.png + │ └── styles/ + │ ├── tokens.css + │ ├── base.css + │ └── components.css + │ + └── crr-python/ + ├── README.md + ├── pyproject.toml + └── src/crr_common/ + └── __init__.py +``` + +`crr-python` starts intentionally small. Do not extract helpers just because two apps use Python. Extract code only after both apps have the same concrete need. + +## Common backend conventions + +Both backends should use the same structural rules even where code is not shared: + +- `config.py`: Pydantic Settings only. +- `database.py`: engine/session wiring and app-specific migration startup. +- `models/`: persistence only; no HTTP concerns. +- `schemas/`: request/response models where JSON APIs exist. +- `routers/`: HTTP orchestration and dependency checks, kept thin. +- `services/`: deterministic/domain operations. +- `security/`: app-specific authentication primitives. +- `tests/`: mirrors services/routers and tests observable behaviour. +- SQLAlchemy 2-style `select()` and typed `Mapped[...]` models. +- Alembic owns schema creation/evolution; no schema DDL embedded in application startup code. + +### Tournament JSON compatibility + +The Python rewrite must preserve the existing Vue contract exactly: + +- Same route paths and HTTP methods. +- Same status codes. +- Same `{ "error": "..." }` error shape and, where tests depend on them, the same Portuguese error messages. +- Same camelCase JSON field names. Python uses snake_case internally and Pydantic aliases at the API boundary. +- Same `tournament_admin_session` cookie name, path semantics, `HttpOnly`, `SameSite=Strict`, secure behaviour and seven-day lifetime. +- Reproduce the current HMAC-SHA256/base64url session token format so sessions survive the backend cut-over when `SESSION_SECRET` is unchanged. +- Keep the 5-failures/15-minute in-memory login limiter semantics. +- Keep `/api/public/events` as notification-only SSE: initial `retry: 3000`, 25-second heartbeat, and `state-changed` event with `{"type":"state-changed"}`. +- Preserve `APP_BASE_PATH`/`VITE_BASE_PATH` sub-path deployment behaviour. + +## Tournament database migration + +The existing SQLite file must be usable without export/import. + +1. Model the existing tables exactly in SQLAlchemy first: `tournament_settings`, `display_settings`, `league_groups`, `teams`, `players`, `league_matches`, `final_seeds`, `final_match_results`, `league_rounds`, including current checks/indexes/FKs. +2. `0001_baseline.py` creates that exact schema for a new empty database. +3. Startup checks for an existing pre-Alembic database. If tables exist but `alembic_version` does not, validate the expected table/column set and **stamp** `0001_baseline` instead of running the create migration. +4. Empty databases run `alembic upgrade head` normally. +5. Keep SQLite `foreign_keys=ON`, WAL mode and `busy_timeout=5000` via SQLAlchemy connection events. +6. Add a test that copies a database produced by the current Node application, starts the Python application, and verifies all data/state is unchanged. + +This permits rollback to the Node version during the migration window because the baseline schema itself does not change. + +## Tournament backend port mapping by responsibility + +`app.ts` should not become another monolith. Its endpoints split as follows: + +| Router | Existing endpoints | +|---|---| +| `routers/auth.py` | `/api/auth/login`, `/logout`, `/session` | +| `routers/admin/settings.py` | `/api/admin/settings` | +| `routers/admin/display.py` | `/api/admin/display` | +| `routers/admin/groups.py` | `/api/admin/groups...` | +| `routers/admin/teams.py` | `/api/admin/teams...`, player CRUD | +| `routers/admin/calendar.py` | calendar get/generate/preview/delete | +| `routers/admin/matches.py` | league result + round standings endpoints | +| `routers/admin/final_stage.py` | all final-stage config/seeds/results endpoints | +| `routers/public.py` | `/api/public/state`, `/api/public/events` | + +Pydantic schemas should be grouped by the same domain, not placed in one large schema file. + +## Shifts `web.py` split + +Do this **after** the monorepo move, with no route or template behaviour changes: + +| Target router | Current responsibility | +|---|---| +| `routers/auth.py` | `/login`, `/logout`, auth dependencies | +| `routers/account.py` | `/account` | +| `routers/calendar.py` | ICS feed and calendar page | +| `routers/exports.py` | schedule export HTML/PDF | +| `routers/dashboard.py` | `/`, `/health` | +| `routers/swaps.py` | all member/admin swap transitions outside schedule assignment | +| `routers/admin/teams.py` | admin team CRUD | +| `routers/admin/users.py` | admin user CRUD | +| `routers/admin/schedules.py` | schedule configuration, patterns, rotation membership | +| `routers/admin/assignments.py` | assignments, regeneration, assign-day, swap assignment | +| `routers/admin/notifications.py` | notification log page | + +Shared request helpers (`current_user`, `require_user`, `require_admin`, CSRF validation, flash/context helpers) go into a small router dependency/helper module, not into `security/` unless they are genuinely security primitives. + +## Shared branding + +Use one canonical CRR asset set. The Tournament logo is the higher-resolution current source and becomes `packages/crr-brand/assets/logo.png`; the Shifts copy is removed after visual verification. + +### `tokens.css` + +Normalize the already-overlapping palette around the existing CRR red `#ed1c24`: + +- primary / primary-hover / primary-dark +- background / surface +- text / muted text +- border +- danger / warning +- soft brand background +- shared radii, shadows, spacing and font stack + +App-specific tokens such as Tournament match-card dimensions stay in the app stylesheet. + +### `base.css` + +Only cross-app primitives: box sizing, body typography/background, links, form controls, focus state, basic tables. + +### `components.css` + +Shared visual patterns only where both apps actually use them: CRR button variants, cards/panels, alerts/status badges and common table treatment. Do not attempt to share Vue components with Jinja. + +Tournament imports shared CSS before its app stylesheet. Shifts mounts `packages/crr-brand` as a second FastAPI static directory (for example `/brand`) and loads shared CSS before `app.css`. + +## Python packaging + +Root `pyproject.toml` is a uv workspace: + +```toml +[tool.uv.workspace] +members = [ + "apps/tournament/backend", + "apps/shifts/backend", + "packages/crr-python", +] +``` + +Each backend has its own project metadata and dependencies but resolves into one root `uv.lock`. Use `requires-python = ">=3.13,<3.14"` initially so both applications are tested against the same interpreter generation. + +Shifts dependencies currently in `requirements*.txt` move into `apps/shifts/backend/pyproject.toml`; the requirements files are deleted only after the uv lockfile is generated and CI passes. + +Tournament backend dependencies start with FastAPI, Uvicorn, SQLAlchemy, Alembic, Pydantic Settings and the testing stack (`pytest`, `httpx`). + +## Docker/deployment + +Keep two images: + +- `crr-tournament` +- `crr-shifts` + +Both Dockerfiles use repository root as build context so they can consume `packages/`. The root `.dockerignore` must exclude `original/` entirely. + +Tournament uses a Node 22 build stage for Vue and a Python 3.13 runtime stage. The built SPA is copied into the Python image and served by FastAPI, preserving the current single-container deployment. + +Shifts becomes Python 3.13 + uv and copies the shared brand package into the image. Its database remains independent from Tournament. + +The root `compose.yaml` defines two independent services and storage locations. `docker compose up tournament` and `docker compose up shifts` must both work independently; `docker compose up` may run both. + +## Root developer commands + +The root Makefile should expose namespaced commands rather than hiding which app is running: + +```text +make tournament-backend-dev +make tournament-frontend-dev +make tournament-test +make tournament-build +make shifts-dev +make shifts-test +make shifts-build +make test +``` + +`make test` runs both backends plus Tournament frontend tests/typechecking. + +## CI + +Use path-aware jobs: + +- Tournament frontend job: `apps/tournament/frontend/**` or `packages/crr-brand/**`. +- Tournament backend job: `apps/tournament/backend/**` or `packages/crr-python/**`. +- Shifts job: `apps/shifts/**`, `packages/crr-brand/**` or `packages/crr-python/**`. +- Root tooling changes (`pyproject.toml`, `uv.lock`, Compose, CI itself) run both backend jobs as appropriate. + +A branding-only change must test both applications because it can break either rendered UI. + +## Independent releases + +Do not use one monorepo version. Tag releases independently, for example: + +```text +tournament-v1.1.0 +shifts-v1.0.3 +``` + +A release workflow builds only the relevant image from that tag. Shared package changes are released only through whichever app consumes the change; the shared packages do not need separate public versions initially. + +## Migration sequence + +### Phase 0 — freeze behaviour + +- Run and preserve both current test suites in isolated copies of + `originals/tournment-manager-web/` and `originals/crr-shifts/`. Dependency + installation, bytecode, test caches and build outputs must stay outside snapshots. +- Add any migration-only compatibility fixtures/tests outside `original/`; do not modify the source snapshots. +- Capture the existing Tournament API contract before replacing Fastify. +- Save a representative Tournament SQLite database fixture produced by `original/tournament-manager/` for migration tests. +- Save screenshots of key pages from both original apps for branding regression comparison. + +**Exit:** both existing repositories are green and baseline artefacts exist. + +### Phase 1 — create monorepo without architecture changes + +- Keep `original/` unchanged as the migration baseline. +- Create root structure/configuration. +- Copy Tournament frontend/server sources and Shifts sources from `original/` according to the file map below. +- At this point Tournament may temporarily still run a copied Node backend from its app directory; the goal is only to prove the new monorepo paths. +- Update build/test paths so all migrated builds/tests run from `apps/`, never from `original/`. + +**Exit:** both apps run from the monorepo with no functional change. + +### Phase 2 — shared CRR brand + +- Create `crr-brand` tokens/assets/base components. +- Replace both logo copies with the canonical shared asset. +- Extract only visibly common CSS; leave app-specific layout CSS local. +- Verify mobile/desktop and Tournament print cards. + +**Exit:** both apps have the same brand primitives without layout regressions. + +### Phase 3 — Tournament FastAPI replacement + +- Create SQLAlchemy models and Alembic baseline/adoption logic. +- Port deterministic services and their tests first. +- Port state assembly. +- Port auth/session/rate limiting. +- Port routers one domain at a time. +- Port SSE. +- Serve the existing Vue build from FastAPI. +- Remove Node backend dependencies only after the complete Python API compatibility suite passes. + +**Exit:** existing Vue build works unchanged against Python; existing tournament DB opens in place; API/status/error/SSE contract tests pass. + +### Phase 4 — Shifts structural refactor + +- Move service modules into `services/`. +- Split models without schema changes. +- Split `web.py` router by router. +- Move security primitives into `security/`. +- Do not alter routes, form fields, templates or database schema in this phase. + +**Exit:** Shifts tests pass and rendered behaviour is unchanged. + +### Phase 5 — extract proven Python common code + +Compare the final two Python backends. Only now extract byte-for-byte-equivalent or clearly generic infrastructure into `crr-python`, likely starting with SQLite engine pragmas/Alembic setup helpers if doing so removes real duplication without app-specific switches. + +**Exit:** neither app imports the other's code; shared package contains no domain concepts. + +### Phase 6 — cleanup + +- Remove Tournament TypeScript server files/config/dependencies from `apps/tournament/` only; keep the copy under `original/tournament-manager/` untouched. +- Remove migrated Shifts requirements files and duplicate logo from `apps/shifts/` only; keep `original/crr-shifts/` untouched. +- Remove temporary compatibility scaffolding outside `original/`. +- Update documentation and release workflows. +- Retain `original/` as the historical migration baseline unless a separate, explicit post-migration decision removes it. + +## Non-goals + +- No combined database. +- No shared `Team` model. +- No common login/user schema. +- No migration of Shifts to Vue. +- No migration of Tournament away from Vue. +- No microservices or separate API container/frontend container unless a real deployment need appears later. +- No generic repository/service framework merely to make the directory trees look identical. + +## Acceptance criteria + +The migration is complete when: + +1. Each app can be built/run/tested independently from the monorepo. +2. Tournament's existing Vue frontend works without API changes against FastAPI. +3. Existing Tournament and Shifts SQLite data opens without manual conversion or loss. +4. Each app retains a separate migration history/database/auth model/image/version. +5. Both apps use the same canonical CRR logo and design tokens. +6. Shared Python code contains infrastructure only, no Tournament/Shifts domain entities. +7. A change limited to one app does not require deploying the other. +8. The old Tournament Node backend can be removed with no remaining runtime dependency on Fastify, `better-sqlite3` or Zod. +9. `original/` remains unchanged and no production/runtime code imports or executes from it. + +## Exact existing-file migration map + +The following table accounts for every non-directory file in the two original codebases. Every source path is rooted under `original/`; source files remain untouched. Actions such as `copy`, `port`, `rewrite` and `merge` create or update files outside `original/`. + +| Repository | Source | Action | Destination | Notes | +|---|---|---|---|---| +| Tournament Manager | `original/tournament-manager/.dockerignore` | merge | `/.dockerignore` | Merge Docker ignore rules for root build context | +| Tournament Manager | `original/tournament-manager/.env.example` | copy | `apps/tournament/.env.example` | Keep app-specific environment contract | +| Tournament Manager | `original/tournament-manager/.gitignore` | merge | `/.gitignore` | Merge repository ignore rules | +| Tournament Manager | `original/tournament-manager/AGENTS.md` | copy+edit | `apps/tournament/AGENTS.md` | Keep tournament-specific contributor guidance | +| Tournament Manager | `original/tournament-manager/AGENTS.md.bak` | leave-in-original | `—` | Redundant backup after AGENTS.md is migrated; no migrated copy is needed | +| Tournament Manager | `original/tournament-manager/Dockerfile` | rewrite | `apps/tournament/Dockerfile` | Replace Node backend runtime with Node frontend build + Python 3.13 FastAPI runtime | +| Tournament Manager | `original/tournament-manager/README.md` | copy+edit | `apps/tournament/README.md` | Update monorepo/dev/deploy commands | +| Tournament Manager | `original/tournament-manager/compose.yaml` | merge | `/compose.yaml` | Tournament service becomes one root Compose service | +| Tournament Manager | `original/tournament-manager/index.html` | copy | `apps/tournament/frontend/index.html` | Vite frontend root | +| Tournament Manager | `original/tournament-manager/logo.png` | copy+canonicalize | `packages/crr-brand/assets/logo.png` | Use as canonical high-resolution CRR logo | +| Tournament Manager | `original/tournament-manager/package-lock.json` | copy | `apps/tournament/frontend/package-lock.json` | Regenerate after package.json cleanup | +| Tournament Manager | `original/tournament-manager/package.json` | copy+edit | `apps/tournament/frontend/package.json` | Remove backend Node dependencies/scripts | +| Tournament Manager | `original/tournament-manager/public/favicon.png` | copy+canonicalize | `packages/crr-brand/assets/favicon.png` | Shared favicon | +| Tournament Manager | `original/tournament-manager/src/client/App.vue` | copy+edit | `apps/tournament/frontend/src/App.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/AdminShell.vue` | copy+edit | `apps/tournament/frontend/src/components/AdminShell.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/AppLogo.vue` | copy+edit | `apps/tournament/frontend/src/components/AppLogo.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/AppModal.vue` | copy+edit | `apps/tournament/frontend/src/components/AppModal.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/ClassificationTable.vue` | copy+edit | `apps/tournament/frontend/src/components/ClassificationTable.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/DisplayClassificationPanel.vue` | copy+edit | `apps/tournament/frontend/src/components/DisplayClassificationPanel.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/DisplayFit.vue` | copy+edit | `apps/tournament/frontend/src/components/DisplayFit.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/DisplayLatestResultsPanel.vue` | copy+edit | `apps/tournament/frontend/src/components/DisplayLatestResultsPanel.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/FinalBracket.vue` | copy+edit | `apps/tournament/frontend/src/components/FinalBracket.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/MatchResultCard.vue` | copy+edit | `apps/tournament/frontend/src/components/MatchResultCard.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/match-cards/FourScores.vue` | copy+edit | `apps/tournament/frontend/src/components/match-cards/FourScores.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/match-cards/SingleScore.vue` | copy+edit | `apps/tournament/frontend/src/components/match-cards/SingleScore.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/match-cards/SixScores.vue` | copy+edit | `apps/tournament/frontend/src/components/match-cards/SixScores.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/components/matchCards.ts` | copy+edit | `apps/tournament/frontend/src/components/matchCards.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/displayPanels.ts` | copy+edit | `apps/tournament/frontend/src/displayPanels.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/lib/api.ts` | copy+edit | `apps/tournament/frontend/src/lib/api.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/lib/format.test.ts` | copy+edit | `apps/tournament/frontend/src/lib/format.test.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/lib/format.ts` | copy+edit | `apps/tournament/frontend/src/lib/format.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/lib/score.test.ts` | copy+edit | `apps/tournament/frontend/src/lib/score.test.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/lib/score.ts` | copy+edit | `apps/tournament/frontend/src/lib/score.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/lib/usePublicTournamentState.ts` | copy+edit | `apps/tournament/frontend/src/lib/usePublicTournamentState.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/main.ts` | copy+edit | `apps/tournament/frontend/src/main.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/router.ts` | copy+edit | `apps/tournament/frontend/src/router.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/styles.css` | copy+edit | `apps/tournament/frontend/src/styles.css` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/types.ts` | copy+edit | `apps/tournament/frontend/src/types.ts` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/CalendarView.vue` | copy+edit | `apps/tournament/frontend/src/views/CalendarView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/DisplaySettingsView.vue` | copy+edit | `apps/tournament/frontend/src/views/DisplaySettingsView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/DisplayView.vue` | copy+edit | `apps/tournament/frontend/src/views/DisplayView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/FinalStageView.vue` | copy+edit | `apps/tournament/frontend/src/views/FinalStageView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/LoginView.vue` | copy+edit | `apps/tournament/frontend/src/views/LoginView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/MatchCardsView.vue` | copy+edit | `apps/tournament/frontend/src/views/MatchCardsView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/PublicResultsView.vue` | copy+edit | `apps/tournament/frontend/src/views/PublicResultsView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/ResultsView.vue` | copy+edit | `apps/tournament/frontend/src/views/ResultsView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/client/views/TeamsView.vue` | copy+edit | `apps/tournament/frontend/src/views/TeamsView.vue` | Adjust imports/base paths/brand imports as needed | +| Tournament Manager | `original/tournament-manager/src/server/app.integration.test.ts` | port | `apps/tournament/backend/tests/test_api_compatibility.py` | Port all observable route/status/error contract tests | +| Tournament Manager | `original/tournament-manager/src/server/app.ts` | port+split | `apps/tournament/backend/app/{routers,schemas,services}/` | Split Fastify monolith into FastAPI routers and Pydantic schemas; preserve API contract | +| Tournament Manager | `original/tournament-manager/src/server/auth.ts` | port+split | `apps/tournament/backend/app/security/{session.py,rate_limit.py}` | Preserve cookie name/token format/rate-limit semantics | +| Tournament Manager | `original/tournament-manager/src/server/db/database.test.ts` | port | `apps/tournament/backend/tests/test_database.py` | Port schema/default/constraint tests | +| Tournament Manager | `original/tournament-manager/src/server/db/database.ts` | port+split | `apps/tournament/backend/app/database.py + app/models/ + migrations/versions/0001_baseline.py` | SQLAlchemy model + Alembic baseline with legacy DB adoption | +| Tournament Manager | `original/tournament-manager/src/server/errors.ts` | port | `apps/tournament/backend/app/errors.py` | Preserve JSON error shape | +| Tournament Manager | `original/tournament-manager/src/server/events.ts` | port | `apps/tournament/backend/app/events.py` | SSE state-change broker | +| Tournament Manager | `original/tournament-manager/src/server/main.ts` | port | `apps/tournament/backend/app/main.py` | FastAPI application entry point | +| Tournament Manager | `original/tournament-manager/src/server/services/bracket.test.ts` | port | `apps/tournament/backend/tests/services/test_bracket.py` | | +| Tournament Manager | `original/tournament-manager/src/server/services/bracket.ts` | port | `apps/tournament/backend/app/services/bracket.py` | | +| Tournament Manager | `original/tournament-manager/src/server/services/calendar.test.ts` | port | `apps/tournament/backend/tests/services/test_calendar.py` | | +| Tournament Manager | `original/tournament-manager/src/server/services/calendar.ts` | port | `apps/tournament/backend/app/services/calendar.py` | | +| Tournament Manager | `original/tournament-manager/src/server/services/classification.test.ts` | port | `apps/tournament/backend/tests/services/test_classification.py` | | +| Tournament Manager | `original/tournament-manager/src/server/services/classification.ts` | port | `apps/tournament/backend/app/services/classification.py` | | +| Tournament Manager | `original/tournament-manager/src/server/services/state.ts` | port | `apps/tournament/backend/app/services/state.py` | Translate SQL-backed state assembly to SQLAlchemy while preserving response shape | +| Tournament Manager | `original/tournament-manager/src/shared/types/tournament.ts` | copy+edit | `apps/tournament/frontend/src/types/tournament.ts` | Frontend API types remain TypeScript; FastAPI Pydantic schemas mirror this contract | +| Tournament Manager | `original/tournament-manager/src/vite-env.d.ts` | copy | `apps/tournament/frontend/src/vite-env.d.ts` | | +| Tournament Manager | `original/tournament-manager/tsconfig.client.json` | copy+rename | `apps/tournament/frontend/tsconfig.json` | Update include paths | +| Tournament Manager | `original/tournament-manager/tsconfig.server.json` | leave-in-original | `—` | Node/TypeScript backend removed; no migrated copy is needed | +| Tournament Manager | `original/tournament-manager/vite.config.ts` | copy+edit | `apps/tournament/frontend/vite.config.ts` | Proxy to FastAPI and read shared brand assets | +| CRR Shifts | `original/crr-shifts/.dockerignore` | merge | `/.dockerignore` | Merge Docker ignore rules for root build context | +| CRR Shifts | `original/crr-shifts/.env.example` | copy+edit | `apps/shifts/.env.example` | Keep app-specific environment contract; Python 3.13 runtime | +| CRR Shifts | `original/crr-shifts/.gitignore` | merge | `/.gitignore` | Merge repository ignore rules | +| CRR Shifts | `original/crr-shifts/AGENTS.md` | copy+edit | `apps/shifts/AGENTS.md` | Keep shifts-specific contributor guidance | +| CRR Shifts | `original/crr-shifts/Dockerfile` | rewrite | `apps/shifts/Dockerfile` | Python 3.13 + uv; root build context | +| CRR Shifts | `original/crr-shifts/Makefile` | merge | `/Makefile` | Convert to root namespaced targets such as shifts-dev/shifts-test | +| CRR Shifts | `original/crr-shifts/README.md` | copy+edit | `apps/shifts/README.md` | Update monorepo/dev/deploy commands | +| CRR Shifts | `original/crr-shifts/alembic.ini` | copy+edit | `apps/shifts/backend/alembic.ini` | Update script paths for backend subdirectory | +| CRR Shifts | `original/crr-shifts/app/__init__.py` | copy | `apps/shifts/backend/app/__init__.py` | | +| CRR Shifts | `original/crr-shifts/app/bootstrap.py` | copy+refactor | `apps/shifts/backend/app/services/bootstrap.py` | No behavior change | +| CRR Shifts | `original/crr-shifts/app/calendar.py` | copy+refactor | `apps/shifts/backend/app/services/calendar_feed.py` | Avoid name collision with router | +| CRR Shifts | `original/crr-shifts/app/cli.py` | copy+edit | `apps/shifts/backend/app/cli.py` | Update imports/paths | +| CRR Shifts | `original/crr-shifts/app/config.py` | copy+edit | `apps/shifts/backend/app/config.py` | Keep settings contract | +| CRR Shifts | `original/crr-shifts/app/database.py` | copy+edit | `apps/shifts/backend/app/database.py` | Update migration paths; later share proven generic helpers | +| CRR Shifts | `original/crr-shifts/app/i18n.py` | copy | `apps/shifts/backend/app/i18n.py` | | +| CRR Shifts | `original/crr-shifts/app/main.py` | copy+edit | `apps/shifts/backend/app/main.py` | Include split routers and shared brand StaticFiles mount | +| CRR Shifts | `original/crr-shifts/app/models.py` | split | `apps/shifts/backend/app/models/{user.py,team.py,schedule.py,assignment.py,swap.py,notification.py,__init__.py}` | Pure structural split; retain SQLAlchemy schema | +| CRR Shifts | `original/crr-shifts/app/notifications.py` | copy+refactor | `apps/shifts/backend/app/services/notifications.py` | | +| CRR Shifts | `original/crr-shifts/app/pdf.py` | copy+refactor | `apps/shifts/backend/app/services/pdf.py` | | +| CRR Shifts | `original/crr-shifts/app/scheduling.py` | copy+refactor | `apps/shifts/backend/app/services/scheduling.py` | | +| CRR Shifts | `original/crr-shifts/app/security.py` | split | `apps/shifts/backend/app/security/{passwords.py,csrf.py,__init__.py}` | Keep auth model independent from Tournament | +| CRR Shifts | `original/crr-shifts/app/static/app.css` | copy+edit | `apps/shifts/backend/app/static/app.css` | Remove extracted shared brand tokens/components | +| CRR Shifts | `original/crr-shifts/app/static/logo-crr.png` | reference-shared-copy | `packages/crr-brand/assets/logo.png` | Use shared canonical logo; do not keep duplicate | +| CRR Shifts | `original/crr-shifts/app/templates/_schedule_export_form.html` | copy+edit | `apps/shifts/backend/app/templates/_schedule_export_form.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/account.html` | copy+edit | `apps/shifts/backend/app/templates/account.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/admin_assign.html` | copy+edit | `apps/shifts/backend/app/templates/admin_assign.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/admin_notifications.html` | copy+edit | `apps/shifts/backend/app/templates/admin_notifications.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/admin_schedule.html` | copy+edit | `apps/shifts/backend/app/templates/admin_schedule.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/admin_schedules.html` | copy+edit | `apps/shifts/backend/app/templates/admin_schedules.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/admin_team_delete.html` | copy+edit | `apps/shifts/backend/app/templates/admin_team_delete.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/admin_teams.html` | copy+edit | `apps/shifts/backend/app/templates/admin_teams.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/admin_users.html` | copy+edit | `apps/shifts/backend/app/templates/admin_users.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/base.html` | copy+edit | `apps/shifts/backend/app/templates/base.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/calendar.html` | copy+edit | `apps/shifts/backend/app/templates/calendar.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/dashboard.html` | copy+edit | `apps/shifts/backend/app/templates/dashboard.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/export_schedule.html` | copy+edit | `apps/shifts/backend/app/templates/export_schedule.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/login.html` | copy+edit | `apps/shifts/backend/app/templates/login.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/swap_new.html` | copy+edit | `apps/shifts/backend/app/templates/swap_new.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/templates/swaps.html` | copy+edit | `apps/shifts/backend/app/templates/swaps.html` | Only update shared-brand/static references as needed | +| CRR Shifts | `original/crr-shifts/app/web.py` | split | `apps/shifts/backend/app/routers/` | Split by auth/account/calendar/swaps/admin while preserving routes/templates | +| CRR Shifts | `original/crr-shifts/compose.yaml` | merge | `/compose.yaml` | Shifts service becomes one root Compose service | +| CRR Shifts | `original/crr-shifts/docs/ARCHITECTURE.md` | copy+edit | `apps/shifts/docs/ARCHITECTURE.md` | Update paths/architecture notes | +| CRR Shifts | `original/crr-shifts/docs/DOMAIN.md` | copy+edit | `apps/shifts/docs/DOMAIN.md` | Update paths/architecture notes | +| CRR Shifts | `original/crr-shifts/migrations/README` | copy+edit | `apps/shifts/backend/migrations/README` | | +| CRR Shifts | `original/crr-shifts/migrations/env.py` | copy+edit | `apps/shifts/backend/migrations/env.py` | Update imports/path | +| CRR Shifts | `original/crr-shifts/migrations/script.py.mako` | copy | `apps/shifts/backend/migrations/script.py.mako` | | +| CRR Shifts | `original/crr-shifts/migrations/versions/0001_initial.py` | copy | `apps/shifts/backend/migrations/versions/0001_initial.py` | Preserve migration history unchanged | +| CRR Shifts | `original/crr-shifts/pyproject.toml` | rewrite | `apps/shifts/backend/pyproject.toml` | Full uv project metadata + runtime/dev dependencies | +| CRR Shifts | `original/crr-shifts/pyrightconfig.json` | merge | `/pyrightconfig.json` | One root Python type-check config for both backends | +| CRR Shifts | `original/crr-shifts/requirements-dev.txt` | leave-in-original | `—` | Dependencies move to pyproject.toml/uv.lock; no migrated copy is needed | +| CRR Shifts | `original/crr-shifts/requirements.txt` | leave-in-original | `—` | Dependencies move to pyproject.toml/uv.lock; no migrated copy is needed | +| CRR Shifts | `original/crr-shifts/tests/.gitkeep` | copy+edit | `apps/shifts/backend/tests/.gitkeep` | Update imports/paths only | +| CRR Shifts | `original/crr-shifts/tests/conftest.py` | copy+edit | `apps/shifts/backend/tests/conftest.py` | Update imports/paths only | +| CRR Shifts | `original/crr-shifts/tests/test_export_schedule.py` | copy+edit | `apps/shifts/backend/tests/test_export_schedule.py` | Update imports/paths only | diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0925d52 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +UV ?= uv +PYTHON ?= python3 +.DEFAULT_GOAL := test + +.PHONY: baseline-test baseline-fixtures baseline-screenshots baseline-preserve migration-visual-check branding-visual-check images-smoke-test tournament-install tournament-backend-dev tournament-frontend-dev tournament-test tournament-build shifts-dev shifts-test shifts-build test + +baseline-test: + $(PYTHON) tools/migration_baseline.py all + +baseline-fixtures: + node --import ./.migration/tournament/node_modules/tsx/dist/loader.mjs migration/capture_tournament.mjs + +baseline-screenshots: + $(UV) pip install --python .migration/shifts/.venv/bin/python playwright + .migration/shifts/.venv/bin/python -m playwright install chromium + .migration/shifts/.venv/bin/python migration/capture_screenshots.py + +baseline-preserve: + $(PYTHON) tools/preserve_baseline.py + +migration-visual-check: + .migration/shifts/.venv/bin/python migration/capture_screenshots.py --migrated --reference migration/phase2/screenshots + +branding-visual-check: + .migration/shifts/.venv/bin/python migration/capture_screenshots.py --branding + .migration/shifts/.venv/bin/python migration/capture_screenshots.py --migrated --branding + +images-smoke-test: + $(PYTHON) tools/check_images.py + +tournament-install: + npm --prefix apps/tournament ci + +tournament-backend-dev: + npm --prefix apps/tournament run dev:server + +tournament-frontend-dev: + npm --prefix apps/tournament run dev:client + +tournament-test: + npm --prefix apps/tournament test + npm --prefix apps/tournament run typecheck + +tournament-build: + npm --prefix apps/tournament run build + +shifts-dev: + mkdir -p apps/shifts/backend/data + $(UV) run --directory apps/shifts/backend --package crr-shifts --locked uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 + +shifts-test: + $(UV) run --directory apps/shifts/backend --package crr-shifts --locked pytest + +shifts-build: + docker build -f apps/shifts/Dockerfile -t crr-shifts . + +test: tournament-test shifts-test diff --git a/README.md b/README.md index e69de29..165fadd 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,48 @@ +# CRR tools + +Two independently runnable applications migrating into one monorepo: + +- [Tournament](apps/tournament/README.md): Vue 3 and the temporary legacy Node backend. +- [Shifts](apps/shifts/README.md): FastAPI, Jinja, SQLAlchemy and Alembic on Python 3.13. +- [CRR brand](packages/crr-brand/README.md): shared logo, favicon and CSS primitives. + +See [the migration plan](MONOREPO_MIGRATION_PLAN.md) and +[verified progress and baseline evidence](migration/README.md). + +## Development + +Requires Node 22+, Python 3.13, `uv`, and `make`. + +```sh +make tournament-install +make tournament-test tournament-build +make shifts-test +make test +``` + +`uv` resolves Shifts dependencies using the single root `uv.lock` and creates the +root `.venv`. Tournament joins that workspace when the FastAPI backend is ported. +Run `make tournament-backend-dev` and `make tournament-frontend-dev` in separate +terminals, or `make shifts-dev` for Shifts. App-specific environment setup is +documented in each application's README. + +## Deployment + +Docker Compose 2.24+ supports the optional per-app environment files: + +```sh +docker compose up --build tournament +docker compose up --build shifts +``` + +Each service has its own Dockerfile, image, database volume, and credentials. +Set the application's secrets in its environment file before deployment. +Release tags will be app-specific: `tournament-v...` and `shifts-v...`. + +## Baselines + +`originals/` is read-only, including its nested Git repositories. Baseline checks +run in disposable `.migration/` copies using `make baseline-test`; production +code and CI run exclusively from `apps/`. See `migration/README.md` for fixture +and screenshot commands. Root Docker contexts exclude both the snapshots and +migration artefacts. diff --git a/apps/shifts/.env.example b/apps/shifts/.env.example new file mode 100644 index 0000000..fb15968 --- /dev/null +++ b/apps/shifts/.env.example @@ -0,0 +1,46 @@ +APP_NAME=Contínuos CRR +SECRET_KEY=replace-with-a-long-random-value +DATABASE_URL=sqlite:///./data/bar_rota.db +BASE_URL=http://localhost:8000 + +# Behind a reverse proxy: the public address of the app, used in notification e-mails. +# ROOT_PATH is the prefix the proxy serves us under, e.g. /crr for https://host/crr/. +# Leave it empty when the app is at the root of the domain. +ROOT_PATH= +PORT=8000 + +# Monorepo Compose uses an independent shifts-data volume mounted at /data. +# DATABASE_URL above is the local-development path; Compose overrides it. + +# Initial admin is created only if no teams exist (normally the first start). +INITIAL_ADMIN_NAME=Administrador +INITIAL_ADMIN_USERNAME=admin +INITIAL_ADMIN_EMAIL=admin@example.com +INITIAL_ADMIN_PASSWORD=change-me-now + +# SMTP is optional. If SMTP_HOST is blank, notifications are recorded in the DB +# but no email is sent. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM=bar-rota@example.com +SMTP_STARTTLS=true + +# Secret token of the shared whole-rota calendar feed (/calendar/.ics). +# Leave empty and it is generated the first time an admin opens the schedules page. +CALENDAR_TOKEN= + +# Timezone of the calendar feed. Times are published in this zone, so shifts show +# at the bar's own wall clock (Europe/Lisbon follows the European summer time). +TIMEZONE=Europe/Lisbon + +# Where the startup backups of the SQLite file go. Empty means "backups" next to +# the database file. One copy per start, kept forever; names are never reused. +BACKUP_DIR= + +# Reminder command looks this many days ahead. +REMINDER_DAYS_AHEAD=1 + +# Set true when the app is served only over HTTPS (always true behind a TLS proxy). +SESSION_HTTPS_ONLY=false diff --git a/apps/shifts/AGENTS.md b/apps/shifts/AGENTS.md new file mode 100644 index 0000000..3ea9169 --- /dev/null +++ b/apps/shifts/AGENTS.md @@ -0,0 +1,9 @@ +# Shifts + +- Preserve Portuguese messages, Jinja templates, form fields, routes, session auth, + CSRF, `ROOT_PATH` deployment, existing SQLite data, and Alembic history. +- Backend commands run from `backend/`; production code must not import originals. +- Keep the Phase 1 service/model/router layout until the planned Phase 4 refactor. +- Preserve manual/swapped assignments, atomic swaps, and commit-before-email rules. +- Run `make shifts-test` from the repository root after relevant changes. +- Version and release this application independently using `shifts-v...` tags. diff --git a/apps/shifts/Dockerfile b/apps/shifts/Dockerfile new file mode 100644 index 0000000..af48f0d --- /dev/null +++ b/apps/shifts/Dockerfile @@ -0,0 +1,14 @@ +FROM ghcr.io/astral-sh/uv:0.11.31 AS uv +FROM python:3.13-slim +COPY --from=uv /uv /uvx /bin/ +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 UV_PROJECT_ENVIRONMENT=/opt/venv PATH="/opt/venv/bin:$PATH" +WORKDIR /workspace +COPY pyproject.toml uv.lock ./ +COPY apps/shifts/backend/pyproject.toml apps/shifts/backend/pyproject.toml +RUN uv sync --frozen --package crr-shifts --no-dev --no-editable +COPY apps/shifts/backend/ apps/shifts/backend/ +COPY packages/crr-brand/ packages/crr-brand/ +WORKDIR /workspace/apps/shifts/backend +RUN mkdir -p data +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/apps/shifts/README.md b/apps/shifts/README.md new file mode 100644 index 0000000..200bc13 --- /dev/null +++ b/apps/shifts/README.md @@ -0,0 +1,47 @@ +# Contínuos CRR — Shifts + +The server-rendered FastAPI application lives in `backend/`. Phase 1 preserves +its service/model/router layout, Jinja templates and `0001_initial` Alembic +history. Python dependencies are managed with `uv` and the root `uv.lock`. + +The canonical logo, favicon and common CSS live in `packages/crr-brand`, mounted +at `/brand` with root-path-aware Jinja links. PDF exports read that same logo. +App-specific layouts and forms remain in `backend/app/static/app.css`; Docker +copies the brand package into the image alongside the backend. + +From the repository root: + +```sh +make shifts-test +make shifts-dev +``` + +Copy `apps/shifts/.env.example` to `apps/shifts/backend/.env` and configure +`SECRET_KEY`, `INITIAL_ADMIN_PASSWORD`, and the remaining deployment settings. +The local server listens on port 8000. Migrations, backups and initial data run +on startup; SQLite data defaults to `backend/data/bar_rota.db`. `make shifts-dev` +creates its data directory. Create that directory before using CLI initialization +directly if using the default database URL. + +```sh +docker compose up --build shifts +make shifts-build +``` + +Compose loads `apps/shifts/backend/.env`. The `shifts-data` volume contains +`/data/bar_rota.db`; this database is independent from Tournament. The image is +`crr-shifts`, targeting Python 3.13. Initial Python project version is `0.1.0`; +future release tags use `shifts-v...`. + +For administrative CLI commands: + +```sh +uv run --directory apps/shifts/backend --package crr-shifts --locked python -m app.cli init-db +uv run --directory apps/shifts/backend --package crr-shifts --locked python -m app.cli seed-demo +``` + +The requirements files are retained during migration until CI verifies the +lockfile workflow. Tests use isolated synthetic SQLite data and disabled SMTP. +See `docs/DOMAIN.md` for domain rules and `docs/ARCHITECTURE.md` for the preserved +application lifecycle (legacy dependency/deployment commands there are being +superseded by this README). diff --git a/apps/shifts/backend/alembic.ini b/apps/shifts/backend/alembic.ini new file mode 100644 index 0000000..e7c98f8 --- /dev/null +++ b/apps/shifts/backend/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = %(here)s/migrations +prepend_sys_path = . + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/shifts/backend/app/__init__.py b/apps/shifts/backend/app/__init__.py new file mode 100644 index 0000000..1462d13 --- /dev/null +++ b/apps/shifts/backend/app/__init__.py @@ -0,0 +1 @@ +"""Bar Rota application package.""" diff --git a/apps/shifts/backend/app/bootstrap.py b/apps/shifts/backend/app/bootstrap.py new file mode 100644 index 0000000..fc91d39 --- /dev/null +++ b/apps/shifts/backend/app/bootstrap.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from datetime import date, time + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .config import settings +from .models import Schedule, Team, User +from .security import hash_password + + +def ensure_initial_data(db: Session) -> None: + team_count = db.scalar(select(Team.id).limit(1)) + if team_count is None: + db.add( + Team( + name=settings.initial_admin_name, + email=settings.initial_admin_email or None, + phone=None, + user=User( + name=settings.initial_admin_name, + username=settings.initial_admin_username, + password_hash=hash_password(settings.initial_admin_password), + is_admin=True, + is_active=True, + ), + is_active=True, + ) + ) + db.commit() + + defaults = [ + { + "name": "Noite", + "slug": "night", + "schedule_type": "fixed", + "weekdays": "0,1,2,3,4,5,6", + "start_time": time(20, 30), + "end_time": time(0, 0), + "rotation_anchor_date": None, + }, + { + "name": "Almoço - Sábados", + "slug": "saturday-lunch", + "schedule_type": "rotation", + "weekdays": "5", + "start_time": time(13, 0), + "end_time": time(16, 0), + "rotation_anchor_date": date(2024, 1, 6), + }, + { + "name": "Almoço - Domingo", + "slug": "sunday-lunch", + "schedule_type": "rotation", + "weekdays": "6", + "start_time": time(13, 0), + "end_time": time(16, 0), + "rotation_anchor_date": date(2024, 1, 7), + }, + ] + + for item in defaults: + exists = db.scalar(select(Schedule.id).where(Schedule.slug == item["slug"])) + if exists is None: + db.add(Schedule(**item, requires_manager_approval=True, is_active=True)) + db.commit() diff --git a/apps/shifts/backend/app/branding.py b/apps/shifts/backend/app/branding.py new file mode 100644 index 0000000..f3f51a2 --- /dev/null +++ b/apps/shifts/backend/app/branding.py @@ -0,0 +1,6 @@ +"""Filesystem location of the canonical brand assets in local and Docker layouts.""" + +from pathlib import Path + +BRAND_DIR = Path(__file__).resolve().parents[4] / "packages" / "crr-brand" +LOGO_PATH = BRAND_DIR / "assets" / "logo.png" diff --git a/apps/shifts/backend/app/calendar.py b/apps/shifts/backend/app/calendar.py new file mode 100644 index 0000000..8bffdb5 --- /dev/null +++ b/apps/shifts/backend/app/calendar.py @@ -0,0 +1,172 @@ +"""iCalendar feed of the rota, so members can subscribe from any calendar app. + +Events carry the bar's timezone (`DTSTART;TZID=Europe/Lisbon:20261017T203000`) +plus a VTIMEZONE block with the European summer-time rules, so a shift shows at +20:30 in the reader's calendar and keeps the right offset across the year. +""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from .config import settings +from .i18n import day_long, day_numeric_long, window_label +from .models import Assignment, Schedule, Team + +# How much of the rota the feed publishes: recent history and the next months. +MONTHS_BACK = 1 +MONTHS_FORWARD = 6 + +DEFAULT_TIMEZONE = "Europe/Lisbon" + + +def feed_timezone() -> str: + """The configured zone, falling back to Lisbon when it is unknown.""" + try: + ZoneInfo(settings.timezone) + except (ZoneInfoNotFoundError, ValueError): + return DEFAULT_TIMEZONE + return settings.timezone + + +def _timezone_block(tzid: str) -> list[str]: + """European rules: the last Sunday in March moves to WEST, in October back to WET.""" + return [ + "BEGIN:VTIMEZONE", + f"TZID:{tzid}", + f"X-LIC-LOCATION:{tzid}", + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:+0000", + "TZOFFSETTO:+0100", + "TZNAME:WEST", + "DTSTART:19700329T020000", + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:+0100", + "TZOFFSETTO:+0000", + "TZNAME:WET", + "DTSTART:19701025T030000", + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", + "END:STANDARD", + "END:VTIMEZONE", + ] + + +def _stamp(moment: datetime) -> str: + return moment.strftime("%Y%m%dT%H%M%S") + + +def _fold(line: str) -> list[str]: + """RFC 5545 folds long lines at 75 octets, never cutting a character in half.""" + if len(line.encode("utf-8")) <= 75: + return [line] + parts: list[str] = [] + current: list[str] = [] + used = 0 + limit = 75 + for character in line: + size = len(character.encode("utf-8")) + if used + size > limit: + parts.append("".join(current)) + current = [] + used = 0 + limit = 74 # continuation lines start with a space + current.append(character) + used += size + if current: + parts.append("".join(current)) + return [parts[0]] + [" " + part for part in parts[1:]] + + +def _escape(value: str) -> str: + return ( + value.replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\n", "\\n") + ) + + +def _event(assignment: Assignment, team: Team | None, tzid: str) -> list[str]: + schedule = assignment.schedule + start = datetime.combine(assignment.date, schedule.start_time) + end = datetime.combine(assignment.date, schedule.end_time) + if end <= start: # a shift that runs past midnight + end += timedelta(days=1) + # The team's name is part of the event title, so a list of shifts reads at a glance. + summary = ( + f"{team.name} · {schedule.name}" if team is not None else f"{schedule.name}" + ) + description = [ + day_long(assignment.date), + f"{schedule.start_time:%H:%M}–{schedule.end_time:%H:%M}", + ] + if team is not None: + description.append(f"Equipa: {team.name}") + description.append(settings.app_name) + return [ + "BEGIN:VEVENT", + f"UID:assignment-{assignment.id}@{_host()}", + f"DTSTAMP:{_stamp(datetime.utcnow())}", + f"DTSTART;TZID={tzid}:{start:%Y%m%dT%H%M%S}", + f"DTEND;TZID={tzid}:{end:%Y%m%dT%H%M%S}", + f"SUMMARY:{_escape(summary)}", + f"DESCRIPTION:{_escape(chr(10).join(description))}", + f"LOCATION:{_escape(settings.app_name)}", + "TRANSP:TRANSPARENT", + "END:VEVENT", + ] + + +def _host() -> str: + return (settings.base_url.split("//")[-1] or "localhost").split("/")[0] + + +def build_feed(db: Session, team: Team | None, name: str) -> str: + """The whole rota, or only the shifts of one team.""" + today = date.today() + first = (today.replace(day=1) - timedelta(days=31 * MONTHS_BACK)).replace(day=1) + last = (today.replace(day=1) + timedelta(days=32 * MONTHS_FORWARD)).replace( + day=1 + ) - timedelta(days=1) + + schedules = db.scalars(select(Schedule).where(Schedule.is_active.is_(True))).all() + assignments = db.scalars( + select(Assignment) + .where( + Assignment.date >= first, + Assignment.date <= last, + Assignment.schedule_id.in_([s.id for s in schedules]), + ) + .options(selectinload(Assignment.schedule), selectinload(Assignment.team)) + .order_by(Assignment.date) + ).all() + if team is not None: + assignments = [a for a in assignments if a.team_id == team.id] + + tzid = feed_timezone() + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + f"PRODID:-//{_escape(settings.app_name)}//Rota//PT", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + f"X-WR-CALNAME:{_escape(name)}", + f"X-WR-CALDESC:{_escape(f'{settings.app_name} — {day_numeric_long(first)} a {day_numeric_long(last)}')}", + "REFRESH-INTERVAL;VALUE=DURATION:PT12H", + "X-PUBLISHED-TTL:PT12H", + f"X-WR-TIMEZONE:{tzid}", + ] + lines.extend(_timezone_block(tzid)) + for assignment in assignments: + lines.extend(_event(assignment, assignment.team, tzid)) + lines.append("END:VCALENDAR") + folded: list[str] = [] + for line in lines: + folded.extend(_fold(line)) + return "\r\n".join(folded) + "\r\n" diff --git a/apps/shifts/backend/app/cli.py b/apps/shifts/backend/app/cli.py new file mode 100644 index 0000000..a3b890d --- /dev/null +++ b/apps/shifts/backend/app/cli.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import code +import sys +from datetime import date, timedelta + +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from .bootstrap import ensure_initial_data +from .config import settings +from .database import ( + SessionLocal, + current_revision, + has_tables, + run_migrations, + stamp, +) +from . import models +from .models import ( + Assignment, + User, + MonthlyPattern, + NotificationLog, + RotationMember, + Schedule, + Team, +) +from .i18n import day_long, day_numeric +from .notifications import send_email_notification +from .scheduling import ensure_month_assignments +from .security import hash_password + +BASELINE_REVISION = "0001_initial" + + +def migrate() -> None: + if has_tables() and current_revision() is None: + stamp(BASELINE_REVISION) + print(f"Existing database stamped at {BASELINE_REVISION}.") + run_migrations() + print(f"Database at revision {current_revision()}.") + + +def init_db() -> None: + migrate() + with SessionLocal() as db: + ensure_initial_data(db) + print("Database initialized.") + + +def seed_demo() -> None: + init_db() + # Carla and Diogo are two teams covered by the same user, as a real team would be. + demo_teams = [ + ("Alice", "alice", "alice@example.com"), + ("Bruno", "bruno", "bruno@example.com"), + ("Carla", "sabado", None), + ("Diogo", "sabado", "diogo@example.com"), + ] + with SessionLocal() as db: + for name, username, email in demo_teams: + if db.scalar(select(Team.id).where(Team.name == name)): + continue # this team is already there, whoever signs in for it + user = db.scalar(select(User).where(User.username == username)) + if user is None: + user = User( + name=username.capitalize(), + username=username, + password_hash=hash_password("demo-password"), + is_admin=False, + is_active=True, + ) + db.add(user) + db.flush() + db.add( + Team( + name=name, + user=user, + email=email, + phone=None, + is_active=True, + notify_email=True, + ) + ) + db.commit() + teams = db.scalars( + select(Team) + .join(User) + .where(User.username.in_([x[1] for x in demo_teams])) + .order_by(Team.name) + ).all() + schedules = db.scalars( + select(Schedule).where(Schedule.slug.in_(["saturday-lunch", "sunday-lunch"])).order_by(Schedule.id) + ).all() + for schedule in schedules: + if not db.scalar(select(RotationMember.id).where(RotationMember.schedule_id == schedule.id)): + for pos, team in enumerate(teams, start=1): + db.add(RotationMember(schedule_id=schedule.id, team_id=team.id, position=pos)) + + night = db.scalar(select(Schedule).where(Schedule.slug == "night")) + if night is not None and not db.scalar( + select(MonthlyPattern.id).where(MonthlyPattern.schedule_id == night.id) + ): + for day in range(1, 32): + db.add(MonthlyPattern( + schedule_id=night.id, + day_of_month=day, + team_id=teams[(day - 1) % len(teams)].id, + )) + db.commit() + + today = date.today() + next_month = (today.replace(day=28) + timedelta(days=4)).replace(day=1) + for month in (today, next_month): + ensure_month_assignments(db, month.year, month.month) + print("Demo data seeded for this month and next. Existing assignments and configuration preserved.") + print("Demo users: alice, bruno, sabado (Carla e Diogo entram com o mesmo utilizador). " + "Password: demo-password (new accounts only).") + + +def db_shell() -> None: + """An interactive SQLAlchemy session against the configured application database.""" + init_db() + with SessionLocal() as db: + namespace = {"db": db, "select": select, "models": models} + namespace.update({name: getattr(models, name) for name in ( + "Team", "User", "Schedule", "MonthlyPattern", "RotationMember", "Assignment", "SwapRequest", "NotificationLog" + )}) + code.interact( + banner="Database console: db, select, models and model classes are available.\n" + "Example: db.scalars(select(Team)).all()\n" + "Use db.commit() to save edits, db.rollback() to discard them; Ctrl-D to exit.", + local=namespace, + ) + + +def send_reminders() -> None: + migrate() + target = date.today() + timedelta(days=settings.reminder_days_ahead) + with SessionLocal() as db: + ensure_initial_data(db) + ensure_month_assignments(db, target.year, target.month) + assignments = db.scalars( + select(Assignment) + .where(Assignment.date == target, Assignment.team_id.is_not(None)) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + ).all() + count = 0 + for assignment in assignments: + if not assignment.team: + continue + subject = f"Lembrete de turno: {assignment.schedule.name} a {day_numeric(assignment.date)}" + already_sent = db.scalar( + select(NotificationLog.id).where( + NotificationLog.team_id == assignment.team.id, + NotificationLog.event_type == "shift_reminder", + NotificationLog.subject == subject, + NotificationLog.status == "sent", + ) + ) + if already_sent: + continue + send_email_notification( + db, + assignment.team, + "shift_reminder", + subject, + f"Lembrete: está atribuído ao turno de {assignment.schedule.name} a " + f"{day_long(assignment.date)}.\n\nVer a escala: {settings.normalized_base_url}/", + ) + count += 1 + print(f"Processed {count} reminder(s) for {target.isoformat()}.") + + +def main() -> None: + command = sys.argv[1] if len(sys.argv) > 1 else "" + commands = { + "init-db": init_db, + "migrate": migrate, + "seed-demo": seed_demo, + "db-shell": db_shell, + "send-reminders": send_reminders, + } + if command not in commands: + print("Usage: python -m app.cli [init-db|migrate|seed-demo|db-shell|send-reminders]") + raise SystemExit(2) + commands[command]() + + +if __name__ == "__main__": + main() diff --git a/apps/shifts/backend/app/config.py b/apps/shifts/backend/app/config.py new file mode 100644 index 0000000..6b26965 --- /dev/null +++ b/apps/shifts/backend/app/config.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Contínuos CRR" + secret_key: str = "dev-only-change-me" + database_url: str = "sqlite:///./data/bar_rota.db" + base_url: str = "http://localhost:8000" + # Prefix the reverse proxy serves the app under, e.g. "/crr" for https://host/crr/. + # The proxy must forward that prefix (X-Forwarded-Prefix) or the paths here must match. + root_path: str = "" + # Networks allowed to set X-Forwarded-*; "*" for a private docker network. + trusted_proxy_hosts: str = "*" + # Secret token of the whole-rota calendar feed; every person also has their own. + calendar_token: str = "" + # Where the startup copies of the SQLite file go; empty means "backups" beside it. + backup_dir: str = "" + # Timezone of the calendar feed, so shifts show at the bar's own wall-clock time. + timezone: str = "Europe/Lisbon" + + initial_admin_name: str = "Administrador" + initial_admin_username: str = "admin" + initial_admin_email: str = "admin@example.com" + initial_admin_password: str = "change-me-now" + + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_from: str = "bar-rota@example.com" + smtp_starttls: bool = True + reminder_days_ahead: int = 1 + session_https_only: bool = False + + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore" + ) + + @property + def normalized_base_url(self) -> str: + return self.base_url.rstrip("/") + + @property + def prefix(self) -> str: + """The path the reverse proxy serves the app under, without a trailing slash.""" + return self.root_path.rstrip("/") + + def url(self, path: str) -> str: + """Prefix an internal path so it keeps working under a sub-path deployment.""" + if not self.prefix or not path.startswith("/") or path.startswith("//"): + return path + cut = min( + (index for index in (path.find("?"), path.find("#")) if index != -1), + default=len(path), + ) + return f"{self.prefix}{path[:cut]}{path[cut:]}" + + +settings = Settings() diff --git a/apps/shifts/backend/app/database.py b/apps/shifts/backend/app/database.py new file mode 100644 index 0000000..7d75590 --- /dev/null +++ b/apps/shifts/backend/app/database.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import shutil +import sqlite3 +from collections.abc import Generator +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +from alembic import command +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy import create_engine, inspect +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import settings + +MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "migrations" + + +class Base(DeclarativeBase): + pass + + +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, connect_args=connect_args, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, class_=Session) + + +def alembic_config() -> Config: + config = Config(str(MIGRATIONS_DIR.parent / "alembic.ini")) + config.set_main_option("script_location", str(MIGRATIONS_DIR)) + config.set_main_option("sqlalchemy.url", settings.database_url) + return config + + +def run_migrations() -> None: + """Bring the database up to the latest revision. Safe to call on every start.""" + command.upgrade(alembic_config(), "head") + + +def current_revision() -> str | None: + with engine.connect() as connection: + return MigrationContext.configure(connection).get_current_revision() + + +def has_tables() -> bool: + """True when the database already holds the pre-Alembic schema.""" + return bool(set(inspect(engine).get_table_names()) - {"alembic_version"}) + + +def head_revision() -> str: + return ScriptDirectory.from_config(alembic_config()).get_current_head() or "" + + +def stamp(revision: str) -> None: + command.stamp(alembic_config(), revision) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def database_file(url: str | None = None) -> Path | None: + """The SQLite file behind DATABASE_URL, or None for another engine.""" + raw = url or settings.database_url + if not raw.startswith("sqlite"): + return None + path = urlparse(raw).path + if not path: + return None + # sqlite:///./data/x.db is relative, sqlite:////srv/x.db is absolute. + if path.startswith("/"): + path = path[1:] + if not path or path.startswith(":memory:"): + return None + return Path(path) + + +def backup_database(destination_dir: str | None = None) -> Path | None: + """Copy the database aside on every start, never overwriting an older copy. + + Uses SQLite's own backup API so the copy is consistent even if another + process is writing. Returns the new file, or None when there is nothing to copy. + """ + source = database_file() + if source is None or not source.is_file(): + return None + + folder = Path(destination_dir or settings.backup_dir or source.parent / "backups") + folder.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + target = folder / f"{source.stem}-{stamp}{source.suffix}" + index = 1 + while target.exists(): # two starts in the same second + target = folder / f"{source.stem}-{stamp}-{index}{source.suffix}" + index += 1 + + with sqlite3.connect(source) as origin, sqlite3.connect(target) as copy: + origin.backup(copy) + shutil.copystat(source, target) + return target diff --git a/apps/shifts/backend/app/i18n.py b/apps/shifts/backend/app/i18n.py new file mode 100644 index 0000000..a97d486 --- /dev/null +++ b/apps/shifts/backend/app/i18n.py @@ -0,0 +1,152 @@ +"""European Portuguese labels, dates and enum translations. + +The interface, the notification emails and every rendered date are pt-PT. +`datetime.strftime` is locale-independent in the app container, so weekday and +month names come from the tables below instead of the `calendar` module. +""" + +from __future__ import annotations + +from datetime import date, datetime, time + +MONTHS = ( + "Janeiro", + "Fevereiro", + "Março", + "Abril", + "Maio", + "Junho", + "Julho", + "Agosto", + "Setembro", + "Outubro", + "Novembro", + "Dezembro", +) + +MONTHS_SHORT = ( + "jan", + "fev", + "mar", + "abr", + "mai", + "jun", + "jul", + "ago", + "set", + "out", + "nov", + "dez", +) + +WEEKDAYS_SHORT = ("seg", "ter", "qua", "qui", "sex", "sáb", "dom") + +WEEKDAYS_LONG = ( + "segunda-feira", + "terça-feira", + "quarta-feira", + "quinta-feira", + "sexta-feira", + "sábado", + "domingo", +) + +SOURCE_LABELS = { + "generated": "automático", + "manual": "manual", + "swap": "troca", +} + +SWAP_STATUS_LABELS = { + "open": "aberto", + "pending_approval": "aguarda aprovação", + "approved": "aprovado", + "rejected": "recusado", + "reverted": "revertida", + "cancelled": "cancelado", +} + +NOTIFICATION_STATUS_LABELS = { + "sent": "enviado", + "skipped": "ignorado", + "failed": "falhou", +} + +EVENT_TYPE_LABELS = { + "swap_requested": "Pedido de troca", + "swap_needs_approval": "Troca aguarda aprovação", + "swap_accepted": "Troca aceite", + "swap_approved": "Troca aprovada", + "swap_rejected": "Troca recusada", + "swap_reverted": "Troca revertida", + "assignment_updated": "Turno atribuído", + "assignment_changed": "Turno retirado", + "shift_reminder": "Lembrete de turno", + "reminder": "Lembrete", +} + + +def month_name(month: int) -> str: + return MONTHS[month - 1] + + +def weekday_name(weekday: int, *, short: bool = False) -> str: + names = WEEKDAYS_SHORT if short else WEEKDAYS_LONG + return names[weekday].capitalize() + + +def weekday_names(weekdays: set[int]) -> str: + names = [weekday_name(day) for day in sorted(weekdays)] + if len(names) == 1: + return names[0] + if len(names) == 2: + return f"{names[0]} e {names[1]}" + return ", ".join(names[:-1]) + f" e {names[-1]}" + + +def day_short(day: date) -> str: + """`sáb 03 jan`""" + return f"{WEEKDAYS_SHORT[day.weekday()]} {day.day:02d} {MONTHS_SHORT[day.month - 1]}" + + +def day_long(day: date) -> str: + """`sábado, 3 de janeiro de 2026`""" + return ( + f"{weekday_name(day.weekday())}, {day.day} de {month_name(day.month).lower()} de {day.year}" + ) + + +def day_numeric(day: date) -> str: + """`03/01/2026`""" + return f"{day.day:02d}/{day.month:02d}/{day.year}" + + +def day_numeric_long(day: date) -> str: + """`3 de janeiro de 2026`""" + return f"{day.day} de {month_name(day.month).lower()} de {day.year}" + + +def moment(moment: datetime) -> str: + """`03/01/2026 21:45`""" + return f"{day_numeric(moment.date())} {moment.strftime('%H:%M')}" + + +def source_label(source: str) -> str: + return SOURCE_LABELS.get(source, source) + + +def swap_status_label(status: str) -> str: + return SWAP_STATUS_LABELS.get(status, status.replace("_", " ")) + + +def notification_status_label(status: str) -> str: + return NOTIFICATION_STATUS_LABELS.get(status, status) + + +def event_type_label(event_type: str) -> str: + return EVENT_TYPE_LABELS.get(event_type, event_type) + + +def window_label(start: time, end: time) -> str: + """`20:30–00:00`, the hours a shift runs.""" + return f"{start:%H:%M}\u2013{end:%H:%M}" diff --git a/apps/shifts/backend/app/main.py b/apps/shifts/backend/app/main.py new file mode 100644 index 0000000..b1143ca --- /dev/null +++ b/apps/shifts/backend/app/main.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + +from .bootstrap import ensure_initial_data +from .branding import BRAND_DIR +from .config import settings +from .database import ( + SessionLocal, + backup_database, + current_revision, + has_tables, + run_migrations, + stamp, +) +from .i18n import ( + day_long, + day_numeric, + day_numeric_long, + day_short, + event_type_label, + moment, + notification_status_label, + source_label, + swap_status_label, + weekday_name, + window_label, +) +from .web import router + +BASE_DIR = Path(__file__).resolve().parent + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # A copy of the database before anything else runs; never fatal. + try: + backup = backup_database() + if backup is not None: + print(f"Database backup written to {backup}") + except Exception as exc: # a failed backup must never stop the app + print(f"Could not back up the database: {exc}") + # Databases created before Alembic existed have no version stamp: adopt them + # at the baseline revision so the migrations can continue from there. + if has_tables() and current_revision() is None: + stamp("0001_initial") + run_migrations() + with SessionLocal() as db: + ensure_initial_data(db) + yield + + +app = FastAPI(title=settings.app_name, lifespan=lifespan, root_path=settings.root_path) +# Behind a reverse proxy, uvicorn runs with --proxy-headers so X-Forwarded-Proto/Host +# set the public scheme and host; ROOT_PATH adds the prefix the proxy serves us under. +app.add_middleware( + SessionMiddleware, + secret_key=settings.secret_key, + same_site="lax", + https_only=settings.session_https_only, + max_age=60 * 60 * 24 * 30, +) +app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") +app.mount("/brand", StaticFiles(directory=BRAND_DIR), name="brand") +templates = Jinja2Templates(directory=BASE_DIR / "templates") +templates.env.filters.update( + { + "dia": day_short, + "dia_curto": lambda day: weekday_name(day.weekday(), short=True), + "dia_longo": day_long, + "data": day_numeric, + "data_longa": day_numeric_long, + "momento": moment, + "origem": source_label, + "estado": swap_status_label, + "estado_email": notification_status_label, + "evento": event_type_label, + "horario": lambda schedule: window_label(schedule.start_time, schedule.end_time), + } +) +templates.env.globals["app_path"] = settings.url +app.state.templates = templates +app.include_router(router) diff --git a/apps/shifts/backend/app/models.py b/apps/shifts/backend/app/models.py new file mode 100644 index 0000000..cadaa31 --- /dev/null +++ b/apps/shifts/backend/app/models.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from datetime import date, datetime, time + +from sqlalchemy import ( + Boolean, + CheckConstraint, + Date, + DateTime, + ForeignKey, + Integer, + String, + Text, + Time, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .database import Base + + +class User(Base): + """A login: a username, a password and the administrator flag. Several teams can be + assigned to it, so one person can sign in once and control the shifts of all of them.""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(300), nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + teams: Mapped[list[Team]] = relationship(back_populates="user") + + @property + def can_login(self) -> bool: + return bool(self.username and self.password_hash) + + @property + def label(self) -> str: + return self.name or self.username + + +class Team(Base): + """Who works a shift: one person, or the people who cover it together. Every rota + row, rotation position, pattern day and change request names a team.""" + + __tablename__ = "teams" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + email: Mapped[str | None] = mapped_column(String(254), unique=True, nullable=True) + phone: Mapped[str | None] = mapped_column(String(40), nullable=True) + calendar_token: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + notify_email: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + user: Mapped[User | None] = relationship(back_populates="teams") + + @property + def can_login(self) -> bool: + """Whether anyone signs in for this team.""" + return self.user is not None + + @property + def is_admin(self) -> bool: + """Administrator rights belong to the user, so every team it covers has them.""" + return bool(self.user and self.user.is_admin) + + @property + def username(self) -> str | None: + return self.user.username if self.user else None + + +class Schedule(Base): + __tablename__ = "schedules" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) + schedule_type: Mapped[str] = mapped_column(String(20), nullable=False) # fixed | rotation + weekdays: Mapped[str] = mapped_column(String(30), nullable=False) # comma-separated 0..6 + # When the shift runs, in the bar's local time. An end at or before the start means the next day. + start_time: Mapped[time] = mapped_column(Time, default=time(20, 30), nullable=False) + end_time: Mapped[time] = mapped_column(Time, default=time(23, 59), nullable=False) + requires_manager_approval: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + rotation_anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + rotation_members: Mapped[list[RotationMember]] = relationship( + back_populates="schedule", cascade="all, delete-orphan", order_by="RotationMember.position" + ) + pattern_days: Mapped[list[MonthlyPattern]] = relationship( + back_populates="schedule", cascade="all, delete-orphan", order_by="MonthlyPattern.day_of_month" + ) + + @property + def weekday_set(self) -> set[int]: + return {int(v) for v in self.weekdays.split(",") if v.strip()} + + +class MonthlyPattern(Base): + """One team per calendar day of the month, repeating every month.""" + + __tablename__ = "monthly_patterns" + __table_args__ = ( + UniqueConstraint("schedule_id", "day_of_month", name="uq_pattern_schedule_day"), + CheckConstraint("day_of_month BETWEEN 1 AND 31", name="ck_pattern_day_of_month"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) + day_of_month: Mapped[int] = mapped_column(Integer, nullable=False) + team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + + schedule: Mapped[Schedule] = relationship(back_populates="pattern_days") + team: Mapped[Team | None] = relationship() + + +class RotationMember(Base): + __tablename__ = "rotation_members" + __table_args__ = ( + UniqueConstraint("schedule_id", "team_id", name="uq_rotation_schedule_team"), + UniqueConstraint("schedule_id", "position", name="uq_rotation_schedule_position"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) + team_id: Mapped[int] = mapped_column(ForeignKey("teams.id", ondelete="CASCADE"), nullable=False) + position: Mapped[int] = mapped_column(Integer, nullable=False) + + schedule: Mapped[Schedule] = relationship(back_populates="rotation_members") + team: Mapped[Team] = relationship() + + +class Assignment(Base): + __tablename__ = "assignments" + __table_args__ = (UniqueConstraint("schedule_id", "date", name="uq_assignment_schedule_date"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + source: Mapped[str] = mapped_column(String(20), default="generated", nullable=False) # generated | manual | swap + note: Mapped[str | None] = mapped_column(String(500), nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + schedule: Mapped[Schedule] = relationship() + team: Mapped[Team | None] = relationship() + + +class SwapRequest(Base): + """A swap is one shift for one shift: `assignment_id` goes to the target and + `target_assignment_id` comes back to the requester.""" + + __tablename__ = "swap_requests" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=False) + target_assignment_id: Mapped[int | None] = mapped_column( + ForeignKey("assignments.id", ondelete="SET NULL"), nullable=True + ) + requester_id: Mapped[int] = mapped_column(ForeignKey("teams.id", ondelete="CASCADE"), nullable=False) + target_team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + accepted_team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + status: Mapped[str] = mapped_column(String(30), default="open", nullable=False) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + assignment: Mapped[Assignment] = relationship(foreign_keys=[assignment_id]) + target_assignment: Mapped[Assignment | None] = relationship(foreign_keys=[target_assignment_id]) + requester: Mapped[Team] = relationship(foreign_keys=[requester_id]) + target_team: Mapped[Team | None] = relationship(foreign_keys=[target_team_id]) + accepted_by: Mapped[Team | None] = relationship(foreign_keys=[accepted_team_id]) + + +class NotificationLog(Base): + __tablename__ = "notification_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + event_type: Mapped[str] = mapped_column(String(60), nullable=False) + channel: Mapped[str] = mapped_column(String(20), default="email", nullable=False) + recipient: Mapped[str | None] = mapped_column(String(254), nullable=True) + subject: Mapped[str] = mapped_column(String(250), nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False) # sent | skipped | failed + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + team: Mapped[Team | None] = relationship() diff --git a/apps/shifts/backend/app/notifications.py b/apps/shifts/backend/app/notifications.py new file mode 100644 index 0000000..10076a3 --- /dev/null +++ b/apps/shifts/backend/app/notifications.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import smtplib +from datetime import datetime +from email.message import EmailMessage + +from sqlalchemy.orm import Session + +from .config import settings +from .models import NotificationLog, Team + + +def send_email_notification( + db: Session, + team: Team, + event_type: str, + subject: str, + body: str, +) -> NotificationLog: + recipient = team.email + log = NotificationLog( + team_id=team.id, + event_type=event_type, + channel="email", + recipient=recipient, + subject=subject, + body=body, + status="skipped", + ) + + if not team.notify_email or not recipient: + log.error = "Notificações por e-mail desativadas ou sem endereço de e-mail" + db.add(log) + db.commit() + return log + + if not settings.smtp_host: + log.error = "SMTP_HOST não está configurado" + db.add(log) + db.commit() + return log + + msg = EmailMessage() + msg["From"] = settings.smtp_from + msg["To"] = recipient + msg["Subject"] = subject + msg.set_content(body) + + try: + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as smtp: + if settings.smtp_starttls: + smtp.starttls() + if settings.smtp_username: + smtp.login(settings.smtp_username, settings.smtp_password) + smtp.send_message(msg) + log.status = "sent" + log.sent_at = datetime.utcnow() + except Exception as exc: # keep scheduling operations usable if email is unavailable + log.status = "failed" + log.error = str(exc) + + db.add(log) + db.commit() + return log + + +def send_many( + db: Session, + teams: list[Team], + event_type: str, + subject: str, + body: str, +) -> None: + seen: set[int] = set() + for team in teams: + if team.id in seen: + continue + seen.add(team.id) + send_email_notification(db, team, event_type, subject, body) diff --git a/apps/shifts/backend/app/pdf.py b/apps/shifts/backend/app/pdf.py new file mode 100644 index 0000000..b4152f2 --- /dev/null +++ b/apps/shifts/backend/app/pdf.py @@ -0,0 +1,167 @@ +"""Single-page A4 schedule lists, downloaded without a browser print dialog.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from datetime import date +from io import BytesIO +from itertools import combinations +from pathlib import Path +from xml.sax.saxutils import escape + +import reportlab +from reportlab.lib.colors import HexColor +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen.canvas import Canvas +from reportlab.platypus import Paragraph + +from .config import settings +from .branding import LOGO_PATH +from .i18n import month_name +from .models import Assignment, Schedule + +# ReportLab ships these Unicode fonts, including Portuguese accents, in its wheel. +FONT_DIR = Path(reportlab.__file__).parent / "fonts" +pdfmetrics.registerFont(TTFont("Rota", str(FONT_DIR / "Vera.ttf"))) +pdfmetrics.registerFont(TTFont("RotaBold", str(FONT_DIR / "VeraBd.ttf"))) +RED = HexColor("#c81018") +GREEN = HexColor("#183c2f") +MUTED = HexColor("#6f6567") + + +@dataclass +class MonthBlock: + title: str + rows: list[tuple[str, Paragraph, float]] + height: float + + +def _columns(blocks: list[MonthBlock], count: int) -> list[list[MonthBlock]]: + """Balance complete months across columns, retaining chronological order.""" + best: list[list[MonthBlock]] = [] + best_height = float("inf") + for cuts in combinations(range(1, len(blocks)), count - 1): + edges = (0, *cuts, len(blocks)) + groups = [blocks[left:right] for left, right in zip(edges, edges[1:])] + height = max(sum(block.height for block in group) for group in groups) + if height < best_height: + best, best_height = groups, height + return best + + +def _layout( + months: dict[tuple[int, int], list[Assignment]], width: float, available: float +) -> tuple[list[list[MonthBlock]], float, float, float]: + """Fit the entire list on one page, wrapping names instead of truncating them.""" + for count in range(1, min(4, len(months)) + 1): + column_width = (width - (count - 1) * 7 * mm) / count + for size in range(16, 7, -1): + style = ParagraphStyle( + "team", fontName="RotaBold", fontSize=size, leading=size * 1.3, + textColor=GREEN, splitLongWords=True, + ) + blocks = [] + for (year, month), assignments in months.items(): + rows = [] + for assignment in assignments: + text = assignment.team.name if assignment.team else "Sem atribuição" + paragraph = Paragraph(escape(text), style) + _, height = paragraph.wrap(column_width - size * 2.5, available) + rows.append((str(assignment.date.day), paragraph, height + size * 0.3)) + if not rows: + paragraph = Paragraph("Sem turnos neste período.", style) + _, height = paragraph.wrap(column_width - size * 2.5, available) + rows.append(("", paragraph, height + size * 0.3)) + blocks.append(MonthBlock( + f"{month_name(month)} {year}", rows, + size * 2 + sum(row[2] for row in rows) + size, + )) + groups = _columns(blocks, count) + height = max(sum(block.height for block in group) for group in groups) + if height <= available: + return groups, column_width, size, 1.0 + # Very dense ranges or unusually long names still retain every row on A4. + return groups, column_width, size, min(1.0, available / height) + + +def build_schedule_pdf( + schedule: Schedule, assignments: list[Assignment], start: date, end: date +) -> bytes: + output = BytesIO() + canvas = Canvas(output, pagesize=A4) + canvas.setTitle(schedule.name) + canvas.setAuthor(settings.app_name) + width, height = A4 + margin = 16 * mm + left_margin = 24 * mm + content_width = width - left_margin - margin + text_x = left_margin + 94 + text_width = content_width - 112 + brand = Paragraph(escape(settings.app_name.upper()), ParagraphStyle( + "brand", fontName="RotaBold", fontSize=9, leading=12, textColor=MUTED, + )) + _, brand_height = brand.wrap(text_width, height) + title = Paragraph(escape(schedule.name), ParagraphStyle( + "title", fontName="RotaBold", fontSize=24, leading=29, textColor=RED, + )) + _, title_height = title.wrap(text_width, height) + header_height = max(94, brand_height + title_height + 43) + header_top = height - margin + header_bottom = header_top - header_height + + # A softly tinted banner and white logo badge frame the CRR identity. + canvas.setFillColor(HexColor("#fff4f4")) + canvas.roundRect(left_margin, header_bottom, content_width, header_height, 10, stroke=0, fill=1) + badge_y = header_bottom + (header_height - 68) / 2 + canvas.setFillColor(HexColor("#ffffff")) + canvas.roundRect(left_margin + 12, badge_y, 68, 68, 8, stroke=0, fill=1) + canvas.drawImage( + str(LOGO_PATH), left_margin + 18, badge_y + 6, width=56, height=56, + preserveAspectRatio=True, anchor="c", mask="auto", + ) + text_top = header_bottom + (header_height + brand_height + 7 + title_height) / 2 + brand.drawOn(canvas, text_x, text_top - brand_height) + title.drawOn(canvas, text_x, text_top - brand_height - 7 - title_height) + + canvas.setStrokeColor(HexColor("#eedada")) + canvas.setLineWidth(0.7) + canvas.line(left_margin, header_bottom - 12, width - margin, header_bottom - 12) + canvas.setStrokeColor(RED) + canvas.setLineWidth(3) + canvas.line(left_margin, header_bottom - 12, left_margin + 44, header_bottom - 12) + top = header_bottom - 30 + available = top - margin + + months: dict[tuple[int, int], list[Assignment]] = defaultdict(list) + for assignment in sorted(assignments, key=lambda row: row.date): + months[(assignment.date.year, assignment.date.month)].append(assignment) + if not months: + months[(start.year, start.month)] = [] + groups, column_width, size, scale = _layout(months, content_width, available) + canvas.saveState() + canvas.translate(left_margin, top) + canvas.scale(scale, scale) + for index, group in enumerate(groups): + x = index * (column_width + 7 * mm) + y = 0.0 + for block in group: + canvas.setFillColor(RED) + canvas.setFont("RotaBold", size) + canvas.drawString(x, y - size, block.title) + y -= size * 2 + for day, paragraph, row_height in block.rows: + canvas.setFillColor(GREEN) + canvas.setFont("RotaBold", size) + canvas.drawRightString(x + size * 1.5, y - size, day) + paragraph.drawOn(canvas, x + size * 2.5, y - paragraph.height) + y -= row_height + y -= size + canvas.restoreState() + canvas.showPage() + canvas.save() + return output.getvalue() diff --git a/apps/shifts/backend/app/scheduling.py b/apps/shifts/backend/app/scheduling.py new file mode 100644 index 0000000..12e1c78 --- /dev/null +++ b/apps/shifts/backend/app/scheduling.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import calendar +from collections import defaultdict +from datetime import date, timedelta + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from .models import Assignment, MonthlyPattern, RotationMember, Schedule, Team + + +def month_bounds(year: int, month: int) -> tuple[date, date]: + last_day = calendar.monthrange(year, month)[1] + return date(year, month, 1), date(year, month, last_day) + + +def iter_month_dates(year: int, month: int): + first, last = month_bounds(year, month) + current = first + while current <= last: + yield current + current += timedelta(days=1) + + +def _rotation_occurrence_index(schedule: Schedule, target: date) -> int: + """Return zero-based matching-day occurrence index from the schedule anchor.""" + if schedule.rotation_anchor_date is None: + raise ValueError("Rotation schedule is missing an anchor date") + + anchor = schedule.rotation_anchor_date + weekdays = schedule.weekday_set + step = 1 if target >= anchor else -1 + current = anchor + count = 0 + + if step == 1: + while current < target: + if current.weekday() in weekdays: + count += 1 + current += timedelta(days=1) + return count + + while current > target: + current -= timedelta(days=1) + if current.weekday() in weekdays: + count -= 1 + return count + + +def rotation_team_for_date(db: Session, schedule: Schedule, target: date) -> Team | None: + """Continue after the last actual assignee in this schedule, with anchor fallback.""" + if schedule.schedule_type != "rotation" or target.weekday() not in schedule.weekday_set: + return None + + members = db.scalars( + select(RotationMember) + .join(Team, RotationMember.team_id == Team.id) + .where(RotationMember.schedule_id == schedule.id) + .order_by(RotationMember.position) + .options(selectinload(RotationMember.team)) + ).all() + active_members = [member for member in members if member.team.is_active] + if not active_members: + return None + + previous_team_id = db.scalar( + select(Assignment.team_id) + .where( + Assignment.schedule_id == schedule.id, + Assignment.date < target, + Assignment.team_id.is_not(None), + ) + .order_by(Assignment.date.desc()) + .limit(1) + ) + for idx, member in enumerate(members): + if member.team_id == previous_team_id: + for offset in range(1, len(members) + 1): + next_member = members[(idx + offset) % len(members)] + if next_member.team.is_active: + return next_member.team + + # No usable reference yet (or the last assignee left the rotation). + idx = _rotation_occurrence_index(schedule, target) % len(active_members) + return active_members[idx].team + + +def pattern_team_for_date(db: Session, schedule: Schedule, target: date) -> Team | None: + """The team configured for this calendar day of the month, if any.""" + row = db.scalar( + select(MonthlyPattern) + .where(MonthlyPattern.schedule_id == schedule.id, MonthlyPattern.day_of_month == target.day) + .options(selectinload(MonthlyPattern.team)) + ) + if row is None or row.team is None or not row.team.is_active: + return None + return row.team + + +def pattern_teams(db: Session, schedule: Schedule) -> dict[int, int]: + """Active team per calendar day of the month, for a `fixed` schedule.""" + rows = db.scalars( + select(MonthlyPattern) + .where(MonthlyPattern.schedule_id == schedule.id) + .options(selectinload(MonthlyPattern.team)) + ).all() + return {row.day_of_month: row.team.id for row in rows if row.team is not None and row.team.is_active} + + +def ensure_month_assignments(db: Session, year: int, month: int) -> list[Assignment]: + schedules = db.scalars(select(Schedule).where(Schedule.is_active.is_(True))).all() + first, last = month_bounds(year, month) + existing = db.scalars( + select(Assignment).where(Assignment.date >= first, Assignment.date <= last) + ).all() + existing_keys = {(a.schedule_id, a.date) for a in existing} + + changed = False + for schedule in schedules: + patterns = pattern_teams(db, schedule) if schedule.schedule_type == "fixed" else {} + for day in iter_month_dates(year, month): + if day.weekday() not in schedule.weekday_set: + continue + key = (schedule.id, day) + if key in existing_keys: + continue + + team_id = None + if schedule.schedule_type == "rotation": + team = rotation_team_for_date(db, schedule, day) + team_id = team.id if team else None + else: + team_id = patterns.get(day.day) + + db.add( + Assignment( + schedule_id=schedule.id, + date=day, + team_id=team_id, + source="generated", + ) + ) + # SessionLocal disables autoflush: the next date must see this assignee. + db.flush() + changed = True + + if changed: + db.commit() + + return list( + db.scalars( + select(Assignment) + .where(Assignment.date >= first, Assignment.date <= last) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + .order_by(Assignment.date, Assignment.schedule_id) + ).all() + ) + + +def assignments_by_date(assignments: list[Assignment]) -> dict[date, list[Assignment]]: + result: dict[date, list[Assignment]] = defaultdict(list) + for assignment in assignments: + result[assignment.date].append(assignment) + return dict(result) diff --git a/apps/shifts/backend/app/security.py b/apps/shifts/backend/app/security.py new file mode 100644 index 0000000..c97b05d --- /dev/null +++ b/apps/shifts/backend/app/security.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets + +ITERATIONS = 310_000 + + +MIN_PASSWORD_LENGTH = 4 + + +def hash_password(password: str) -> str: + if len(password.strip()) < MIN_PASSWORD_LENGTH: + raise ValueError( + f"A palavra-passe tem de ter pelo menos {MIN_PASSWORD_LENGTH} caracteres" + ) + salt = os.urandom(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS) + return f"pbkdf2_sha256${ITERATIONS}${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, encoded: str | None) -> bool: + if not encoded: + return False + try: + scheme, iterations, salt_hex, digest_hex = encoded.split("$", 3) + if scheme != "pbkdf2_sha256": + return False + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt_hex), int(iterations) + ) + return hmac.compare_digest(digest.hex(), digest_hex) + except (ValueError, TypeError): + return False + + +def new_csrf_token() -> str: + return secrets.token_urlsafe(32) diff --git a/apps/shifts/backend/app/static/app.css b/apps/shifts/backend/app/static/app.css new file mode 100644 index 0000000..ee5ddfa --- /dev/null +++ b/apps/shifts/backend/app/static/app.css @@ -0,0 +1,137 @@ +:root{ + --bg:var(--crr-background); + --card:var(--crr-surface); + --text:var(--crr-text); + --muted:var(--crr-muted); + --line:var(--crr-border); + --brand:var(--crr-primary); + --brand-deep:var(--crr-primary-hover); + --brand-dark:var(--crr-primary-dark); + --brand-soft:var(--crr-brand-soft); + --accent:var(--crr-primary); + --accent2:var(--brand-soft); + --danger:var(--crr-danger); + --warn:var(--crr-warning); +} +body{font-size:16px;line-height:1.45} +.container{width:min(1100px,calc(100% - 28px));margin:auto} +.topbar{background:#fff;border-top:4px solid var(--brand);border-bottom:1px solid var(--line);margin-bottom:28px} +.topbar-inner{min-height:68px;display:flex;align-items:center;gap:20px;flex-wrap:wrap} +.brand{display:inline-flex;align-items:center;gap:10px;font-weight:800;color:var(--text);text-decoration:none;font-size:1.12rem;min-width:0} +.brand-logo{display:block;width:46px;height:41px;object-fit:contain;flex:0 0 auto} +.brand-name{white-space:nowrap} +.nav{display:flex;gap:14px;flex:1;flex-wrap:wrap} +.nav a,.link-button{color:var(--muted);text-decoration:none;background:none;border:0;padding:0;font:inherit;cursor:pointer} +.nav a:hover,.link-button:hover{color:var(--brand-dark)} +.account{display:flex;gap:10px;align-items:center;font-size:.9rem} +.page-head{display:flex;justify-content:space-between;gap:20px;align-items:end;flex-wrap:wrap;margin-bottom:20px} +h1,h2,h3{line-height:1.2} +h1{margin:0 0 6px;font-size:1.8rem} +h2{font-size:1.15rem;margin:0 0 8px} +a{color:var(--brand-dark)} +.muted{color:var(--muted)} +.auth-card,.day-card{background:var(--card);border:1px solid var(--line);border-radius:12px} +.panel{padding:18px} +.auth-card{max-width:430px;margin:56px auto 70px;padding:28px} +.auth-brand{display:flex;justify-content:center;margin:-2px 0 18px} +.auth-brand img{display:block;width:126px;max-width:42%;height:auto;aspect-ratio:2048/1807;object-fit:contain} +.stack{display:flex;flex-direction:column} +.stack:not(.gap-lg){gap:14px} +.gap-lg{gap:14px} +.top-space{margin-top:18px} +.row{display:flex;align-items:center} +.row.end{justify-content:flex-end} +.between{justify-content:space-between} +.wrap{flex-wrap:wrap} +.gap{gap:14px} +.month-nav,.actions,.inline-form{display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.button,button{border-radius:var(--crr-radius-control);border-width:1px;border-style:solid;padding:9px 12px;text-decoration:none;display:inline-block;font-weight:600} +button:not(.secondary):not(.danger){border-color:transparent} +.danger{background:#fff;color:var(--danger);border-color:#e7c3c3} +.small{padding:5px 9px;font-size:.88rem} +.compact{gap:4px} +.link-button{padding:0;font-weight:400} +.account-link{display:flex;flex-direction:column;line-height:1.15;padding:5px 11px;border:1px solid var(--line);border-radius:999px;font-weight:600;text-decoration:none;color:var(--text)} +.account-link .muted{font-size:.78rem;font-weight:500} +.account-link:hover{border-color:var(--brand);color:var(--brand-deep)} +.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} +.form-col{display:flex;flex-direction:column;gap:14px} +.form-grid fieldset{margin:0;border:1px solid var(--line);border-radius:10px;padding:12px 14px 14px;min-width:0} +.form-grid legend{padding:0 6px;font-weight:700;font-size:.9rem} +.form-grid label,.stack label,.swap-form label{display:flex;flex-direction:column;gap:5px;font-weight:600;font-size:.9rem} +input,select{padding:9px 10px} +.check{flex-direction:row!important;align-items:center!important;font-weight:500!important} +.check input{width:auto;accent-color:var(--brand-deep)} +.flash{padding:12px 14px;border-radius:9px;margin-bottom:18px;background:var(--brand-soft);border-left:4px solid var(--brand)} +.flash.error{border-left-color:var(--danger)} +.calendar-list{display:flex;flex-direction:column;gap:8px;padding-bottom:35px} +.calendar-subscribe-actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:14px 0} +.calendar-subscribe-actions .button{display:flex;align-items:center;justify-content:center;text-align:center;min-height:48px} +.calendar-feed label{display:block;margin-bottom:6px;font-size:.9rem;font-weight:600} +.calendar-url-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px} +.calendar-url-row input{flex:1 1 260px;min-width:0} +.calendar-copy-status{min-height:1.45em;margin:8px 0 0;font-size:.9rem} +.calendar-help details+details{margin-top:8px} +.calendar-help summary{padding:10px 0;min-height:44px} +.calendar-help ol{padding-left:24px;margin-top:4px} +.calendar-help li+li{margin-top:8px} +.day-card{display:grid;grid-template-columns:74px 1fr;overflow:hidden} +.day-card.today{outline:2px solid var(--brand);outline-offset:1px} +.day-label{padding:14px;text-align:center;border-right:1px solid var(--line);display:flex;flex-direction:column;background:#fffafa} +.day-label span{font-size:1.55rem;font-weight:800} +.day-assignments{display:flex;flex-direction:column} +.assignment{padding:12px 14px;display:flex;justify-content:space-between;gap:15px;align-items:center} +.assignment+.assignment{border-top:1px solid var(--line)} +.assignment.mine{background:var(--accent2);box-shadow:inset 4px 0 0 var(--brand)} +.assignment-title{font-size:.82rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)} +.assignment-team{font-weight:700} +.unassigned{color:var(--danger)} +details summary{cursor:pointer;color:var(--brand-dark);font-weight:600} +.swap-form{margin-top:10px;min-width:min(360px,75vw);display:grid;gap:8px} +.badge,.status{display:inline-block;border-radius:999px;padding:4px 8px;font-size:.78rem;background:#f2eeee;color:#5a5052} +.status-approved,.status-sent{background:#e5f4eb;color:#286141} +.status-open,.status-pending_approval{background:#fff3d9;color:#735000} +.status-rejected,.status-failed{background:#fae5e5;color:#842e2e} +.quote{border-left:3px solid var(--brand);padding-left:10px;color:var(--muted)} +.eyebrow{text-transform:uppercase;letter-spacing:.04em;font-size:.75rem;color:var(--muted)} +.empty{padding:30px;text-align:center;color:var(--muted)} +.rotation-list{max-width:620px;padding-left:24px} +.rotation-list li{padding:8px 0;border-bottom:1px solid var(--line);display:list-item} +.rotation-list li>span{display:inline-block;min-width:220px} +.group-title{margin:0} +.date-list{margin:0;font-weight:600} +.link-list{list-style:none;margin:0;padding:0} +.link-list li{padding:8px 0;border-bottom:1px solid var(--line)} +.link-list li:last-child{border-bottom:0} +.pattern-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px} +.pattern-day{display:flex;flex-direction:column;gap:4px} +.pattern-day>span{font-size:.75rem;text-transform:uppercase;color:var(--muted)} +.table-wrap{overflow-x:auto;background:#fff;border:1px solid var(--line);border-radius:12px} +table{min-width:650px} +th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line)} +th{font-size:.8rem;color:var(--muted);background:#fff7f7} +.sticky-actions{position:sticky;bottom:10px;padding:15px 0;text-align:right} +.inline-form select{width:auto;min-width:190px} +.inline-form input[type=text]{min-width:260px;flex:1} +@media(max-width:700px){ + .topbar-inner{padding:8px 0} + .brand-logo{width:40px;height:36px} + .nav{order:3;width:100%;padding-bottom:4px} + .account{margin-left:auto} + .form-grid{grid-template-columns:1fr} + .calendar-subscribe-actions{grid-template-columns:1fr} + .calendar-url-row button{width:100%;min-height:44px} + .day-card{grid-template-columns:58px 1fr} + .day-label{padding:10px 7px} + .assignment{align-items:flex-start;flex-direction:column} + .assignment-action{width:100%} + .swap-form{min-width:0;width:100%} + .month-nav{width:100%} + .month-nav .button{flex:1;text-align:center} + .panel{padding:14px} + .auth-card{margin-top:36px;padding:22px} +} +@media(max-width:430px){ + .brand-name{font-size:1rem} + .account-link .name-text{display:none} +} diff --git a/apps/shifts/backend/app/templates/_schedule_export_form.html b/apps/shifts/backend/app/templates/_schedule_export_form.html new file mode 100644 index 0000000..5667555 --- /dev/null +++ b/apps/shifts/backend/app/templates/_schedule_export_form.html @@ -0,0 +1,20 @@ +{% if error %}{% endif %} +
+ +
+ + +
+

As datas inicial e final estão incluídas. Máximo de 12 meses; o tamanho do texto adapta-se ao intervalo.

+
+
diff --git a/apps/shifts/backend/app/templates/account.html b/apps/shifts/backend/app/templates/account.html new file mode 100644 index 0000000..0e1d40e --- /dev/null +++ b/apps/shifts/backend/app/templates/account.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}A minha conta · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Rota

+

A minha conta

+

{{ current_user.name or current_user.username }} · @{{ current_user.username }}

+ {% if current_teams %} +

Este utilizador entra em nome de {{ current_teams|length }} equipa(s): {{ current_teams|map(attribute='name')|join(', ') }}. Vê e controla os turnos de todas.

+ {% endif %} +
+
+ +
+

Alterar palavra-passe

+
+ + +
+
+
+{% endblock %} diff --git a/apps/shifts/backend/app/templates/admin_assign.html b/apps/shifts/backend/app/templates/admin_assign.html new file mode 100644 index 0000000..98b59ee --- /dev/null +++ b/apps/shifts/backend/app/templates/admin_assign.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Atribuição por dia · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Escalas

+

Atribuição por dia

+

Escolha o dia e a equipa que fica com cada turno. É uma atribuição direta, não uma troca.

+
+
+ +
+
+ +
+ ← Dia anterior + Hoje + Dia seguinte → +
+ +
+
+

{{ day|dia_longo }}

+ {% for row in shifts %} +
+ + +
+ {{ row.schedule.name }} + · {{ row.schedule|horario }} +
+ {% if row.assignment.team %} + Agora: {{ row.assignment.team.name }} ({{ row.assignment.source|origem }}) + {% else %} + Agora: sem atribuição + {% endif %} +
+
+
+ + +
+
+ {% else %} +
Este dia não tem turnos.
+ {% endfor %} +
+

Atribuir aqui fecha qualquer pedido de troca em aberto sobre esse turno e avisa a nova equipa e a anterior.

+

«Automático» repõe a equipa que a escala dá a este dia — a ordem da rotação ou o padrão de noites — e volta a marcar o turno como automático.

+
+{% endblock %} \ No newline at end of file diff --git a/apps/shifts/backend/app/templates/admin_notifications.html b/apps/shifts/backend/app/templates/admin_notifications.html new file mode 100644 index 0000000..2706c90 --- /dev/null +++ b/apps/shifts/backend/app/templates/admin_notifications.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Registo de e-mails · {{ settings.app_name }}{% endblock %} +{% block content %} +

Registo de e-mails

«ignorado» significa que a equipa não tem e-mail, que tem as notificações desligadas ou que o SMTP não está configurado.

+
+{% for log in logs %}{% else %}{% endfor %} +
Data e horaEventoEquipaDestinatárioEstadoAssunto
{{ log.created_at|momento }}{{ log.event_type|evento }}{{ log.team.name if log.team else '—' }}{{ log.recipient or '—' }}{{ log.status|estado_email }}{{ log.subject }}
Ainda não há notificações.
+{% endblock %} diff --git a/apps/shifts/backend/app/templates/admin_schedule.html b/apps/shifts/backend/app/templates/admin_schedule.html new file mode 100644 index 0000000..d520307 --- /dev/null +++ b/apps/shifts/backend/app/templates/admin_schedule.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} +{% block content %} +{% set schedule = card.schedule %} +
+
+

Escalas

+

{{ schedule.name }}

+

{{ card.kind_label }} · {{ schedule|horario }}

+
+
+ + + + + + +
+
+
+{% if card.kind == 'rotation' %} +
+
+

{{ month_name }} {{ year }}

+ +
+
+ + + +
+

Deixa sem atribuição os turnos automáticos do mês e ano escolhidos e de todos os seguintes. Nenhum turno é apagado e as atribuições manuais e as trocas concluídas são mantidas. Use Limpar turnos antes de Aplicar rotação, para recalcular tudo a partir de um ponto.

+
+ + +
+ {% for assignment in assignments %} + + {% endfor %} +
DataEquipaOrigemAção
{{ assignment.date|dia }}{{ assignment.source|origem }}
+
+
+ {% for assignment in assignments %} +
+ +
+ {% endfor %} +
+
+

Próximas datas

+

{{ card.dates | join(' · ') }}

+
+
+

Ordem da rotação

+

Rotação independente, preenchida por ordem. Depois de alterar a ordem, use Aplicar rotação acima para atualizar os turnos automáticos deste mês.

+
    + {% for member in schedule.rotation_members %} +
  1. + {{ member.team.name }} +
    +
    +
    +
    +
    +
  2. + {% else %}
  3. Ainda não há membros.
  4. {% endfor %} +
+
+ + + +
+
+{% else %} +
+

Repete todos os meses

+
+ +
+ {% for day in pattern_days %} + + {% endfor %} +
+
+
+
+
+

Aplicar novo padrão

+
+ + + + +
+

Atualiza os turnos automáticos já existentes do mês e ano escolhidos e de todos os seguintes, para refletirem o padrão guardado acima. As atribuições manuais e as trocas concluídas são mantidas, os dias sem equipa no padrão ficam como estão e nenhum turno é criado.

+
+{% endif %} +
+

Limpar atribuições

+

Limpar atribuições a partir do mês de:

+
+ + + + +
+
+
+

Pedidos de troca

+

Um administrador pode atribuir o turno a outra equipa, mesmo que nenhuma pessoa entre na aplicação por ela. Só se aplica se quem pediu a troca continuar a ser o titular do turno.

+ +
+
+{% endblock %} diff --git a/apps/shifts/backend/app/templates/admin_schedules.html b/apps/shifts/backend/app/templates/admin_schedules.html new file mode 100644 index 0000000..4f194de --- /dev/null +++ b/apps/shifts/backend/app/templates/admin_schedules.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Escalas

+

Uma página por tipo de escala. A escala da noite é fixa e repete todos os meses; o almoço de sábado e o de domingo são rotações independentes.

+
+ Atribuir por dia +
+
+{% if schedules %} +
+

Exportar PDF

+

Escolhe uma escala e as datas para descarregar a lista de dias e equipas numa única folha A4.

+ {% include "_schedule_export_form.html" %} +
+{% endif %} +{% for card in cards %} +{% set schedule = card.schedule %} +
+
+
+

{{ schedule.name }}

+

{{ card.kind_label }} · {{ schedule|horario }}{% if not schedule.is_active %} · inativa{% endif %}

+
+
+ {% if schedule.is_active %}Exportar PDF{% endif %} + Configurar +
+
+ {% if card.kind == 'rotation' %} +

Próximas datas

+

{{ card.dates | join(' · ') }}

+

Ordem da rotação

+
    + {% for name in card.rotation_names %} +
  1. {{ name }}
  2. + {% else %}
  3. Ainda não há membros.
  4. {% endfor %} +
+ {% else %} +

Padrão

+ + {% endif %} +
+{% else %} +
Ainda não há escalas. Execute python -m app.cli init-db para criar as predefinidas.
+{% endfor %} +
+

Calendário da equipa

+

Um único endereço com a rota completa, para qualquer calendário.

+

Adicionar ao calendário do telemóvel

+
+ + +
+
+
+{% endblock %} diff --git a/apps/shifts/backend/app/templates/admin_team_delete.html b/apps/shifts/backend/app/templates/admin_team_delete.html new file mode 100644 index 0000000..f027690 --- /dev/null +++ b/apps/shifts/backend/app/templates/admin_team_delete.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Remover {{ team.name }} · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Equipas

+

Remover equipa

+

Isto é definitivo e não pode ser desfeito.

+
+
+
+

{{ team.name }}

+

{% if team.user %}@{{ team.user.username }} · {{ team.user.name }}{% if team.user.teams|length > 1 %} (partilhada com {{ team.user.teams|length - 1 }} equipa(s)){% endif %}{% else %}Sem utilizador, apenas aparece na escala.{% endif %}

+

Só é possível remover quem ainda não tem turnos atribuídos. Primeiro transfira os turnos desta equipa para outras; assim o registo de quem trabalhou cada data mantém-se.

+

Ao remover, acontece o seguinte:

+ +

Se só quiser que esta equipa deixe de receber turnos, desative-a em vez de a remover.

+
+
+ + +
+ Cancelar +
+
+{% endblock %} \ No newline at end of file diff --git a/apps/shifts/backend/app/templates/admin_teams.html b/apps/shifts/backend/app/templates/admin_teams.html new file mode 100644 index 0000000..f008de0 --- /dev/null +++ b/apps/shifts/backend/app/templates/admin_teams.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Equipas · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Equipas

+

Quem entra na rota. Uma equipa é quem fica com um turno, e pode ser uma pessoa ou várias. Os utilizadores que entram na aplicação criam-se em Utilizadores; aqui escolhe-se apenas quem entra para cada equipa.

+
+
+
+
+

Adicionar equipa

+ +
+
+ +
+ + + +
+
+ Utilizador (opcional) + +
+ +
+
+
+{% for team in teams %} +
+ {{ team.name }} {% if team.user %}@{{ team.user.username }}{% if team.user.teams|length > 1 %} · partilhada com {{ team.user.teams|length - 1 }}{% endif %}{% else %}sem utilizador{% endif %}{% if not team.is_active %} · inativa{% endif %} +
+ +
+ + + +
+
+ Utilizador + +

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

+
+ + +
+ {% if team.id in removable_ids %}
Remover
{% endif %} +
+
+{% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/apps/shifts/backend/app/templates/admin_users.html b/apps/shifts/backend/app/templates/admin_users.html new file mode 100644 index 0000000..9487984 --- /dev/null +++ b/apps/shifts/backend/app/templates/admin_users.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Utilizadores · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Utilizadores

+

Cada utilizador tem utilizador e palavra-passe e pode entrar em várias equipas. Quem entra vê e controla os turnos de todas elas, e a administração é do utilizador, não da equipa.

+
+
+
+
+

Adicionar utilizador

+ +
+
+ +
+ + + +
+
+ Equipas (pode escolher várias) + {% for team in teams %} + + {% else %} +

Ainda não há equipas. Crie-as em Equipas.

+ {% endfor %} +
+ +
+
+
+{% for account in users %} +
+ @{{ account.username }} {{ account.name }}{% if account.is_admin %} · administrador{% endif %}{% if not account.is_active %} · inativo{% endif %} · {{ account.teams|length }} equipa(s) +
+ +
+ + + +
+
+ Equipas que entra + {% for team in teams %} + + {% endfor %} +
+ + +
+ {% if account.id in removable_ids %} +
+ {% endif %} +
+ {% if account.teams %} +

Equipas: {% for team in account.teams %}{{ team.name }}{% if not team.is_active %} (inativa){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}

+ {% else %} +

Ainda sem equipas atribuídas.

+ {% endif %} +
+{% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/apps/shifts/backend/app/templates/base.html b/apps/shifts/backend/app/templates/base.html new file mode 100644 index 0000000..be5e63c --- /dev/null +++ b/apps/shifts/backend/app/templates/base.html @@ -0,0 +1,56 @@ + + + + + + + {% block title %}{{ settings.app_name }}{% endblock %} + + + + + + + +
+
+ + + {{ settings.app_name }} + + + {% if current_user %} + + {% else %} + + {% endif %} +
+
+
+ {% if flash %} +
{{ flash.message }}
+ {% endif %} + {% block content %}{% endblock %} +
+ + diff --git a/apps/shifts/backend/app/templates/calendar.html b/apps/shifts/backend/app/templates/calendar.html new file mode 100644 index 0000000..1d63e60 --- /dev/null +++ b/apps/shifts/backend/app/templates/calendar.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% block title %}Calendário · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Rota

+

Calendário

+

Os teus turnos na app de calendário do telemóvel. Subscreve uma vez: as alterações à rota aparecem automaticamente, quando a app atualizar o calendário.

+
+
+ +
+{% for feed in feeds %} +
+

{{ feed.title }}

+ +

No iPhone, confirma a subscrição na app Calendário. No Google e Outlook, adiciona pelo navegador à conta que usas no telemóvel. Ver os passos.

+ +
+ + +
+

+
+{% else %} +
Ainda não tens uma equipa ativa com calendário.
+{% endfor %} +
+ +
+

Como adicionar no telemóvel

+
+ iPhone / iPad — Apple Calendar +
    +
  1. Toca em iPhone / Apple Calendar no calendário que queres adicionar.
  2. +
  3. Permite abrir a app Calendário e confirma Subscrever.
  4. +
  5. Se o botão não abrir a app, copia o endereço. Na app Calendário, abre Calendários → Adicionar calendário → Adicionar calendário de assinatura e cola-o.
  6. +
+
+
+ Android / Google Calendar +
    +
  1. Toca em Google Calendar / Android e entra na conta Google que usas no telemóvel.
  2. +
  3. O Google exige a versão para computador para adicionar subscrições. No navegador do telemóvel, ativa Site para computador; se não conseguires, abre esta página num computador.
  4. +
  5. Confirma a adição. Se não aparecer essa opção, copia o endereço e, no Google Calendar, escolhe Outros calendários → + → A partir de URL, cola-o e adiciona o calendário.
  6. +
  7. Na app Google Calendar, abre o menu e ativa o novo calendário. Se não aparecer, verifica as definições de sincronização.
  8. +
+
+
+ Outlook +
    +
  1. Toca em Outlook e entra na conta que usas na app.
  2. +
  3. Confirma a subscrição no Outlook na Web. Se necessário, usa Adicionar calendário → Subscrever a partir da Web e cola o endereço copiado.
  4. +
  5. Na app Outlook, seleciona o novo calendário nessa conta. Se a versão móvel não mostrar a opção de subscrever, usa o navegador em modo computador.
  6. +
+
+

Escolhe uma subscrição por endereço, em vez de importar um ficheiro .ics, para receber futuras alterações. A atualização depende da app e pode demorar algumas horas. Para Google e Outlook, o endereço tem de estar acessível pela Internet.

+
+ + +{% endblock %} diff --git a/apps/shifts/backend/app/templates/dashboard.html b/apps/shifts/backend/app/templates/dashboard.html new file mode 100644 index 0000000..fad5dc3 --- /dev/null +++ b/apps/shifts/backend/app/templates/dashboard.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block content %} +
+
+

{{ month_name }} {{ year }}

+
+
+ {% if current_user %} + Calendário + Pedir troca + {% endif %} + ← Anterior + Seguinte → +
+
+ +
+{% for day, assignments in month_days %} +
+
+ {{ day|dia_curto }} + {{ day.day }} +
+
+ {% for assignment in assignments %} +
+
+
{{ assignment.schedule.name }}
+
+ {% if assignment.team %}{{ assignment.team.name }}{% else %}Sem atribuição{% endif %} +
+
+ {% set admin_request = admin_requests.get(assignment.id) %} + {% if assignment.id in open_swap_assignment_ids or admin_request %} +
+ {% if current_user and current_user.is_admin and admin_request %} +

Pedido de {{ admin_request.requester.name }}{% if admin_request.target_assignment %} ({{ admin_request.assignment.date|dia }} por {{ admin_request.target_assignment.date|dia }}){% endif %}{% if admin_request.accepted_by %}, aceite por {{ admin_request.accepted_by.name }}{% endif %}

+
+ + {% if admin_request.accepted_by %} + + {% elif admin_request.target_team %} + {# Approve it as requested: the named team takes the shift. #} + + {% else %} + + + {% endif %} +
+
+ + +
+ {% else %} + Troca pedida + {% endif %} +
+ {% endif %} +
+ {% endfor %} +
+
+{% endfor %} +
+{% endblock %} diff --git a/apps/shifts/backend/app/templates/export_schedule.html b/apps/shifts/backend/app/templates/export_schedule.html new file mode 100644 index 0000000..ec5ada5 --- /dev/null +++ b/apps/shifts/backend/app/templates/export_schedule.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Exportar escala · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Rota

+

Exportar escala

+

Escolhe uma escala e as datas. O PDF é descarregado numa única folha A4, com os dias e as equipas agrupados por mês.

+
+
+
+ {% include "_schedule_export_form.html" %} +
+{% endblock %} diff --git a/apps/shifts/backend/app/templates/login.html b/apps/shifts/backend/app/templates/login.html new file mode 100644 index 0000000..84d785e --- /dev/null +++ b/apps/shifts/backend/app/templates/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Entrar · {{ settings.app_name }}{% endblock %} +{% block content %} +
+ +

Entrar

+

Use o seu utilizador e a sua palavra-passe da escala.

+
+ + + + +
+
+{% endblock %} diff --git a/apps/shifts/backend/app/templates/swap_new.html b/apps/shifts/backend/app/templates/swap_new.html new file mode 100644 index 0000000..d7f8531 --- /dev/null +++ b/apps/shifts/backend/app/templates/swap_new.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Pedir troca · {{ settings.app_name }}{% endblock %} +{% block content %} +
+
+

Trocas

+

Pedir troca

+

Uma troca é entre duas equipas da mesma escala: cada uma fica com o turno da outra.

+
+ +
+ +
+{% for group in groups %} +
+
+

{{ group.schedule.name }}

+ {{ month_name }} {{ year }} +
+
+ + + + +
+
+
+{% else %} +
Não há turnos disponíveis para {{ month_name|lower }} de {{ year }}.
+{% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/apps/shifts/backend/app/templates/swaps.html b/apps/shifts/backend/app/templates/swaps.html new file mode 100644 index 0000000..9ca6b9b --- /dev/null +++ b/apps/shifts/backend/app/templates/swaps.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block content %} +
+

Pedidos de troca

+
+ +
+{% for swap in swaps %} +
+
+
+
{{ swap.assignment.date|dia_longo }}
+

{{ swap.assignment.schedule.name }}

+

{{ swap.requester.name }} passa o turno de {{ swap.assignment.date|dia }} + {%- if swap.target_assignment %} e fica com o de {{ swap.target_assignment.date|dia }}{% endif %} + {%- if swap.target_team %} ({{ swap.target_team.name }}){% endif %}.

+ {% if swap.message %}

{{ swap.message }}

{% endif %} + {% if swap.accepted_by %}

Aceite por {{ swap.accepted_by.name }}.

{% endif %} +
+ {{ swap.status|estado }} +
+ +
+ {% set eligible = swap.requester_id not in my_team_ids and (swap.target_team_id is none or swap.target_team_id in my_team_ids) %} + {% set can_accept = swap.status == 'open' and eligible %} + {% set can_decline = swap.status in ['open', 'pending_approval'] and (current_user.is_admin or (swap.status == 'open' and eligible)) %} + {% if can_accept %} +
+ + +
+ {% endif %} + {% if can_decline %} + {% set decline_action = '/swaps/' ~ swap.id ~ ('/admin-reject' if current_user.is_admin else '/reject') %} +
+ + +
+ {% endif %} + {% if swap.requester_id in my_team_ids and swap.status in ['open', 'pending_approval'] %} +
+ + +
+ {% endif %} + {% if current_user.is_admin and swap.status == 'approved' and swap.target_assignment %} +
+ + +
+ {% endif %} + {% if current_user.is_admin and swap.status in ['open', 'pending_approval'] %} +
+ + {% if swap.accepted_by %} + + {% elif swap.target_team %} + {# Nobody accepted yet: approve it exactly as it was requested. #} + + {% else %} + + + {% endif %} +
+ {% endif %} +
+
+{% else %} +
Ainda não há pedidos de troca.
+{% endfor %} +
+{% endblock %} diff --git a/apps/shifts/backend/app/web.py b/apps/shifts/backend/app/web.py new file mode 100644 index 0000000..f355eca --- /dev/null +++ b/apps/shifts/backend/app/web.py @@ -0,0 +1,2456 @@ +from __future__ import annotations + +import calendar +from collections.abc import Sequence +import secrets +from datetime import date, datetime, time, timedelta +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session, selectinload + +from .calendar import build_feed +from .config import settings +from .database import get_db +from .models import ( + Assignment, + User, + MonthlyPattern, + NotificationLog, + RotationMember, + Schedule, + SwapRequest, + Team, +) +from .i18n import ( + MONTHS, + day_numeric, + day_numeric_long, + day_short, + month_name, + weekday_names, + window_label, +) +from .notifications import send_email_notification, send_many +from .pdf import build_schedule_pdf +from .scheduling import ( + assignments_by_date, + ensure_month_assignments, + month_bounds, + pattern_teams, + rotation_team_for_date, +) +from .security import hash_password, new_csrf_token, verify_password + +router = APIRouter() + + +def _redirect(path: str, status_code: int = 303) -> RedirectResponse: + return RedirectResponse(settings.url(path), status_code=status_code) + + +def _parse_time(value: str) -> time: + try: + return time.fromisoformat(value.strip()) + except ValueError: + raise HTTPException(status_code=400, detail="Hora inválida") + + +def _current_user(request: Request, db: Session) -> User | None: + """The user the session belongs to. One user can be assigned to several teams.""" + user_id = request.session.get("user_id") + if not user_id: + return None + return db.get(User, int(user_id)) + + +def _require_user(request: Request, db: Session) -> User: + user = _current_user(request, db) + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="É necessário iniciar sessão") + return user + + +def _require_admin(request: Request, db: Session) -> User: + user = _require_user(request, db) + if not user.is_admin: + raise HTTPException(status_code=403, detail="É necessária a permissão de administrador") + return user + + +def _user_teams(db: Session, user: User | None) -> list[Team]: + """The teams assigned to this user. A user sees and controls the rota of all of them.""" + if user is None: + return [] + return list( + db.scalars(select(Team).where(Team.user_id == user.id).order_by(Team.name)).all() + ) + + +def _user_team_ids(db: Session, user: User | None) -> set[int]: + return {team.id for team in _user_teams(db, user)} + + +def _user_label(user: User) -> str: + """How a user is named in messages and e-mails.""" + return user.name or user.username + + +def _csrf(request: Request) -> str: + token = request.session.get("csrf") + if not token: + token = new_csrf_token() + request.session["csrf"] = token + return token + + +def _check_csrf(request: Request, token: str) -> None: + expected = request.session.get("csrf") + if not expected or token != expected: + raise HTTPException(status_code=400, detail="Token CSRF inválido") + + +def _flash(request: Request, message: str, level: str = "info") -> None: + request.session["flash"] = {"message": message, "level": level} + + +def _context(request: Request, db: Session, **extra): + user = _current_user(request, db) + teams = _user_teams(db, user) + return { + "request": request, + "settings": settings, + "current_user": user, + "current_teams": teams, + "my_team_ids": {team.id for team in teams}, + "csrf_token": _csrf(request), + "flash": request.session.pop("flash", None), + **extra, + } + + +def _parse_month(year: int | None, month: int | None) -> tuple[int, int]: + today = date.today() + y = year or today.year + m = month or today.month + if m < 1 or m > 12 or y < 2000 or y > 2100: + raise HTTPException(status_code=400, detail="Mês inválido") + return y, m + + +ALL_WEEKDAYS = frozenset(range(7)) + + +def _next_dates(weekdays: set[int], count: int = 6) -> list[str]: + today = date.today() + labels: list[str] = [] + day = today + while len(labels) < count: + if day.weekday() in weekdays: + labels.append(day_short(day)) + day += timedelta(days=1) + return labels + + +def _schedule_card(schedule: Schedule, db: Session | None = None) -> dict: + weekdays = schedule.weekday_set + fixed = schedule.schedule_type != "rotation" and weekdays == ALL_WEEKDAYS + if fixed: + kind_label = "Todos os dias · repete todos os meses" + weekday_label = "Todos os dias" + dates: list[str] = [] + else: + weekday_label = weekday_names(weekdays) + kind_label = f"Rotação · {weekday_label}" + dates = _next_dates(weekdays) + pattern: list[tuple[int, str]] = [] + if fixed and db is not None: + rows = db.scalars( + select(MonthlyPattern) + .where(MonthlyPattern.schedule_id == schedule.id) + .options(selectinload(MonthlyPattern.team)) + .order_by(MonthlyPattern.day_of_month) + ).all() + pattern = [ + (row.day_of_month, row.team.name) + for row in rows + if row.team is not None and row.team.is_active + ] + return { + "schedule": schedule, + "kind": "fixed" if fixed else "rotation", + "kind_label": kind_label, + "weekday_label": weekday_label, + "dates": dates, + "pattern": pattern, + "rotation_names": [member.team.name for member in schedule.rotation_members], + } + + +def _schedule_groups(schedules: Sequence[Schedule], db: Session | None = None) -> list[dict]: + cards = [_schedule_card(schedule, db) for schedule in schedules] + # Every rotation is independent: Saturday and Sunday lunch never share an order. + return sorted(cards, key=lambda card: (card["kind"] != "fixed", card["weekday_label"] != "Todos os dias")) + + +def _active_schedules(db: Session) -> list[Schedule]: + return list( + db.scalars( + select(Schedule) + .where(Schedule.is_active.is_(True)) + .options(selectinload(Schedule.rotation_members).selectinload(RotationMember.team)) + .order_by(Schedule.id) + ).all() + ) + + +def _all_schedules(db: Session) -> list[Schedule]: + return list( + db.scalars( + select(Schedule) + .options(selectinload(Schedule.rotation_members).selectinload(RotationMember.team)) + .order_by(Schedule.id) + ).all() + ) + + +def _get_schedule(db: Session, schedule_id: int) -> Schedule: + schedule = db.get(Schedule, schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail="Escala não encontrada") + return schedule + + +def _month_nav(year: int, month: int) -> tuple[tuple[int, int], tuple[int, int]]: + if month == 1: + prev = (year - 1, 12) + else: + prev = (year, month - 1) + if month == 12: + nxt = (year + 1, 1) + else: + nxt = (year, month + 1) + return prev, nxt + + +def _calendar_token(db: Session, team: Team) -> str: + """A private feed token for this team, created the first time it is needed.""" + if not team.calendar_token: + team.calendar_token = secrets.token_urlsafe(24) + db.commit() + return team.calendar_token + + +@router.get("/calendar/{token}.ics") +def calendar_feed( + token: str, + db: Session = Depends(get_db), +): + """Subscribe from any calendar app: one feed per team, plus a shared whole-rota feed.""" + team = db.scalar(select(Team).where(Team.calendar_token == token, Team.is_active.is_(True))) + shared = bool(settings.calendar_token) and token == settings.calendar_token + if team is None and not shared: + raise HTTPException(status_code=404, detail="Calendário não encontrado") + name = f"{settings.app_name} — {team.name}" if team else settings.app_name + return Response( + content=build_feed(db, team, name), + media_type="text/calendar; charset=utf-8", + headers={ + "Content-Disposition": 'inline; filename="rota.ics"', + "Cache-Control": "private, max-age=300", + }, + ) + + +@router.get("/account", response_class=HTMLResponse) +def account_page(request: Request, db: Session = Depends(get_db)): + """The password editor lives on its own page, reached by clicking the user name.""" + _require_user(request, db) + return request.app.state.templates.TemplateResponse( + request=request, + name="account.html", + context=_context(request, db), + ) + + +@router.post("/account") +def account_password( + request: Request, + new_password: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """A user changes its own password from `/account`: one field, no ceremony.""" + _check_csrf(request, csrf_token) + user = _require_user(request, db) + if verify_password(new_password, user.password_hash): + _flash(request, "A nova palavra-passe é igual à atual.", "error") + return _redirect(_safe_back(request, "/")) + try: + user.password_hash = hash_password(new_password) + except ValueError as exc: + _flash(request, str(exc), "error") + return _redirect(_safe_back(request, "/")) + db.commit() + _flash(request, "Palavra-passe alterada.") + return _redirect(_safe_back(request, "/")) + + +@router.get("/calendar", response_class=HTMLResponse) +def calendar_page(request: Request, db: Session = Depends(get_db)): + """The links to subscribe, kept off the rota so that page stays about the shifts.""" + user = _require_user(request, db) + feeds = [ + { + "title": f"Turnos de {team.name}", + "url": f"{request.base_url.scheme}://{request.base_url.netloc}" + f"{settings.url(f'/calendar/{_calendar_token(db, team)}.ics')}", + } + for team in _user_teams(db, user) + if team.is_active + ] + if user.is_admin: + feeds.append( + { + "title": "Rota completa", + "url": _shared_calendar_url(request), + } + ) + for feed in feeds: + feed["webcal_url"] = "webcal://" + feed["url"].split("://", 1)[-1] + feed["google_url"] = "https://calendar.google.com/calendar/render?" + urlencode( + {"cid": feed["url"]} + ) + feed["outlook_url"] = "https://outlook.live.com/calendar/0/addfromweb?" + urlencode( + {"url": feed["url"], "name": feed["title"]} + ) + return request.app.state.templates.TemplateResponse( + request=request, + name="calendar.html", + context=_context(request, db, feeds=feeds), + ) + + +@router.get("/export/schedule", response_class=HTMLResponse) +def export_schedule( + request: Request, + schedule_id: int | None = None, + db: Session = Depends(get_db), +): + """Choose a schedule and exact dates before downloading its PDF.""" + user = _require_user(request, db) + schedules = _active_schedules(db) + if not schedules: + raise HTTPException(status_code=404, detail="Não há escalas ativas") + if schedule_id is None: + schedule = schedules[0] + else: + schedule = _get_schedule(db, schedule_id) + if not schedule.is_active: + raise HTTPException(status_code=404, detail="Escala não encontrada") + + if user.is_admin: + return _redirect(f"/admin/schedules?schedule_id={schedule.id}#export-pdf") + + today = date.today() + start, end = month_bounds(today.year, today.month) + return request.app.state.templates.TemplateResponse( + request=request, + name="export_schedule.html", + context=_context( + request, + db, + schedules=schedules, + selected_schedule_id=schedule.id, + start=start.isoformat(), + end=end.isoformat(), + ), + ) + + +@router.get("/export/schedule.pdf") +def download_schedule_pdf( + request: Request, + schedule_id: int, + start_date: date, + end_date: date, + db: Session = Depends(get_db), +): + user = _require_user(request, db) + schedule = _get_schedule(db, schedule_id) + if not schedule.is_active: + raise HTTPException(status_code=404, detail="Escala não encontrada") + error = None + if not (2000 <= start_date.year <= 2100 and 2000 <= end_date.year <= 2100): + error = "Escolhe datas entre 2000 e 2100." + elif end_date < start_date: + error = "A data final deve ser igual ou posterior à data inicial." + elif (end_date.year - start_date.year) * 12 + end_date.month - start_date.month >= 12: + error = "Escolhe um intervalo de no máximo 12 meses." + if error: + if user.is_admin: + return _render_admin_schedules( + request, db, schedule.id, start_date, end_date, error, status_code=400, + ) + return request.app.state.templates.TemplateResponse( + request=request, + name="export_schedule.html", + status_code=400, + context=_context( + request, + db, + schedules=_active_schedules(db), + selected_schedule_id=schedule.id, + start=start_date.isoformat(), + end=end_date.isoformat(), + error=error, + ), + ) + + # Generate chronologically, keeping existing manual and swapped assignments. + first = start_date.year * 12 + start_date.month - 1 + last = end_date.year * 12 + end_date.month - 1 + assignments: list[Assignment] = [] + for index in range(first, last + 1): + year, month = divmod(index, 12) + assignments.extend( + row for row in ensure_month_assignments(db, year, month + 1) + if row.schedule_id == schedule.id and start_date <= row.date <= end_date + ) + filename = f"escala-{schedule.id}-{start_date.isoformat()}-{end_date.isoformat()}.pdf" + return Response( + content=build_schedule_pdf(schedule, assignments, start_date, end_date), + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "private, no-store", + }, + ) + + +@router.get("/health") +def health(): + return {"status": "ok"} + + +@router.get("/login", response_class=HTMLResponse) +def login_page(request: Request, db: Session = Depends(get_db)): + if _current_user(request, db): + return _redirect("/") + return request.app.state.templates.TemplateResponse( + request=request, + name="login.html", + context=_context(request, db), + ) + + +@router.post("/login") +def login( + request: Request, + username: str = Form(...), + password: str = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + user = db.scalar( + select(User).where(func.lower(User.username) == username.strip().lower()) + ) + if not user or not user.is_active or not verify_password(password, user.password_hash): + _flash(request, "Nome de utilizador ou palavra-passe inválidos.", "error") + return _redirect("/login") + request.session["user_id"] = user.id + teams = _user_teams(db, user) + who = ", ".join(team.name for team in teams) or _user_label(user) + _flash(request, f"Bem-vindo(a), {who}.") + return _redirect("/") + + +@router.post("/logout") +def logout(request: Request, csrf_token: str = Form(...)): + _check_csrf(request, csrf_token) + request.session.clear() + return _redirect("/login") + + +@router.get("/", response_class=HTMLResponse) +def dashboard( + request: Request, + year: int | None = None, + month: int | None = None, + db: Session = Depends(get_db), +): + user = _current_user(request, db) + if user and not user.is_active: + user = None + my_ids = _user_team_ids(db, user) + year, month = _parse_month(year, month) + assignments = ensure_month_assignments(db, year, month) + grouped = assignments_by_date(assignments) + prev, nxt = _month_nav(year, month) + + my_assignment_ids = {a.id for a in assignments if a.team_id in my_ids} + open_swap_assignment_ids = set() + admin_requests: dict[int, SwapRequest] = {} + if my_ids: + open_swap_assignment_ids = set( + db.scalars( + select(SwapRequest.assignment_id).where( + SwapRequest.requester_id.in_(my_ids), + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ).all() + ) + if user and user.is_admin: + # Administrators can settle any request on this month's rota. + admin_requests = { + request.assignment_id: request + for request in db.scalars( + select(SwapRequest) + .where( + SwapRequest.assignment_id.in_([a.id for a in assignments]), + SwapRequest.status.in_(["open", "pending_approval"]), + ) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + selectinload(SwapRequest.accepted_by), + ) + .order_by(SwapRequest.created_at) + ).all() + } + + today = date.today() + return request.app.state.templates.TemplateResponse( + request=request, + name="dashboard.html", + context=_context( + request, + db, + year=year, + month=month, + month_name=month_name(month), + month_days=[(date(year, month, d), grouped.get(date(year, month, d), [])) for d in range(1, calendar.monthrange(year, month)[1] + 1)], + grouped=grouped, + my_assignment_ids=my_assignment_ids, + open_swap_assignment_ids=open_swap_assignment_ids, + admin_requests=admin_requests, + prev=prev, + nxt=nxt, + schedule_teams=_schedule_teams_map( + db, {request.assignment.schedule_id for request in admin_requests.values()} + ), + today=today, + ), + ) + + +@router.get("/swaps", response_class=HTMLResponse) +def swaps_page(request: Request, db: Session = Depends(get_db)): + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + query = ( + select(SwapRequest) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + selectinload(SwapRequest.target_team), + selectinload(SwapRequest.accepted_by), + ) + .order_by(SwapRequest.created_at.desc()) + ) + if not user.is_admin: + # A user sees and answers everything that involves the teams assigned to it. + query = query.where( + (SwapRequest.requester_id.in_(my_ids)) + | (SwapRequest.target_team_id.in_(my_ids)) + | ((SwapRequest.target_team_id.is_(None)) & (SwapRequest.status == "open")) + | (SwapRequest.accepted_team_id.in_(my_ids)) + ) + swaps = db.scalars(query).all() + return request.app.state.templates.TemplateResponse( + request=request, + name="swaps.html", + context=_context( + request, + db, + swaps=swaps, + today=date.today(), + schedule_teams=_schedule_teams_map( + db, {swap.assignment.schedule_id for swap in swaps} + ), + ), + ) + + +def _schedule_team_ids(db: Session, schedule_id: int) -> set[int]: + """Everyone who works this schedule: its rotation, its pattern or one of its shifts. + + A swap is always one team for one team of the same schedule, so these are + the only teams who can be asked or chosen. + """ + ids: set[int] = set() + ids.update( + db.scalars( + select(Assignment.team_id).where( + Assignment.schedule_id == schedule_id, Assignment.team_id.is_not(None) + ) + ).all() + ) + ids.update( + db.scalars( + select(MonthlyPattern.team_id).where( + MonthlyPattern.schedule_id == schedule_id, MonthlyPattern.team_id.is_not(None) + ) + ).all() + ) + ids.update( + db.scalars(select(RotationMember.team_id).where(RotationMember.schedule_id == schedule_id)).all() + ) + return {i for i in ids if i is not None} + + +def _schedule_teams(db: Session, schedule_id: int) -> list[Team]: + ids = _schedule_team_ids(db, schedule_id) + if not ids: + return [] + return list( + db.scalars( + select(Team).where(Team.id.in_(ids), Team.is_active.is_(True)).order_by(Team.name) + ).all() + ) + + +def _schedule_teams_map(db: Session, schedule_ids: set[int]) -> dict[int, list[Team]]: + """Active teams per schedule, for the swap forms: a swap stays inside one schedule.""" + return {schedule_id: _schedule_teams(db, schedule_id) for schedule_id in schedule_ids} + + +def _active_request(db: Session, assignment_id: int) -> int | None: + return db.scalar( + select(SwapRequest.id).where( + SwapRequest.assignment_id == assignment_id, + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ) + + +def _swap_side( + db: Session, schedule: Schedule, assignment_id: int | None, *, exclude_team_id: int | None = None +) -> Assignment: + """One side of a swap: an assigned, future shift of the same schedule.""" + assignment = db.get(Assignment, assignment_id) if assignment_id else None + if not assignment: + raise HTTPException(status_code=400, detail="Escolha o turno da outra equipa") + if assignment.schedule_id != schedule.id: + raise HTTPException(status_code=400, detail="Só pode trocar com quem trabalha nesta escala") + if assignment.team_id is None or assignment.team_id == exclude_team_id: + raise HTTPException(status_code=400, detail="Escolha o turno de outra equipa") + if assignment.date < date.today(): + raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") + if _active_request(db, assignment.id): + raise HTTPException(status_code=400, detail="Esse turno já tem um pedido de troca") + return assignment + + +def _swap_partner(db: Session, schedule: Schedule, team_id: int | None) -> Team | None: + """The one team this swap is with: active and working the same schedule.""" + if not team_id: + raise HTTPException(status_code=400, detail="Escolha a equipa que fica com o turno") + if team_id not in _schedule_team_ids(db, schedule.id): + raise HTTPException( + status_code=400, detail="Só pode trocar com quem trabalha nesta escala" + ) + partner = db.get(Team, team_id) + if not partner or not partner.is_active: + raise HTTPException(status_code=400, detail="Equipa inválida para este turno") + return partner + + +@router.get("/swaps/new", response_class=HTMLResponse) +def new_swap_page( + request: Request, + year: int | None = None, + month: int | None = None, + db: Session = Depends(get_db), +): + """Its own page, so the rota keeps showing only who works each day.""" + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + year, month = _parse_month(year, month) + assignments = ensure_month_assignments(db, year, month) + taken = set( + db.scalars( + select(SwapRequest.assignment_id).where( + SwapRequest.status.in_(["open", "pending_approval"]) + ) + ).all() + ) + # One team can work several days, so the shift to offer is chosen here, + # and each schedule only offers the teams who work that same schedule. + names = {t.id: t.name for t in db.scalars(select(Team)).all()} + groups: list[dict] = [] + for schedule in _active_schedules(db): + mine = [ + a for a in assignments + if a.schedule_id == schedule.id + and a.team_id + and (a.team_id in my_ids or user.is_admin) + and a.date >= date.today() + and a.id not in taken + ] + theirs = [ + a for a in assignments + if a.schedule_id == schedule.id + and a.team_id + and a.team_id not in my_ids + and a.date >= date.today() + and a.id not in taken + ] + if not mine or not theirs: + continue + groups.append( + { + "schedule": schedule, + "shifts": [ + {"id": a.id, "label": f"{day_short(a.date)} · {names[a.team_id]}"} + for a in sorted(mine, key=lambda r: r.date) + ], + "wanted": [ + { + "id": a.id, + "label": f"{day_short(a.date)} · {names[a.team_id]}", + } + for a in sorted(theirs, key=lambda r: r.date) + ], + } + ) + prev, nxt = _month_nav(year, month) + return request.app.state.templates.TemplateResponse( + request=request, + name="swap_new.html", + context=_context( + request, + db, + year=year, + month=month, + month_name=month_name(month), + prev=prev, + nxt=nxt, + groups=groups, + ), + ) + + +@router.post("/swaps/new") +def create_swap_for_chosen_shift( + request: Request, + assignment_id: int = Form(...), + target_assignment_id: int = Form(...), + message: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + return _create_swap(assignment_id, request, target_assignment_id, message, csrf_token, db) + + +@router.post("/assignments/{assignment_id}/swap") +def create_swap( + assignment_id: int, + request: Request, + target_assignment_id: int = Form(...), + message: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + return _create_swap(assignment_id, request, target_assignment_id, message, csrf_token, db) + + +def _create_swap( + assignment_id: int, + request: Request, + target_assignment_id: int, + message: str, + csrf_token: str, + db: Session, +): + _check_csrf(request, csrf_token) + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + assignment = db.get(Assignment, assignment_id) + if not assignment: + raise HTTPException(status_code=404, detail="Turno não encontrado") + # An administrator may open a request for someone else, but the shift stays + # the requester's own so the ownership rule still applies on acceptance. + owner_id = assignment.team_id + if owner_id is None or (owner_id not in my_ids and not user.is_admin): + raise HTTPException(status_code=403, detail="Só pode oferecer o turno da sua equipa") + owner = db.get(Team, owner_id) + if assignment.date < date.today(): + raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") + if _active_request(db, assignment.id): + _flash(request, "Já existe um pedido de troca ativo para este turno.", "error") + return _redirect("/swaps") + + # A swap is one shift for one shift, both of the same schedule. + incoming = _swap_side(db, assignment.schedule, target_assignment_id, exclude_team_id=owner.id) + if incoming.id == assignment.id: + raise HTTPException(status_code=400, detail="Escolha outro turno para a troca") + target = db.get(Team, incoming.team_id) + + swap = SwapRequest( + assignment_id=assignment.id, + target_assignment_id=incoming.id, + requester_id=owner.id, + target_team_id=target.id, + status="open", + message=message.strip() or None, + ) + db.add(swap) + db.commit() + + subject = ( + f"Troca de turnos: {day_short(assignment.date)} por {day_short(incoming.date)} " + f"– {assignment.schedule.name}" + ) + body = ( + f"{owner.name} propõe trocar o turno de {assignment.schedule.name} de " + f"{day_numeric_long(assignment.date)} pelo seu turno de {day_numeric_long(incoming.date)}.\n\n" + f"{message.strip()}\n\n" + f"Abrir a escala: {settings.normalized_base_url}/swaps" + ) + send_email_notification(db, target, "swap_requested", subject, body) + + _flash(request, "Pedido de troca criado.") + return _redirect("/swaps") + + +def _safe_back(request: Request, fallback: str) -> str: + """Return to the rota when the action was started there, never to an arbitrary URL.""" + back = str(request.query_params.get("back") or request.headers.get("referer") or "") + if back.startswith("/") and not back.startswith("//"): + return back.split("#")[0] + return fallback + + +def _reject_swap(db: Session, swap: SwapRequest) -> None: + swap.status = "rejected" + db.commit() + + +def _rejectable(user: User, my_ids: set[int], swap: SwapRequest) -> bool: + """Anyone eligible may decline an open request; admins can also drop a pending one.""" + if user.is_admin: + return True + if swap.status != "open" or swap.requester_id in my_ids: + return False + return swap.target_team_id is None or swap.target_team_id in my_ids + + +def _swap_ownership_holds(db: Session, swap: SwapRequest) -> bool: + """Both teams must still own their own shift, otherwise the swap is void.""" + if swap.assignment.team_id != swap.requester_id: + return False + if swap.target_assignment_id is not None and swap.target_assignment is not None: + if swap.target_assignment.team_id != swap.target_team_id: + return False + return True + + +def _complete_swap(db: Session, swap: SwapRequest, new_team: Team) -> None: + """Exchange both shifts and close the request. Ownership must be checked by the caller.""" + swap.assignment.team_id = new_team.id + swap.assignment.source = "swap" + incoming = swap.target_assignment + if incoming is not None: + incoming.team_id = swap.requester_id + incoming.source = "swap" + swap.accepted_team_id = new_team.id + swap.status = "approved" + for other in db.scalars( + select(SwapRequest).where( + SwapRequest.id != swap.id, + SwapRequest.status.in_(["open", "pending_approval"]), + ( + (SwapRequest.assignment_id == swap.assignment_id) + | (SwapRequest.target_assignment_id == swap.assignment_id) + | ( + (SwapRequest.assignment_id.is_not(None)) + & (SwapRequest.assignment_id == swap.target_assignment_id) + ) + | ( + (SwapRequest.target_assignment_id.is_not(None)) + & (SwapRequest.target_assignment_id == swap.target_assignment_id) + ) + ), + ) + ).all(): + other.status = "cancelled" + db.commit() + + +def _accepting_team(db: Session, user: User, swap: SwapRequest) -> Team: + """Which team takes the shift: the one it was asked of, or the first active one + assigned to this user. The user answers as a whole; the rota records one team.""" + if swap.target_team_id: + target = db.get(Team, swap.target_team_id) + if target is not None and target.user_id == user.id and target.is_active: + return target + for team in _user_teams(db, user): + if team.is_active: + return team + raise HTTPException(status_code=400, detail="Este utilizador não tem nenhuma equipa ativa para o turno") + + +def _admin_teams(db: Session) -> list[Team]: + """Every active team whose user is an administrator.""" + return list( + db.scalars( + select(Team) + .join(User, Team.user_id == User.id) + .where( + User.is_admin.is_(True), + User.is_active.is_(True), + Team.is_active.is_(True), + ) + .order_by(Team.name) + ).all() + ) + + +@router.post("/swaps/{swap_id}/accept") +def accept_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + ) + ) + if not swap or swap.status != "open": + raise HTTPException(status_code=400, detail="Este pedido já não está aberto") + if swap.requester_id in my_ids: + raise HTTPException(status_code=400, detail="Não pode aceitar o seu próprio pedido") + if swap.target_team_id is not None and swap.target_team_id not in my_ids: + raise HTTPException(status_code=403, detail="Este pedido é para outra equipa") + if not _swap_ownership_holds(db, swap): + swap.status = "cancelled" + db.commit() + raise HTTPException(status_code=409, detail="O turno já foi alterado") + + team = _accepting_team(db, user, swap) + swap.accepted_team_id = team.id + if swap.assignment.schedule.requires_manager_approval: + swap.status = "pending_approval" + db.commit() + admins = _admin_teams(db) + send_many( + db, + admins, + "swap_needs_approval", + "Uma troca de turno precisa de aprovação", + f"{team.name} aceitou a troca de {swap.assignment.schedule.name} de " + f"{day_numeric_long(swap.assignment.date)}" + + ( + f" por {day_numeric_long(swap.target_assignment.date)}" + if swap.target_assignment is not None + else "" + ) + + f", pedida por {swap.requester.name}. Aprove em {settings.normalized_base_url}/swaps", + ) + send_email_notification( + db, + swap.requester, + "swap_accepted", + "A sua troca de turno foi aceite", + f"{team.name} aceitou o seu pedido. Está agora à espera da aprovação da gestão.", + ) + _flash(request, "Aceite. A aguardar aprovação da gestão.") + else: + _complete_swap(db, swap, team) + incoming = swap.target_assignment + for recipient, other in ((swap.requester, team), (team, swap.requester)): + send_email_notification( + db, + recipient, + "swap_approved", + "Troca de turno confirmada", + f"Troca concluída em {swap.assignment.schedule.name}: fica com o turno de " + f"{day_numeric_long(swap.assignment.date)}" + + ( + f" e passa o de {day_numeric_long(incoming.date)} para {other.name}." + if incoming is not None + else "." + ), + ) + _flash(request, "Troca concluída.") + return _redirect("/swaps") + + +@router.post("/swaps/{swap_id}/reject") +def reject_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options(selectinload(SwapRequest.requester)) + ) + if not swap or swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=400, detail="Este pedido já não está aberto") + if not _rejectable(user, my_ids, swap): + raise HTTPException(status_code=403, detail="Não pode recusar este pedido") + who = _user_label(user) + _reject_swap(db, swap) + send_email_notification( + db, + swap.requester, + "swap_rejected", + "Pedido de troca recusado", + f"{who} recusou o seu pedido de troca.", + ) + _flash(request, "Pedido recusado.") + return _redirect(_safe_back(request, "/swaps")) + + +@router.post("/swaps/{swap_id}/cancel") +def cancel_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + swap = db.get(SwapRequest, swap_id) + if not swap or swap.requester_id not in my_ids or swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=403, detail="Não é possível cancelar este pedido") + swap.status = "cancelled" + db.commit() + _flash(request, "Pedido cancelado.") + return _redirect("/swaps") + + +@router.post("/swaps/{swap_id}/approve") +def approve_swap( + swap_id: int, + request: Request, + team_id: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + selectinload(SwapRequest.accepted_by), + ) + ) + if not swap or swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=400, detail="Este pedido já não pode ser aprovado") + if not _swap_ownership_holds(db, swap): + swap.status = "cancelled" + db.commit() + raise HTTPException(status_code=409, detail="O turno já foi alterado") + if swap.assignment.date < date.today(): + raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") + + if swap.accepted_by and swap.accepted_by.is_active: + new_team = swap.accepted_by + elif swap.target_team_id and db.get(Team, swap.target_team_id).is_active: + new_team = db.get(Team, swap.target_team_id) + else: + new_team = _swap_partner(db, swap.assignment.schedule, int(team_id) if team_id.strip() else None) + if new_team.id == swap.requester_id: + raise HTTPException(status_code=400, detail="Escolha outra equipa para a troca") + + _complete_swap(db, swap, new_team) + incoming = swap.target_assignment + subject = "Troca de turno aprovada" + body = ( + f"{_user_label(admin)} aprovou a troca de {swap.assignment.schedule.name}: " + f"{new_team.name} fica com o turno de {day_numeric_long(swap.assignment.date)}" + + ( + f" e {swap.requester.name} com o de {day_numeric_long(incoming.date)}." + if incoming is not None + else "." + ) + ) + send_many(db, [swap.requester, new_team], "swap_approved", subject, body) + _flash(request, "Troca aprovada e escala atualizada.") + return _redirect(_safe_back(request, "/swaps")) + + +@router.post("/swaps/{swap_id}/revert") +def revert_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """Administrator only: undo an approved swap and give both shifts back.""" + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment), + selectinload(SwapRequest.requester), + ) + ) + if not swap or swap.target_assignment_id is None: + raise HTTPException(status_code=404, detail="Troca não encontrada") + if swap.status != "approved": + raise HTTPException(status_code=400, detail="Só uma troca aprovada pode ser revertida") + # The shifts must still be the ones the swap produced, or somebody edited them since. + if swap.assignment.team_id != swap.accepted_team_id or ( + swap.target_assignment is not None + and swap.target_assignment.team_id != swap.requester_id + ): + raise HTTPException( + status_code=409, detail="O turno foi alterado depois da troca; reverta com a atribuição por dia" + ) + + incoming = swap.target_assignment + note = f"Revertida a troca #{swap.id} por {_user_label(admin)}" + swap.assignment.team_id = swap.requester_id + swap.assignment.source = "manual" + swap.assignment.note = note + incoming.team_id = swap.target_team_id + incoming.source = "manual" + incoming.note = note + swap.status = "reverted" + db.commit() + + subject = "Troca de turno revertida" + body = ( + f"{_user_label(admin)} reverteu a troca de {swap.assignment.schedule.name}. " + f"{swap.requester.name} volta a {day_numeric_long(swap.assignment.date)} e " + f"{swap.target_team.name} volta a {day_numeric_long(incoming.date)}.\n\n" + f"Ver a escala: {settings.normalized_base_url}/" + ) + send_many(db, [swap.requester, swap.target_team], "swap_reverted", subject, body) + _flash(request, "Troca revertida: os dois turnos voltaram a quem os tinha.") + return _redirect(_safe_back(request, "/swaps")) + + +@router.post("/swaps/{swap_id}/admin-reject") +def admin_reject_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options(selectinload(SwapRequest.requester), selectinload(SwapRequest.accepted_by)) + ) + if not swap or swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=400, detail="Este pedido não pode ser recusado") + _reject_swap(db, swap) + involved = [t for t in [swap.requester, swap.accepted_by] if t] + send_many(db, involved, "swap_rejected", "Troca de turno não aprovada", + f"{_user_label(admin)} não aprovou a troca de turno pedida.") + _flash(request, "Troca recusada.") + return _redirect(_safe_back(request, "/swaps")) + + +@router.get("/admin/teams", response_class=HTMLResponse) +def admin_teams(request: Request, db: Session = Depends(get_db)): + """Who works the rota. Users are not created here: a team only picks who signs in for it.""" + admin = _require_admin(request, db) + teams = db.scalars( + select(Team) + .options(selectinload(Team.user).selectinload(User.teams)) + .order_by(Team.name) + ).all() + removable_ids = {team.id for team in teams if _team_removal_blocker(db, admin, team) is None} + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_teams.html", + context=_context( + request, + db, + teams=teams, + removable_ids=removable_ids, + users_by_id=_users_by_id(db), + ), + ) + + +def _users_by_id(db: Session) -> dict[int, User]: + return {user.id: user for user in db.scalars(select(User).order_by(User.username)).all()} + + +@router.post("/admin/teams") +def admin_create_team( + request: Request, + name: str = Form(...), + email: str = Form(""), + phone: str = Form(""), + assigned_user_id: str = Form(""), + notify_email: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """Add a team to the rota. The user who signs in for it is chosen here or later.""" + _check_csrf(request, csrf_token) + _require_admin(request, db) + email_value = email.strip().lower() or None + if email_value and db.scalar(select(Team.id).where(Team.email == email_value)): + _flash(request, "Esse endereço de e-mail já existe.", "error") + return _redirect("/admin/teams") + db.add( + Team( + name=name.strip(), + user=_chosen_user(db, assigned_user_id), + email=email_value, + phone=phone.strip() or None, + is_active=True, + notify_email=bool(notify_email), + ) + ) + db.commit() + _flash(request, "Equipa criada.") + return _redirect("/admin/teams") + + +def _chosen_user(db: Session, raw: str) -> User | None: + """The user picked in a form, or None for "nobody signs in for this team".""" + value = raw.strip() + if not value.isdigit(): + return None + user = db.get(User, int(value)) + if user is None: + raise HTTPException(status_code=400, detail="Utilizador inválido") + return user + + +@router.post("/admin/teams/{team_id}/update") +def admin_update_team( + team_id: int, + request: Request, + name: str = Form(...), + email: str = Form(""), + phone: str = Form(""), + assigned_user_id: str = Form(""), + is_active: str = Form(""), + notify_email: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + admin = _require_admin(request, db) + team = db.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Equipa não encontrada") + email_value = email.strip().lower() or None + duplicate = ( + db.scalar(select(Team.id).where(Team.email == email_value, Team.id != team.id)) + if email_value + else None + ) + if duplicate: + _flash(request, "Esse endereço de e-mail já está a ser usado.", "error") + return _redirect("/admin/teams") + + # Whoever is signed in must keep an active team to sign in with, not necessarily this one. + chosen = _chosen_user(db, assigned_user_id) + stays_active = bool(is_active) + if _loses_last_team(db, admin, team, chosen, stays_active): + _flash(request, "Não pode desativar a última equipa do utilizador com que está ligado.", "error") + return _redirect("/admin/teams") + + team.name = name.strip() + team.user = chosen + team.email = email_value + team.phone = phone.strip() or None + team.notify_email = bool(notify_email) + team.is_active = stays_active + db.commit() + _flash(request, "Equipa atualizada.") + return _redirect("/admin/teams") + + +def _loses_last_team( + db: Session, user: User, team: Team, chosen: User | None, stays_active: bool +) -> bool: + """True when this edit would leave the user without any active team to sign in with.""" + still_mine = chosen is not None and chosen.id == user.id and stays_active + if still_mine: + return False + return not db.scalar( + select(Team.id).where( + Team.user_id == user.id, Team.id != team.id, Team.is_active.is_(True) + ) + ) + + +def _team_removal_impact(db: Session, team: Team) -> dict[str, int]: + return { + "assignments": db.scalar( + select(func.count(Assignment.id)).where(Assignment.team_id == team.id) + ) or 0, # must be zero for the removal to be allowed + "rotation": db.scalar( + select(func.count(RotationMember.id)).where(RotationMember.team_id == team.id) + ) or 0, + "pattern_days": db.scalar( + select(func.count(MonthlyPattern.id)).where(MonthlyPattern.team_id == team.id) + ) or 0, + "requests": db.scalar( + select(func.count(SwapRequest.id)).where( + SwapRequest.status.in_(["open", "pending_approval"]), + ( + (SwapRequest.requester_id == team.id) + | (SwapRequest.target_team_id == team.id) + | (SwapRequest.accepted_team_id == team.id) + ), + ) + ) or 0, + } + + +def _usable_admin_users(db: Session, exclude: User | None = None) -> list[User]: + """Administrator users that can still sign in: active, and covering an active team.""" + users = db.scalars( + select(User) + .where(User.is_admin.is_(True), User.is_active.is_(True)) + .options(selectinload(User.teams)) + ).all() + return [ + user + for user in users + if user.id != (exclude.id if exclude else None) + and any(team.is_active for team in user.teams) + ] + + +def _team_removal_blocker(db: Session, admin: User, team: Team) -> str | None: + """Why this team cannot be removed, or None when the removal is allowed.""" + user = team.user + if user is not None and user.id == admin.id and not db.scalar( + select(Team.id).where(Team.user_id == user.id, Team.id != team.id) + ): + return "Não pode remover a si próprio(a)" + if user is not None and user.is_admin and not _usable_admin_users(db, exclude=user): + return "Não pode remover a equipa do último administrador" + if db.scalar(select(Assignment.id).where(Assignment.team_id == team.id)): + return "Não pode remover uma equipa que ainda tem turnos atribuídos" + return None + + +def _require_removable(db: Session, admin: User, team: Team) -> None: + reason = _team_removal_blocker(db, admin, team) + if reason: + raise HTTPException(status_code=400, detail=reason) + + +@router.get("/admin/teams/{team_id}/delete", response_class=HTMLResponse) +def admin_team_delete_page( + team_id: int, + request: Request, + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + team = db.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Equipa não encontrada") + _require_removable(db, admin, team) + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_team_delete.html", + context=_context(request, db, team=team, impact=_team_removal_impact(db, team)), + ) + + +@router.post("/admin/teams/{team_id}/delete") +def admin_team_delete( + team_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + team = db.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Equipa não encontrada") + _require_removable(db, admin, team) + + # No assignment can survive here: _require_removable refuses while the team holds one. + for swap in db.scalars( + select(SwapRequest).where(SwapRequest.requester_id == team.id) + ).all(): + db.delete(swap) + for swap in db.scalars( + select(SwapRequest).where( + (SwapRequest.target_team_id == team.id) | (SwapRequest.accepted_team_id == team.id) + ) + ).all(): + if swap.target_team_id == team.id: + swap.target_team_id = None + if swap.accepted_team_id == team.id: + swap.accepted_team_id = None + for pattern in db.scalars( + select(MonthlyPattern).where(MonthlyPattern.team_id == team.id) + ).all(): + pattern.team_id = None + for log in db.scalars( + select(NotificationLog).where(NotificationLog.team_id == team.id) + ).all(): + log.team_id = None + db.execute(delete(RotationMember).where(RotationMember.team_id == team.id)) + db.delete(team) + db.commit() + return _redirect("/admin/teams") + + +@router.get("/admin/users", response_class=HTMLResponse) +def admin_users(request: Request, db: Session = Depends(get_db)): + """The users: created here, then assigned to the teams they sign in for.""" + admin = _require_admin(request, db) + users = db.scalars( + select(User).options(selectinload(User.teams)).order_by(User.username) + ).all() + removable_ids = {user.id for user in users if _user_removal_blocker(db, admin, user) is None} + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_users.html", + context=_context( + request, + db, + users=users, + removable_ids=removable_ids, + teams=db.scalars(select(Team).order_by(Team.name)).all(), + ), + ) + + +def _user_removal_blocker(db: Session, admin: User, user: User) -> str | None: + """Why this user cannot be removed, or None when the removal is allowed.""" + teams = db.scalars(select(Team).where(Team.user_id == user.id).order_by(Team.name)).all() + if teams: + return f"Está atribuído a {len(teams)} equipa(s)" + if user.id == admin.id: + return "Não pode remover o seu próprio acesso" + if user.is_admin and not _usable_admin_users(db, exclude=user): + return "Não pode remover o último administrador" + return None + + +@router.post("/admin/users") +def admin_create_user( + request: Request, + name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + team_ids: list[str] = Form([]), + is_admin: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """A new user, assigned to the teams it will sign in for.""" + _check_csrf(request, csrf_token) + _require_admin(request, db) + user, error = _new_user(db, name, username, password, is_admin=bool(is_admin)) + if error: + _flash(request, error, "error") + return _redirect("/admin/users") + db.add(user) + db.commit() + _assign_user_to_teams(db, user, team_ids) + db.commit() + _flash(request, "Utilizador criado.") + return _redirect("/admin/users") + + +def _new_user( + db: Session, name: str, username: str, password: str, *, is_admin: bool +) -> tuple[User | None, str | None]: + """A user with a username and a password, unsaved, or the reason it was refused.""" + value = username.strip().lower() + if not value: + return None, "Defina um nome de utilizador." + if db.scalar(select(User.id).where(User.username == value)): + return None, "Esse nome de utilizador já existe." + try: + password_hash = hash_password(password) + except ValueError as exc: + return None, str(exc) + return ( + User( + name=name.strip() or value, + username=value, + password_hash=password_hash, + is_admin=is_admin, + is_active=True, + ), + None, + ) + + +def _assign_user_to_teams(db: Session, user: User, team_ids: Sequence[str]) -> None: + """Attach this user to exactly the teams the form selected.""" + chosen = {int(value) for value in team_ids if str(value).isdigit()} + for team in db.scalars(select(Team)).all(): + if (team.id in chosen) != (team.user_id == user.id): + team.user = user if team.id in chosen else None + + +@router.post("/admin/users/{user_id}/update") +def admin_update_user( + user_id: int, + request: Request, + name: str = Form(...), + username: str = Form(...), + password: str = Form(""), + team_ids: list[str] = Form([]), + is_admin: str = Form(""), + is_active: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + admin = _require_admin(request, db) + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="Utilizador não encontrado") + username_value = username.strip().lower() + if not username_value: + _flash(request, "O nome de utilizador não pode ficar vazio.", "error") + return _redirect("/admin/users") + duplicate = db.scalar( + select(User.id).where(User.username == username_value, User.id != user.id) + ) + if duplicate: + _flash(request, "Esse nome de utilizador já está a ser usado.", "error") + return _redirect("/admin/users") + loses_admin = user.is_admin and not is_admin + loses_access = user.id == admin.id and not is_active + last_admin = loses_admin and not _usable_admin_users(db, exclude=user) + if last_admin: + _flash(request, "Não pode tirar a administração ao último utilizador que a tem.", "error") + return _redirect("/admin/users") + if loses_access: + _flash(request, "Não pode desativar o acesso com que está ligado.", "error") + return _redirect("/admin/users") + if password.strip(): + try: + user.password_hash = hash_password(password.strip()) + except ValueError as exc: + _flash(request, str(exc), "error") + return _redirect("/admin/users") + + user.name = name.strip() or username_value + user.username = username_value + user.is_admin = bool(is_admin) + user.is_active = True if user.id == admin.id else bool(is_active) + db.commit() + _assign_user_to_teams(db, user, team_ids) + db.commit() + _flash(request, "Utilizador atualizado.") + return _redirect("/admin/users") + + +@router.post("/admin/users/{user_id}/delete") +def admin_user_delete( + user_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + admin = _require_admin(request, db) + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="Utilizador não encontrado") + reason = _user_removal_blocker(db, admin, user) + if reason: + _flash(request, f"Não é possível remover este utilizador: {reason}.", "error") + return _redirect("/admin/users") + db.delete(user) + db.commit() + _flash(request, "Utilizador removido.") + return _redirect("/admin/users") + + +@router.get("/admin/schedules", response_class=HTMLResponse) +def admin_schedules( + request: Request, + schedule_id: int | None = None, + db: Session = Depends(get_db), +): + _require_admin(request, db) + return _render_admin_schedules(request, db, schedule_id) + + +def _render_admin_schedules( + request: Request, + db: Session, + schedule_id: int | None = None, + start: date | None = None, + end: date | None = None, + error: str | None = None, + status_code: int = 200, +): + cards = _schedule_groups(_all_schedules(db), db) + schedules = [card["schedule"] for card in cards if card["schedule"].is_active] + if schedule_id is not None: + selected = _get_schedule(db, schedule_id) + if not selected.is_active: + raise HTTPException(status_code=404, detail="Escala não encontrada") + else: + selected = schedules[0] if schedules else None + today = date.today() + first, last = month_bounds(today.year, today.month) + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_schedules.html", + status_code=status_code, + context=_context( + request, + db, + cards=cards, + calendar_feed_url=_shared_calendar_url(request), + schedules=schedules, + selected_schedule_id=selected.id if selected else None, + start=(start or first).isoformat(), + end=(end or last).isoformat(), + error=error, + ), + ) + + +def _shared_calendar_url(request: Request) -> str: + """The whole-rota feed, with a team token created the first time it is asked for.""" + if not settings.calendar_token: + settings.calendar_token = secrets.token_urlsafe(24) + path = settings.url(f"/calendar/{settings.calendar_token}.ics") + return f"{request.base_url.scheme}://{request.base_url.netloc}{path}" + + +@router.get("/admin/schedules/{schedule_id}", response_class=HTMLResponse) +def admin_schedule( + schedule_id: int, + request: Request, + year: int | None = None, + month: int | None = None, + db: Session = Depends(get_db), +): + _require_admin(request, db) + schedule = _get_schedule(db, schedule_id) + teams = db.scalars(select(Team).where(Team.is_active.is_(True)).order_by(Team.name)).all() + year, month = _parse_month(year, month) + assignments: list[Assignment] = [] + prev = nxt = (year, month) + if schedule.schedule_type == "rotation": + assignments = [ + row for row in ensure_month_assignments(db, year, month) if row.schedule_id == schedule.id + ] + prev, nxt = _month_nav(year, month) + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_schedule.html", + context=_context( + request, + db, + card=_schedule_card(schedule, db), + cards=_schedule_groups(_all_schedules(db), db), + teams=teams, + schedule_teams=_schedule_teams(db, schedule.id), + assignments=assignments, + swap_requests=_open_swap_requests(db, schedule.id), + pattern_days=list(range(1, 32)), + pattern=_pattern_map(db, schedule), + months=MONTHS, + year=year, + month=month, + month_name=month_name(month), + prev=prev, + nxt=nxt, + ), + ) + + +def _open_swap_requests(db: Session, schedule_id: int) -> list[SwapRequest]: + """Open and pending change requests for one schedule, oldest first.""" + return list( + db.scalars( + select(SwapRequest) + .where( + SwapRequest.assignment_id.in_( + select(Assignment.id).where(Assignment.schedule_id == schedule_id) + ), + SwapRequest.status.in_(["open", "pending_approval"]), + ) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + selectinload(SwapRequest.target_team), + selectinload(SwapRequest.accepted_by), + ) + .order_by(SwapRequest.created_at) + ).all() + ) + + +def _pattern_map(db: Session, schedule: Schedule) -> dict[int, int]: + rows = db.scalars( + select(MonthlyPattern).where(MonthlyPattern.schedule_id == schedule.id) + ).all() + return {row.day_of_month: row.team_id for row in rows if row.team_id is not None} + + +@router.post("/admin/schedules/{schedule_id}/pattern") +async def admin_schedule_pattern( + schedule_id: int, + request: Request, + db: Session = Depends(get_db), +): + _require_admin(request, db) + form = await request.form() + _check_csrf(request, str(form.get("csrf_token", ""))) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type == "rotation": + raise HTTPException(status_code=400, detail="As rotações usam a sua própria ordem, não um padrão mensal") + teams_by_id = {t.id for t in db.scalars(select(Team).where(Team.is_active.is_(True))).all()} + + existing = { + row.day_of_month: row + for row in db.scalars( + select(MonthlyPattern).where(MonthlyPattern.schedule_id == schedule.id) + ).all() + } + saved = 0 + for day in range(1, 32): + raw = str(form.get(f"day_{day}", "")).strip() + team_id = int(raw) if raw else None + if team_id is not None and team_id not in teams_by_id: + continue + row = existing.get(day) + if row is None: + if team_id is None: + continue + db.add(MonthlyPattern(schedule_id=schedule.id, day_of_month=day, team_id=team_id)) + saved += 1 + elif row.team_id != team_id: + row.team_id = team_id + saved += 1 + db.commit() + touched = _apply_pattern_to_open_rows(db, schedule) + _flash( + request, + f"Padrão de noites guardado ({saved} alteração(ões)). " + f"Foram atualizados {touched} turno(s) sem atribuição neste mês e no próximo; " + f"as noites já atribuídas foram mantidas.", + ) + return _redirect(f"/admin/schedules/{schedule.id}") + + +def _apply_pattern_to_open_rows(db: Session, schedule: Schedule) -> int: + """Fill empty generated nights from the pattern; never touch existing assignees.""" + patterns = _pattern_map(db, schedule) + if not patterns: + return 0 + today = date.today() + last_month = (today.month % 12) + 1 + last_year = today.year + (1 if today.month == 12 else 0) + rows = db.scalars( + select(Assignment).where( + Assignment.schedule_id == schedule.id, + Assignment.team_id.is_(None), + Assignment.source == "generated", + Assignment.date >= today.replace(day=1), + Assignment.date <= date(last_year, last_month, calendar.monthrange(last_year, last_month)[1]), + ) + ).all() + touched = 0 + for row in rows: + team_id = patterns.get(row.date.day) + if team_id and row.team_id != team_id: + row.team_id = team_id + touched += 1 + if touched: + db.commit() + return touched + + +def _apply_pattern_forward(db: Session, schedule: Schedule, first: date) -> int: + """Explicit admin edit: push the monthly pattern onto generated assignments from `first` on. + + Never creates or deletes rows, keeps assignment IDs, and preserves manual + assignments and completed swaps. A day with nobody in the pattern is left as + it is, so an empty pattern entry cannot blank out a shift. + """ + patterns = pattern_teams(db, schedule) + if not patterns: + return 0 + rows = db.scalars( + select(Assignment) + .where( + Assignment.schedule_id == schedule.id, + Assignment.date >= first, + Assignment.source == "generated", + ) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + .order_by(Assignment.date) + ).all() + changed: list[tuple[Assignment, Team | None, Team | None]] = [] + touched = 0 + for row in rows: + team_id = patterns.get(row.date.day) + if team_id is None or row.team_id == team_id: + continue + old_team = db.get(Team, row.team_id) if row.team_id is not None else None + new_team = db.get(Team, team_id) + changed.append((row, old_team, new_team)) + for swap in db.scalars( + select(SwapRequest).where( + SwapRequest.assignment_id == row.id, + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ).all(): + swap.status = "cancelled" + row.team_id = team_id + row.team = new_team + touched += 1 + if changed: + db.commit() + _notify_assignment_changes(db, changed) + return touched + + +@router.post("/admin/schedules/{schedule_id}/apply-pattern") +def admin_apply_pattern( + schedule_id: int, + request: Request, + year: int = Form(...), + month: int = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type != "fixed": + raise HTTPException(status_code=400, detail="Esta escala não usa um padrão mensal") + year, month = _parse_month(year, month) + first = date(year, month, 1) + touched = _apply_pattern_forward(db, schedule, first) + _flash( + request, + f"Padrão aplicado a partir de {day_numeric_long(first)}: {touched} turno(s) atualizado(s). " + f"As atribuições manuais e as trocas foram mantidas, e os dias sem equipa no padrão ficaram como estão.", + ) + return _redirect(f"/admin/schedules/{schedule.id}") + + +def _clear_assignments_from(db: Session, schedule: Schedule, first: date) -> int: + """Explicit admin edit: leave the shifts from `first` on without an assignee. + + Only `generated` rows are touched, so manual assignments and completed swaps + survive. Rows keep their IDs, notes and swap history and stay `generated`, so + applying the rotation again fills them. + """ + rows = db.scalars( + select(Assignment) + .where( + Assignment.schedule_id == schedule.id, + Assignment.date >= first, + Assignment.source == "generated", + Assignment.team_id.is_not(None), + ) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + .order_by(Assignment.date) + ).all() + changed: list[tuple[Assignment, Team | None, Team | None]] = [] + for row in rows: + old_team = db.get(Team, row.team_id) + changed.append((row, old_team, None)) + for swap in db.scalars( + select(SwapRequest).where( + SwapRequest.assignment_id == row.id, + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ).all(): + swap.status = "cancelled" + row.team_id = None + row.team = None + if changed: + db.commit() + _notify_assignment_changes(db, changed) + return len(changed) + + +@router.post("/admin/schedules/{schedule_id}/clear-from") +def admin_clear_from( + schedule_id: int, + request: Request, + year: int = Form(...), + month: int = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type != "rotation": + raise HTTPException(status_code=400, detail="Esta escala não usa rotação") + year, month = _parse_month(year, month) + first = date(year, month, 1) + cleared = _clear_assignments_from(db, schedule, first) + _flash( + request, + f"Turnos limpos a partir de {day_numeric_long(first)}: {cleared} turno(s) sem atribuição. " + f"As atribuições manuais e as trocas foram mantidas. Use Aplicar rotação para os preencher de novo.", + ) + return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") + + +@router.post("/admin/schedules/{schedule_id}/settings") +def admin_schedule_settings( + schedule_id: int, + request: Request, + requires_manager_approval: str = Form(""), + is_active: str = Form(""), + start_time: str = Form(""), + end_time: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + _require_admin(request, db) + schedule = db.get(Schedule, schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail="Escala não encontrada") + if start_time and end_time: + schedule.start_time = _parse_time(start_time) + schedule.end_time = _parse_time(end_time) + schedule.requires_manager_approval = bool(requires_manager_approval) + schedule.is_active = bool(is_active) + db.commit() + _flash(request, "Definições da escala guardadas.") + return _redirect(f"/admin/schedules/{schedule.id}") + + +@router.post("/admin/schedules/{schedule_id}/rotation") +def admin_rotation_add( + schedule_id: int, + request: Request, + team_id: int = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + _require_admin(request, db) + schedule = db.get(Schedule, schedule_id) + user = db.get(Team, team_id) + if not schedule or schedule.schedule_type != "rotation" or not user or not user.is_active: + raise HTTPException(status_code=400, detail="Membro da rotação inválido") + exists = db.scalar( + select(RotationMember.id).where( + RotationMember.schedule_id == schedule.id, + RotationMember.team_id == user.id, + ) + ) + if exists: + _flash(request, "Essa equipa já está nesta rotação.", "error") + return _redirect(f"/admin/schedules/{schedule.id}#rotation-order") + max_position = db.scalar( + select(func.max(RotationMember.position)).where(RotationMember.schedule_id == schedule.id) + ) or 0 + db.add(RotationMember(schedule_id=schedule.id, team_id=user.id, position=max_position + 1)) + db.commit() + _flash(request, "Membro adicionado à rotação.") + return _redirect(f"/admin/schedules/{schedule.id}#rotation-order") + + +@router.post("/admin/rotation/{member_id}/remove") +def admin_rotation_remove( + member_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + _require_admin(request, db) + member = db.get(RotationMember, member_id) + if not member: + raise HTTPException(status_code=404, detail="Membro da rotação não encontrado") + schedule_id = member.schedule_id + db.delete(member) + db.commit() + members = db.scalars( + select(RotationMember).where(RotationMember.schedule_id == schedule_id).order_by(RotationMember.position) + ).all() + for idx, row in enumerate(members, start=1): + row.position = idx + db.commit() + _flash(request, "Membro removido da rotação.") + return _redirect(f"/admin/schedules/{schedule_id}") + + +@router.post("/admin/rotation/{member_id}/move") +def admin_rotation_move( + member_id: int, + request: Request, + direction: str = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + _require_admin(request, db) + member = db.get(RotationMember, member_id) + if not member or direction not in {"up", "down"}: + raise HTTPException(status_code=400, detail="Movimento inválido") + target_position = member.position - 1 if direction == "up" else member.position + 1 + other = db.scalar( + select(RotationMember).where( + RotationMember.schedule_id == member.schedule_id, + RotationMember.position == target_position, + ) + ) + if other: + temporary = 1000000 + member.id + member.position = temporary + db.flush() + other.position = target_position + 1 if direction == "up" else target_position - 1 + db.flush() + member.position = target_position + db.commit() + return _redirect(f"/admin/schedules/{member.schedule_id}") + + +@router.get("/admin/month", response_class=HTMLResponse) +def admin_month(request: Request, db: Session = Depends(get_db)): + _require_admin(request, db) + # Each schedule is now configured on its own page. + return _redirect("/admin/schedules") + + +def _save_assignments(db: Session, assignments: Sequence[Assignment], form) -> int: + teams_by_id = {t.id: t for t in db.scalars(select(Team).where(Team.is_active.is_(True))).all()} + changed: list[tuple[Assignment, Team | None, Team | None]] = [] + for assignment in assignments: + raw = str(form.get(f"assignment_{assignment.id}", "")).strip() + new_team_id = int(raw) if raw else None + if new_team_id is not None and new_team_id not in teams_by_id: + continue + if assignment.team_id != new_team_id: + old_team = assignment.team + new_team = teams_by_id.get(new_team_id) if new_team_id else None + assignment.team_id = new_team_id + assignment.source = "manual" + changed.append((assignment, old_team, new_team)) + db.commit() + + _notify_assignment_changes(db, changed) + return len(changed) + + +def _default_team(db: Session, schedule: Schedule, day: date) -> Team | None: + """Who this shift would have by default: the rotation order or the night pattern.""" + if schedule.schedule_type == "rotation": + return rotation_team_for_date(db, schedule, day) + team_id = _pattern_map(db, schedule).get(day.day) + return db.get(Team, team_id) if team_id else None + + +def _set_assignee( + db: Session, assignment: Assignment, new_team: Team | None, source: str = "manual" +) -> None: + """Put someone on a shift directly: no change request, and any stale one is closed.""" + assignment.team_id = new_team.id if new_team else None + assignment.team = new_team + assignment.source = source + for swap in db.scalars( + select(SwapRequest).where( + SwapRequest.status.in_(["open", "pending_approval"]), + (SwapRequest.assignment_id == assignment.id) + | (SwapRequest.target_assignment_id == assignment.id), + ) + ).all(): + swap.status = "cancelled" + db.commit() + + +def _notify_assignment_changes(db: Session, changed: Sequence[tuple[Assignment, Team | None, Team | None]]) -> None: + for assignment, old_team, new_team in changed: + subject = f"Escala atualizada: {day_short(assignment.date)} – {assignment.schedule.name}" + if new_team: + send_email_notification( + db, + new_team, + "assignment_updated", + subject, + f"Está atribuído ao turno de {assignment.schedule.name} a {day_numeric_long(assignment.date)}.\n\n" + f"Ver a escala: {settings.normalized_base_url}/", + ) + if old_team and (not new_team or old_team.id != new_team.id): + send_email_notification( + db, + old_team, + "assignment_changed", + subject, + f"Já não está atribuído ao turno de {assignment.schedule.name} a {day_numeric_long(assignment.date)}.\n\n" + f"Ver a escala: {settings.normalized_base_url}/", + ) + + +def _month_assignments(db: Session, year: int, month: int) -> list[Assignment]: + first, last = month_bounds(year, month) + return list( + db.scalars( + select(Assignment) + .where(Assignment.date >= first, Assignment.date <= last) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + .order_by(Assignment.date) + ).all() + ) + + +@router.post("/admin/schedules/{schedule_id}/swaps/{swap_id}/assign") +def admin_schedule_assign_swap( + schedule_id: int, + swap_id: int, + request: Request, + team_id: int = Form(...), + year: str = Form(""), + month: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """Complete a change request on behalf of the rota, including for rota-only teams.""" + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + ) + ) + if not swap or swap.assignment.schedule_id != schedule.id: + raise HTTPException(status_code=404, detail="Pedido de troca não encontrado") + if swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=400, detail="Este pedido já não está aberto") + new_team = _swap_partner(db, schedule, team_id) + if new_team.id == swap.requester_id: + raise HTTPException(status_code=400, detail="Escolha outra equipa para a troca") + if swap.assignment.date < date.today(): + raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") + # The request only applies while the team who asked for the change still owns the shift. + if not _swap_ownership_holds(db, swap): + swap.status = "cancelled" + db.commit() + raise HTTPException(status_code=409, detail="O turno já foi alterado") + + swap.assignment.team_id = new_team.id + swap.assignment.source = "swap" + swap.accepted_team_id = new_team.id + swap.status = "approved" + for other in db.scalars( + select(SwapRequest).where( + SwapRequest.assignment_id == swap.assignment_id, + SwapRequest.id != swap.id, + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ).all(): + other.status = "cancelled" + db.commit() + + subject = "Troca de turno aprovada" + body = ( + f"{_user_label(admin)} atribuiu o turno de {schedule.name} de " + f"{day_numeric_long(swap.assignment.date)} a {new_team.name}, a pedido de {swap.requester.name}." + + ( + f" Em troca, {swap.requester.name} passa a ter o turno de " + f"{day_numeric_long(swap.target_assignment.date)}." + if swap.target_assignment is not None + else "" + ) + + f"\n\nVer a escala: {settings.normalized_base_url}/" + ) + send_many(db, [swap.requester, new_team], "swap_approved", subject, body) + _flash(request, f"Turno de {day_short(swap.assignment.date)} atribuído a {new_team.name}.") + return _redirect(f"{_schedule_page(schedule.id, year, month)}#swap-requests") + + +def _schedule_page(schedule_id: int, year: str, month: str) -> str: + if year.isdigit() and month.isdigit(): + return f"/admin/schedules/{schedule_id}?year={int(year)}&month={int(month)}" + return f"/admin/schedules/{schedule_id}" + + +@router.post("/admin/schedules/{schedule_id}/assignments") +async def admin_schedule_assignments( + schedule_id: int, + request: Request, + db: Session = Depends(get_db), +): + _require_admin(request, db) + form = await request.form() + _check_csrf(request, str(form.get("csrf_token", ""))) + schedule = _get_schedule(db, schedule_id) + year = int(form.get("year", date.today().year)) + month = int(form.get("month", date.today().month)) + assignments = [row for row in _month_assignments(db, year, month) if row.schedule_id == schedule.id] + changed = _save_assignments(db, assignments, form) + _flash(request, f"Foram guardadas {changed} alteração(ões) de turno.") + return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") + + +def _apply_rotation_to_rows(db: Session, schedule: Schedule, assignments: Sequence[Assignment]) -> int: + """Explicit admin edit: retain assignment IDs and invalidate requests when ownership changes.""" + changed: list[tuple[Assignment, Team | None, Team | None]] = [] + touched = 0 + db.flush() + for assignment in sorted(assignments, key=lambda row: row.date): + new_team = rotation_team_for_date(db, schedule, assignment.date) + new_team_id = new_team.id if new_team else None + if assignment.team_id == new_team_id and assignment.source == "generated": + continue + if assignment.team_id != new_team_id: + old_team = db.get(Team, assignment.team_id) if assignment.team_id is not None else None + changed.append((assignment, old_team, new_team)) + if assignment.id is not None: + requests = db.scalars(select(SwapRequest).where( + SwapRequest.assignment_id == assignment.id, + SwapRequest.status.in_(["open", "pending_approval"]), + )).all() + for swap in requests: + swap.status = "cancelled" + assignment.team_id = new_team_id + assignment.team = new_team + assignment.source = "generated" + # Later dates continue from the team selected for this date. + db.flush() + touched += 1 + db.commit() + _notify_assignment_changes(db, changed) + return touched + + +def _generate_rotation_range(db: Session, schedule: Schedule, first: date, last: date) -> tuple[int, int]: + rows = {row.date: row for row in db.scalars( + select(Assignment) + .where(Assignment.schedule_id == schedule.id, Assignment.date >= first, Assignment.date <= last) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + ).all()} + assignments = [] + created = 0 + day = first + while day <= last: + if day.weekday() not in schedule.weekday_set: + day += timedelta(days=1) + continue + row = rows.get(day) + if row is None: + row = Assignment(schedule=schedule, date=day, source="generated") + db.add(row) + created += 1 + if row.source == "generated": + assignments.append(row) + day += timedelta(days=1) + changed = _apply_rotation_to_rows(db, schedule, assignments) + return created, changed + + +@router.post("/admin/schedules/{schedule_id}/apply-rotation") +def admin_apply_rotation( + schedule_id: int, + request: Request, + year: int = Form(...), + month: int = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type != "rotation": + raise HTTPException(status_code=400, detail="Esta escala não usa rotação") + year, month = _parse_month(year, month) + created, changed = _generate_rotation_range(db, schedule, *month_bounds(year, month)) + _flash(request, f"Rotação aplicada em {month_name(month)} de {year}: " + f"{created} turno(s) criado(s), {changed} turno(s) atualizado(s). " + f"As atribuições manuais e as trocas foram mantidas.") + return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") + + +@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/regenerate") +@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/use-rotation") +def admin_assignment_regenerate( + schedule_id: int, + assignment_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type != "rotation": + raise HTTPException(status_code=400, detail="Esta escala não usa rotação") + assignment = db.get(Assignment, assignment_id) + if assignment is None or assignment.schedule_id != schedule.id: + raise HTTPException(status_code=404, detail="Turno não encontrado") + if assignment.date.weekday() not in schedule.weekday_set: + raise HTTPException(status_code=400, detail="Esta data não corresponde à escala") + _apply_rotation_to_rows(db, schedule, [assignment]) + _flash(request, "Turno recalculado com a equipa a seguir à responsável pelo turno anterior.") + return _redirect(f"/admin/schedules/{schedule.id}?year={assignment.date.year}&month={assignment.date.month}") + + +@router.get("/admin/assign", response_class=HTMLResponse) +def admin_assign_day( + request: Request, + on: str | None = None, + db: Session = Depends(get_db), +): + """Pick a day and put someone on one of its shifts. No change request involved. + + `on` is a whole ISO date (`YYYY-MM-DD`), which is what the date input sends. + """ + _require_admin(request, db) + today = date.today() + chosen = today + if on: + try: + chosen = date.fromisoformat(on) + except ValueError: + raise HTTPException(status_code=400, detail="Data inválida") + rows = { + a.schedule_id: a + for a in ensure_month_assignments(db, chosen.year, chosen.month) + if a.date == chosen + } + shifts = [ + { + "schedule": schedule, + "assignment": rows[schedule.id], + "auto": _default_team(db, schedule, chosen), + } + for schedule in _active_schedules(db) + if schedule.id in rows + ] + prev_day = chosen - timedelta(days=1) + next_day = chosen + timedelta(days=1) + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_assign.html", + context=_context( + request, + db, + day=chosen, + shifts=shifts, + teams=db.scalars( + select(Team).where(Team.is_active.is_(True)).order_by(Team.name) + ).all(), + prev_day=prev_day, + next_day=next_day, + today=today, + ), + ) + + +@router.post("/admin/assign") +def admin_assign_team( + request: Request, + assignment_id: int = Form(...), + team_id: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + assignment = db.scalar( + select(Assignment) + .where(Assignment.id == assignment_id) + .options(selectinload(Assignment.schedule), selectinload(Assignment.team)) + ) + if not assignment: + raise HTTPException(status_code=404, detail="Turno não encontrado") + if assignment.date.weekday() not in assignment.schedule.weekday_set: + raise HTTPException(status_code=400, detail="Esta data não corresponde à escala") + + choice = team_id.strip() + source = "manual" + new_team = None + if choice == "auto": + source = "generated" + new_team = _default_team(db, assignment.schedule, assignment.date) + elif choice: + new_team = db.get(Team, int(choice)) + if not new_team or not new_team.is_active: + raise HTTPException(status_code=400, detail="Equipa inválida") + if new_team and new_team.id == assignment.team_id and assignment.source == source: + _flash(request, f"{new_team.name} já estava em {day_short(assignment.date)}.") + return _redirect(_assign_day_url(assignment.date)) + + previous_team_id = assignment.team_id + _set_assignee(db, assignment, new_team, source) + previous_team = db.get(Team, previous_team_id) if previous_team_id else None + _notify_assignment_changes(db, [(assignment, previous_team, new_team)]) + if new_team is None: + message = f"Turno de {day_short(assignment.date)} sem atribuição." + else: + message = f"{new_team.name} em {day_short(assignment.date)} · {assignment.schedule.name}." + if source == "generated": + message += " (automático)" + _flash(request, message) + return _redirect(_assign_day_url(assignment.date)) + + +def _assign_day_url(day: date) -> str: + return f"/admin/assign?on={day.isoformat()}" + + +@router.get("/admin/notifications", response_class=HTMLResponse) +def admin_notifications(request: Request, db: Session = Depends(get_db)): + _require_admin(request, db) + logs = db.scalars( + select(NotificationLog) + .options(selectinload(NotificationLog.team)) + .order_by(NotificationLog.created_at.desc()) + .limit(200) + ).all() + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_notifications.html", + context=_context(request, db, logs=logs), + ) diff --git a/apps/shifts/backend/migrations/README b/apps/shifts/backend/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/apps/shifts/backend/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/apps/shifts/backend/migrations/env.py b/apps/shifts/backend/migrations/env.py new file mode 100644 index 0000000..ce67c4a --- /dev/null +++ b/apps/shifts/backend/migrations/env.py @@ -0,0 +1,58 @@ +"""Alembic environment for the rota database. + +The URL always comes from app settings (`DATABASE_URL`), so migrations run +against the same database as the application, on the host and in Docker alike. +""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import settings +from app.database import Base +from app import models # noqa: F401 (imported for its side effect: metadata) + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=settings.database_url.startswith("sqlite"), + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=settings.database_url.startswith("sqlite"), + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/shifts/backend/migrations/script.py.mako b/apps/shifts/backend/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/apps/shifts/backend/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/apps/shifts/backend/migrations/versions/0001_initial.py b/apps/shifts/backend/migrations/versions/0001_initial.py new file mode 100644 index 0000000..8567828 --- /dev/null +++ b/apps/shifts/backend/migrations/versions/0001_initial.py @@ -0,0 +1,161 @@ +"""initial schema + +The history was squashed: this revision is the whole schema as it stands, so a new +database is created in one step and an existing one only needs `alembic stamp head`. + +Revision ID: 0001_initial +Revises: +Create Date: 2026-10-06 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0001_initial" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "schedules", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("slug", sa.String(length=80), nullable=False), + sa.Column("schedule_type", sa.String(length=20), nullable=False), + sa.Column("weekdays", sa.String(length=30), nullable=False), + sa.Column("start_time", sa.Time(), nullable=False), + sa.Column("end_time", sa.Time(), nullable=False), + sa.Column("requires_manager_approval", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("rotation_anchor_date", sa.Date(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + sa.UniqueConstraint("slug"), + ) + # A user is a login: one username, several teams can be assigned to it. + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("username", sa.String(length=80), nullable=False), + sa.Column("password_hash", sa.String(length=300), nullable=False), + sa.Column("is_admin", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("username"), + ) + # A team is who works a shift; without a user nobody signs in for it. + op.create_table( + "teams", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("email", sa.String(length=254), nullable=True), + sa.Column("phone", sa.String(length=40), nullable=True), + sa.Column("calendar_token", sa.String(length=64), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("notify_email", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("calendar_token"), + sa.UniqueConstraint("email"), + ) + op.create_table( + "assignments", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("schedule_id", sa.Integer(), nullable=False), + sa.Column("date", sa.Date(), nullable=False), + sa.Column("team_id", sa.Integer(), nullable=True), + sa.Column("source", sa.String(length=20), nullable=False), + sa.Column("note", sa.String(length=500), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["schedule_id"], ["schedules.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("schedule_id", "date", name="uq_assignment_schedule_date"), + ) + with op.batch_alter_table("assignments", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_assignments_date"), ["date"], unique=False) + + op.create_table( + "monthly_patterns", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("schedule_id", sa.Integer(), nullable=False), + sa.Column("day_of_month", sa.Integer(), nullable=False), + sa.Column("team_id", sa.Integer(), nullable=True), + sa.CheckConstraint("day_of_month BETWEEN 1 AND 31", name="ck_pattern_day_of_month"), + sa.ForeignKeyConstraint(["schedule_id"], ["schedules.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("schedule_id", "day_of_month", name="uq_pattern_schedule_day"), + ) + op.create_table( + "notification_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("team_id", sa.Integer(), nullable=True), + sa.Column("event_type", sa.String(length=60), nullable=False), + sa.Column("channel", sa.String(length=20), nullable=False), + sa.Column("recipient", sa.String(length=254), nullable=True), + sa.Column("subject", sa.String(length=250), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("sent_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "rotation_members", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("schedule_id", sa.Integer(), nullable=False), + sa.Column("team_id", sa.Integer(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["schedule_id"], ["schedules.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("schedule_id", "position", name="uq_rotation_schedule_position"), + sa.UniqueConstraint("schedule_id", "team_id", name="uq_rotation_schedule_team"), + ) + op.create_table( + "swap_requests", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("assignment_id", sa.Integer(), nullable=False), + sa.Column("target_assignment_id", sa.Integer(), nullable=True), + sa.Column("requester_id", sa.Integer(), nullable=False), + sa.Column("target_team_id", sa.Integer(), nullable=True), + sa.Column("accepted_team_id", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("message", sa.String(length=500), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["accepted_team_id"], ["teams.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["assignment_id"], ["assignments.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["requester_id"], ["teams.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["target_assignment_id"], ["assignments.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["target_team_id"], ["teams.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("swap_requests") + op.drop_table("rotation_members") + op.drop_table("notification_logs") + op.drop_table("monthly_patterns") + with op.batch_alter_table("assignments", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_assignments_date")) + + op.drop_table("assignments") + op.drop_table("teams") + op.drop_table("users") + op.drop_table("schedules") \ No newline at end of file diff --git a/apps/shifts/backend/pyproject.toml b/apps/shifts/backend/pyproject.toml new file mode 100644 index 0000000..60ddac4 --- /dev/null +++ b/apps/shifts/backend/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "crr-shifts" +version = "0.1.0" +description = "Contínuos CRR shift rota application" +requires-python = ">=3.13,<3.14" +dependencies = [ + "fastapi>=0.115,<1", + "uvicorn[standard]>=0.30,<1", + "SQLAlchemy>=2.0,<3", + "Jinja2>=3.1,<4", + "python-multipart>=0.0.12,<1", + "itsdangerous>=2.2,<3", + "email-validator>=2.2,<3", + "pydantic-settings>=2.5,<3", + "alembic>=1.13,<2", + "reportlab>=4.2,<5", +] + +[dependency-groups] +dev = ["pytest>=8,<9", "httpx>=0.27,<1", "pypdf>=5,<7"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +addopts = "-ra" diff --git a/apps/shifts/backend/requirements-dev.txt b/apps/shifts/backend/requirements-dev.txt new file mode 100644 index 0000000..9fe9ba9 --- /dev/null +++ b/apps/shifts/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8,<9 +httpx>=0.27,<1 +pypdf>=5,<7 diff --git a/apps/shifts/backend/requirements.txt b/apps/shifts/backend/requirements.txt new file mode 100644 index 0000000..208122e --- /dev/null +++ b/apps/shifts/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115,<1 +uvicorn[standard]>=0.30,<1 +SQLAlchemy>=2.0,<3 +Jinja2>=3.1,<4 +python-multipart>=0.0.12,<1 +itsdangerous>=2.2,<3 +email-validator>=2.2,<3 +pydantic-settings>=2.5,<3 +alembic>=1.13,<2 +reportlab>=4.2,<5 diff --git a/apps/shifts/backend/tests/.gitkeep b/apps/shifts/backend/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/shifts/backend/tests/conftest.py b/apps/shifts/backend/tests/conftest.py new file mode 100644 index 0000000..11e57d7 --- /dev/null +++ b/apps/shifts/backend/tests/conftest.py @@ -0,0 +1,118 @@ +import os +import re +from collections.abc import Iterator +from datetime import date +from tempfile import TemporaryDirectory + +# Settings and the engine are built at import time, so the environment has to be +# set before any app module is imported: a temporary database and no real SMTP. +_TMP = TemporaryDirectory(prefix="crr-export-tests-") +os.environ.update({ + "DATABASE_URL": f"sqlite:///{_TMP.name}/test.db", + "BACKUP_DIR": f"{_TMP.name}/backups", + "SECRET_KEY": "test-secret", + "APP_NAME": "Contínuos CRR", + "BASE_URL": "http://testserver", + "CALENDAR_TOKEN": "", + "TIMEZONE": "Europe/Lisbon", + "TRUSTED_PROXY_HOSTS": "*", + "SMTP_HOST": "", + "SMTP_USERNAME": "", + "SMTP_PASSWORD": "", + "SMTP_PORT": "587", + "SMTP_FROM": "test@example.com", + "SMTP_STARTTLS": "false", + "REMINDER_DAYS_AHEAD": "1", + "ROOT_PATH": "", + "SESSION_HTTPS_ONLY": "false", + "INITIAL_ADMIN_USERNAME": "test-admin", + "INITIAL_ADMIN_PASSWORD": "secret123", + "INITIAL_ADMIN_NAME": "Administrador de teste", + "INITIAL_ADMIN_EMAIL": "admin@example.com", +}) + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import settings +from app.bootstrap import ensure_initial_data +from app.database import Base, SessionLocal, engine, run_migrations +from app.main import app +from app.models import Schedule, Team, User +from app.security import hash_password + + +def sign_in(client: TestClient, username: str, password: str) -> TestClient: + page = client.get("/login") + match = re.search(r'name="csrf_token" value="([^"]+)"', page.text) + assert match, "the login form should carry a CSRF token" + client.post( + "/login", + data={"username": username, "password": password, "csrf_token": match.group(1)}, + follow_redirects=True, + ) + return client + + +@pytest.fixture +def db() -> Iterator[Session]: + # The Alembic stamp has to go too, or `upgrade` finds nothing left to do. + Base.metadata.drop_all(bind=engine) + with engine.begin() as connection: + connection.exec_driver_sql("DROP TABLE IF EXISTS alembic_version") + run_migrations() + with SessionLocal() as session: + ensure_initial_data(session) + yield session + + +@pytest.fixture(scope="session", autouse=True) +def cleanup_database() -> Iterator[None]: + yield + engine.dispose() + _TMP.cleanup() + + +@pytest.fixture +def client(db: Session) -> Iterator[TestClient]: + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def admin(db: Session) -> User: + """The administrator the app lifespan bootstraps, with a known password.""" + user = db.scalar(select(User).where(User.username == settings.initial_admin_username)) + assert user is not None + user.password_hash = hash_password("secret123") + db.commit() + return user + + +@pytest.fixture +def logged_in(client: TestClient, admin: User) -> TestClient: + return sign_in(client, admin.username, "secret123") + + +@pytest.fixture +def schedule(db: Session) -> Schedule: + row = Schedule( + name="Almoço de sábado", + slug="almoco-sabado", + schedule_type="rotation", + weekdays="5", + rotation_anchor_date=date(2026, 1, 3), + ) + db.add(row) + db.commit() + return row + + +@pytest.fixture +def team(db: Session) -> Team: + row = Team(name="Equipa Norte") + db.add(row) + db.commit() + return row diff --git a/apps/shifts/backend/tests/test_brand_assets.py b/apps/shifts/backend/tests/test_brand_assets.py new file mode 100644 index 0000000..5e557c9 --- /dev/null +++ b/apps/shifts/backend/tests/test_brand_assets.py @@ -0,0 +1,50 @@ +from html.parser import HTMLParser + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.main import app + + +class BrandResources(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.stylesheets: list[str] = [] + self.images: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + if tag == "link" and attributes.get("rel") == "stylesheet": + self.stylesheets.append(attributes.get("href") or "") + if tag == "img": + self.images.append(attributes.get("src") or "") + + +@pytest.mark.parametrize("prefix", ["", "/crr"]) +def test_login_brand_resources_are_public_and_work_under_root_path(db: Session, prefix: str) -> None: + with TestClient(app, root_path=prefix) as client: + response = client.get(f"{prefix}/login") + assert response.status_code == 200 + resources = BrandResources() + resources.feed(response.text) + paths = [url.removeprefix("http://testserver") for url in resources.stylesheets] + assert paths == [ + f"{prefix}/brand/styles/tokens.css", + f"{prefix}/brand/styles/base.css", + f"{prefix}/brand/styles/components.css", + f"{prefix}/static/app.css", + ] + for path in paths: + stylesheet = client.get(path) + assert stylesheet.status_code == 200 + assert stylesheet.headers["content-type"].startswith("text/css") + assert resources.images + for url in resources.images: + assert f"{prefix}/brand/assets/logo.png" in url + image = client.get(url) + assert image.status_code == 200 + assert image.content.startswith(b"\x89PNG\r\n\x1a\n") + favicon = client.get(f"{prefix}/brand/assets/favicon.png") + assert favicon.status_code == 200 + assert favicon.content.startswith(b"\x89PNG\r\n\x1a\n") diff --git a/apps/shifts/backend/tests/test_export_schedule.py b/apps/shifts/backend/tests/test_export_schedule.py new file mode 100644 index 0000000..c592a7f --- /dev/null +++ b/apps/shifts/backend/tests/test_export_schedule.py @@ -0,0 +1,177 @@ +from datetime import date, time, timedelta +from io import BytesIO + +import pytest +from pypdf import PdfReader +from sqlalchemy import select + +from app.config import settings +from app.models import Assignment, Schedule, Team +from app.pdf import build_schedule_pdf + + +def download(client, schedule, start="2026-09-19", end="2026-12-26"): + return client.get("/export/schedule.pdf", params={ + "schedule_id": schedule.id, "start_date": start, "end_date": end, + }) + + +def test_form_has_editable_dates_and_download_action(logged_in, schedule, monkeypatch): + monkeypatch.setattr(settings, "root_path", "/crr") + response = logged_in.get("/admin/schedules", params={"schedule_id": schedule.id}) + assert response.status_code == 200 + assert 'type="date" name="start_date"' in response.text + assert 'type="date" name="end_date"' in response.text + assert 'action="/crr/export/schedule.pdf"' in response.text + assert "Descarregar PDF" in response.text + assert "window.print" not in response.text + assert f'value="{schedule.id}" selected' in response.text + assert 'id="export-pdf"' in response.text + assert f'/crr/admin/schedules?schedule_id={schedule.id}#export-pdf' in response.text + + +def test_old_admin_export_link_redirects_to_integrated_controls(logged_in, schedule): + response = logged_in.get("/export/schedule", params={"schedule_id": schedule.id}, follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == f"/admin/schedules?schedule_id={schedule.id}#export-pdf" + + +def test_member_export_does_not_grant_administration_access(logged_in, db, admin, schedule): + admin.is_admin = False + db.commit() + assert logged_in.get("/admin/schedules").status_code == 403 + response = logged_in.get("/export/schedule", params={"schedule_id": schedule.id}) + assert response.status_code == 200 + assert 'type="date" name="start_date"' in response.text + assert f'value="{schedule.id}" selected' in response.text + assert download(logged_in, schedule).headers["content-type"] == "application/pdf" + + +def test_both_export_routes_require_login(client, schedule): + assert client.get("/export/schedule").status_code == 401 + assert download(client, schedule).status_code == 401 + + +def test_pdf_is_one_a4_page_with_month_lists_and_inclusive_dates(logged_in, db, schedule): + included = Team(name="João & Luís (Farragão)") + excluded = Team(name="Não incluir") + db.add_all([included, excluded]) + db.flush() + db.add_all([ + Assignment(schedule_id=schedule.id, date=day, team_id=team.id, source=source) + for day, team, source in [ + (date(2026, 9, 12), excluded, "generated"), + (date(2026, 9, 19), included, "manual"), + (date(2026, 12, 26), included, "swap"), + (date(2027, 1, 2), excluded, "generated"), + ] + ]) + db.commit() + response = download(logged_in, schedule) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert response.headers["content-disposition"].startswith("attachment;") + assert "2026-09-19-2026-12-26.pdf" in response.headers["content-disposition"] + assert response.content.startswith(b"%PDF-") + pdf = PdfReader(BytesIO(response.content)) + assert len(pdf.pages) == 1 + page = pdf.pages[0] + assert float(page.mediabox.width) == pytest.approx(595.28, abs=0.1) + assert float(page.mediabox.height) == pytest.approx(841.89, abs=0.1) + text = page.extract_text() + assert "João & Luís (Farragão)" in text + assert "Não incluir" not in text + headings = ["Setembro 2026", "Outubro 2026", "Novembro 2026", "Dezembro 2026"] + assert all(heading in text for heading in headings) + assert [text.index(heading) for heading in headings] == sorted(text.index(h) for h in headings) + assert "19\nJoão" in text and "26\nJoão" in text + assert "Sem atribuição" in text + db.expire_all() + preserved = db.scalars(select(Assignment).where( + Assignment.schedule_id == schedule.id, + Assignment.source.in_(["manual", "swap"]), + ).order_by(Assignment.date)).all() + assert [(row.team_id, row.source) for row in preserved] == [ + (included.id, "manual"), (included.id, "swap"), + ] + + +def test_other_schedule_assignments_are_excluded(logged_in, db, schedule, team): + other = Schedule(name="Outra escala", slug="other", schedule_type="fixed", weekdays="5") + db.add(other) + db.flush() + db.add(Assignment(schedule_id=other.id, date=date(2026, 9, 19), team_id=team.id, source="manual")) + db.commit() + response = download(logged_in, schedule) + text = PdfReader(BytesIO(response.content)).pages[0].extract_text() + assert team.name not in text + assert other.name not in text + + +def test_full_daily_year_fits_one_page_without_losing_rows(logged_in, db, team): + daily = Schedule(name="Noites de teste", slug="test-night", schedule_type="fixed", weekdays="0,1,2,3,4,5,6") + db.add(daily) + db.flush() + from app.models import MonthlyPattern + for day in range(1, 32): + db.add(MonthlyPattern(schedule_id=daily.id, day_of_month=day, team_id=team.id)) + db.commit() + response = download(logged_in, daily, "2028-01-01", "2028-12-31") + pdf = PdfReader(BytesIO(response.content)) + assert len(pdf.pages) == 1 + assert pdf.pages[0].extract_text().count(team.name) == 366 + + +@pytest.mark.parametrize(("start", "end", "message"), [ + ("2026-12-26", "2026-09-19", "data final"), + ("2026-01-01", "2027-01-01", "12 meses"), + ("1999-12-01", "2000-01-01", "2000 e 2100"), +]) +def test_invalid_ranges_keep_form_values_and_show_error(logged_in, schedule, start, end, message): + response = download(logged_in, schedule, start, end) + assert response.status_code == 400 + assert message in response.text + assert f'value="{start}"' in response.text and f'value="{end}"' in response.text + assert 'id="export-pdf"' in response.text + assert f'value="{schedule.id}" selected' in response.text + + +def test_malformed_date_is_rejected(logged_in, schedule): + assert download(logged_in, schedule, "2026-02-30", "2026-03-01").status_code == 422 + + +def test_single_day_and_empty_range_still_produce_pdf(logged_in, schedule): + response = download(logged_in, schedule, "2026-09-19", "2026-09-19") + text = PdfReader(BytesIO(response.content)).pages[0].extract_text() + assert text.count("Sem atribuição") == 1 + response = download(logged_in, schedule, "2026-09-20", "2026-09-20") + assert "Sem turnos neste período." in PdfReader(BytesIO(response.content)).pages[0].extract_text() + + +def test_inactive_or_unknown_schedule_is_rejected(logged_in, db, schedule): + schedule.is_active = False + db.commit() + assert download(logged_in, schedule).status_code == 404 + assert logged_in.get("/export/schedule", params={"schedule_id": 999999}).status_code == 404 + + +def test_dense_long_names_wrap_and_remain_inside_page(): + schedule = Schedule(name="Noite", start_time=time(20, 30), end_time=time(0)) + team = Team(name="João " + "Carreira Ligeiro Farragão " * 4) + first, last = date(2028, 1, 1), date(2028, 12, 31) + rows = [Assignment(date=first + timedelta(days=index), team=team) for index in range(366)] + pdf = PdfReader(BytesIO(build_schedule_pdf(schedule, rows, first, last))) + assert len(pdf.pages) == 1 + page = pdf.pages[0] + assert page.extract_text().count("João") == 366 + positions = [] + + def record(text, cm, tm, font, size): + if text.strip(): + x = tm[4] * cm[0] + tm[5] * cm[2] + cm[4] + y = tm[4] * cm[1] + tm[5] * cm[3] + cm[5] + positions.append((x, y)) + + page.extract_text(visitor_text=record) + assert all(0 <= x <= float(page.mediabox.width) and 0 <= y <= float(page.mediabox.height) + for x, y in positions) diff --git a/apps/shifts/docs/ARCHITECTURE.md b/apps/shifts/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6dcf0b3 --- /dev/null +++ b/apps/shifts/docs/ARCHITECTURE.md @@ -0,0 +1,41 @@ +# Architecture + +## Runtime + +Single FastAPI process with server-rendered HTML and a SQLite database volume. This keeps deployment suitable for a small VPS, NAS or home server. + +For local development, `make dev` prepares the host virtualenv and default `data/` directory, then runs Uvicorn on `127.0.0.1:8000` with auto-reload for `app/`. Override the port with `make dev PORT=8001`. The app uses environment/`.env` settings and performs migrations and initial seeding during startup, just as it does in production. + +## Layers + +- `models.py`: persistence schema. +- `migrations/`: one Alembic revision, `0001_initial`, holding the whole schema (the history was squashed for a test environment). The app upgrades to `head` on start, and an existing database is adopted by stamping `head`; a database created before Alembic existed has no stamp and is adopted at the baseline. +- `scheduling.py`: deterministic month/rotation generation. +- `web.py`: authentication, admin editing and change-request orchestration. +- `notifications.py`: synchronous SMTP adapter + durable send log. +- `pdf.py`: ReportLab-rendered A4 schedule lists, with embedded Unicode fonts and + adaptive layout to keep the selected range on one page. +- `cli.py`: maintenance commands that can be run manually or by cron. + +## Data lifecycle + +1. Visiting a month calls `ensure_month_assignments`. +2. Missing Night rows use the repeating monthly pattern, or remain unassigned for unconfigured days. +3. Missing lunch rows are created from each independent rotation. +4. Admin assigns/overrides rows on each schedule's own configuration page (the night pattern, or a lunch month view). +5. Member change requests operate on concrete assignment IDs. +6. Email is attempted after the database action commits. + +## Deployment + +`Dockerfile` provides a `production` target (the default) and a `dev` target with development dependencies and Uvicorn auto-reload. Both serve `app.main:app` on port 8000. `compose.yaml` is a minimal deployment example: it builds the production target, loads `.env`, publishes `${PORT:-8000}`, and mounts `${DATA_DIR:-./data}` at `/app/data`. It fixes the container database URL to the SQLite file on that mount; default backups also live there. See [the deployment guide](../README.md#run-with-docker-compose) for setup commands. + +Maintenance commands are provided by `python -m app.cli`: `init-db`, `migrate`, `seed-demo`, `db-shell`, and `send-reminders`. Settings come from environment variables and `.env` through `app/config.py`. + +`alembic.ini` supplies migration paths and logging configuration. The actual database URL comes from application settings, configured programmatically in `app/database.py` and reused by `migrations/env.py`. + +For larger installations, the SQLAlchemy configuration can point to PostgreSQL, but the project intentionally ships only the SQLite deployment path today. + +## Testing + +Run `make test` on the host. It creates/reuses `.venv`, installs development dependencies, and invokes pytest; it needs no running application or container. `pyproject.toml` limits discovery to `tests/`. HTTPX supports FastAPI `TestClient` tests, and pypdf verifies PDF downloads. Fixtures configure temporary SQLite storage before importing the app, disable SMTP, run migrations and clean up after the suite. See [the testing guide](../README.md#run-tests). diff --git a/apps/shifts/docs/DOMAIN.md b/apps/shifts/docs/DOMAIN.md new file mode 100644 index 0000000..d313ca0 --- /dev/null +++ b/apps/shifts/docs/DOMAIN.md @@ -0,0 +1,150 @@ +# Domain model + +## Schedules vs assignments + +A **schedule** describes when a duty exists and how its initial assignee is chosen. An **assignment** is the actual duty on a concrete date. Once an assignment exists, it is not recalculated from the schedule. + +This distinction is intentional: editing a rotation must not retroactively change an already published month. + +## Seeded schedules + +Seeded names are Portuguese (`Noite`, `Almoço - Sábados`, `Almoço - Domingo`); the slugs stay `night`, `saturday-lunch` and `sunday-lunch`. +Each schedule carries `start_time`/`end_time`, the hours the shift runs, editable by an administrator on its configuration page: `20:30–00:00` for the night and `13:00–16:00` for both lunches. An end at or before the start means the shift runs into the next day. + +## Backups + +Every start copies the SQLite file aside before anything else runs, using SQLite's own backup API so the copy is consistent. Files go to `backups/` next to the database (or `BACKUP_DIR`), are named `rota-YYYYmmdd-HHMMSS.db` and are never overwritten: two starts in the same second increment the name. Nothing is copied for another engine or an in-memory database, and a failed backup only logs a warning instead of stopping the app. Nothing is ever pruned, so the folder grows one file per start. + +## Calendar feeds + +- `/calendar/.ics` is an iCalendar feed anybody can subscribe to from Google Calendar, Apple Calendar or Outlook. Every team has its own token, created the first time someone assigned to it opens `/calendar`, and its feed only carries the shifts assigned to it. +- `/calendar` is the page holding those links: the rota only has a button pointing there, so it keeps showing nothing but who works each day. A user gets one feed per active team it is assigned to, each titled `Turnos de `, plus the whole-rota feed for administrators. Each feed has mobile-sized **iPhone / Apple Calendar**, **Google Calendar / Android**, and **Outlook** actions. Apple opens a `webcal://` subscription; Google and Outlook open web subscription flows with the feed address encoded in the link. The page includes platform-specific instructions, a readonly address field, and **Copiar endereço**, with clipboard-error handling and a native-selection fallback for phones. +- Google does not add URL subscriptions through its mobile app: use its desktop web interface (request the desktop site on the phone, or use a computer), then enable the calendar in the mobile app using the same account. Outlook subscriptions also use its web interface. Subscriptions refresh according to the provider's schedule, rather than instantly; Google and Outlook must be able to fetch the address over the Internet. Importing a downloaded `.ics` file is a snapshot and is not the subscription workflow. +- Administrators also get one shared feed with the whole rota, using the team token in `CALENDAR_TOKEN` (generated on first use and shown on `/admin/schedules`). +- A feed publishes one month back and six months forward. Times carry the bar's timezone (`DTSTART;TZID=Europe/Lisbon:20261017T203000`) and the feed declares it in `X-WR-TIMEZONE` plus a `VTIMEZONE` block with the European summer-time rules (WET/WEST), so every client shows a night at 20:30 and keeps the right offset across the year. The zone comes from `TIMEZONE`; an unknown zone falls back to `Europe/Lisbon`. Times and names are private member data: the token is the only protection, so the shared link goes only to the team. +- Each event title is ` · · ` so a list of shifts reads at a glance; a shift without a team keeps ` · `. +- An unknown token returns 404, and a deactivated team's token stops working. + +### Night (`Noite`) + +- Exists every day (`Monday` through `Sunday`). +- Type: `fixed`. +- Configured once as a repeating monthly pattern: one team per calendar day of the month (1 to 31), configured on `/admin/schedules/`. +- Generation fills a missing assignment with the team configured for that day of the month. Days 29 to 31 only apply to months that have them. +- There is no per-month configuration for Night: the same pattern is used every month. +- Existing assignments are never rewritten by a pattern change; they keep the team they already had. +- An administrator can explicitly **Aplicar padrão** from a chosen month and year on `/admin/schedules/`. It rewrites every existing `generated` night from the first day of that month onwards, in that month and every later one, to match the current pattern. It is the explicit way to push a corrected pattern onto shifts that already have a team. Assignment IDs, notes and completed request history are kept, `manual` assignments and completed swaps are preserved, days with nobody in the pattern are left as they are, and no assignment is ever created. Open and pending change requests on a rewritten shift are cancelled and the new and previous teams are notified. + +### Saturday Lunch (`Almoço - Sábados`) + +- Saturdays only. +- Type: `rotation`. +- Has its own ordered member list. +- Continues after the previously assigned team's position in the rotation, including across month boundaries. The anchor date is the fallback when there is no usable previous assignee. + +### Sunday Lunch (`Almoço - Domingo`) + +- Sundays only. +- Type: `rotation`. +- Has its own ordered member list, independent of Saturday. + +The data model supports a schedule matching multiple weekdays even though the seeded lunch schedules each use one day. + +## Rotation generation + +For a rotation schedule, use the last assigned team on an earlier date in that same schedule as the reference. Choose the next active member after that team's position in the current rotation order, wrapping to the beginning. Unassigned dates are skipped when finding the reference. Manual assignments and completed swaps also supply the reference for later dates. Saturday and Sunday never use each other's assignees. + +Inactive teams are skipped as recipients, but their position can still supply the reference if they remain in the rotation. When no previous assignee exists, or that team has been removed from the rotation, fall back to the number of matching schedule days from the anchor date modulo the active member count. An empty active rotation leaves the shift unassigned. + +Generation and explicit month/range application process dates chronologically, so each newly assigned date supplies the reference for the next date. Regenerating one assignment only recalculates that date; later existing assignments remain unchanged until an administrator explicitly applies the rotation. + +Only missing assignments are generated. Existing rows are never rewritten. + +An administrator can explicitly **Apply rotation** on a lunch schedule's month page. This recalculates that month's `generated` assignments using the current active rotation members and fills missing dates for that schedule. Manual assignments and completed swaps are preserved. Simply editing the rotation order still does not rewrite any assignment. + +Every assignment on a lunch schedule page has a **Regenerate** button. An administrator can explicitly regenerate any assignment, including an automatic assignment, manual override, or completed swap. It immediately recalculates that date from the current rotation and its source becomes `generated`; if there are no active rotation members, it becomes unassigned. These actions preserve assignment IDs, notes, and completed request history. If ownership changes, open or pending-approval change requests for the assignment are cancelled, and assignment-change notifications are attempted after saving. + + + +**Limpar turnos** empties the `generated` shifts of a lunch schedule from the first day of a chosen month and year onwards, in that month and every later one, so they read as unassigned. No shift is deleted: rows keep their IDs, notes, source and completed request history, and stay `generated`, so **Aplicar rotação** fills them again. Manual assignments and completed swaps are never emptied. Open and pending change requests on an emptied shift are cancelled and the previous team is notified. Use it to force a recalculation from a known point instead of relying on the shifts that already exist. + +## Teams and users + +- A **team** (`teams`) is who works a shift: one person, or the people who cover it together. Every rota row, rotation position, pattern day and change request names a team. +- A **user** (`users`) is a login: a username, a password, a label and the administrator flag. A team points at one user through `teams.user_id`, and a team without one is rota-only: nobody signs in for it. +- `email` and `phone` are optional, and both belong to the team. +- The two are created on separate pages, and neither page creates the other: + - `/admin/teams` adds and edits teams (name, contacts, active) and only *selects* which user signs in for each one. There are no usernames or passwords on this page. + - `/admin/users` creates and edits users (name, username, password, administrator, active) and assigns each of them to the teams it signs in for. There are no team names or contacts on this page. +- One user can be assigned to several teams, which is how a team shares a single login, and the same username can never belong to two users. Ticking a team on `/admin/users` assigns it to that user and takes it away from whoever had it; the teams page is where a team is simply pointed at one user. +- Administration is a property of the **user**, so a team covered by an administrator user is administrated by whoever signs in with it. +- Rota-only teams can be assigned by an administrator, appear in rotations, and can be named as the target of a change request, but cannot answer it; an administrator completes the transfer on the schedule's own configuration page. +- Any password of at least four characters is accepted; there is no complexity rule. +- The session stores the user, never a team. Everything a member sees and does is scoped to the teams assigned to it: their shifts are the "mine" ones on the rota, their change requests are the ones listed, its calendar page holds one feed per team, and it can open and answer requests for any of them. A request made by one of those teams is its own: nobody can accept it through the same user, and a swap naming a team of the same user is refused. +- When a user accepts a request, the rota still records one team: the one the request names, or the user's first active team when the request names nobody. Ownership rules are checked against that team. +- A signed-in user reaches its account through the top bar, which opens `/account`: a single field for the new password, with no confirmation and no retyping of the current one, so anyone holding an open session can change it. The page lists the teams the user signs in for. +- A user cannot be deactivated, demoted or removed while it is the last one that can sign in as an administrator, and nobody can deactivate or remove the user they are signed in with. +- A team cannot be deactivated or lose its user while it is the only team of the user you are signed in with. +- An administrator can remove a team permanently from `/admin/teams`, after a confirmation page that lists the impact. A team that still holds any assignment, past ones included, cannot be removed: those shifts must first be handed over, so the record of who worked each date stays intact. SQLite cascades are not enforced by the application, so the removal clears the remaining references explicitly: night-pattern days lose their team, rotation positions are deleted, that team's own change requests are deleted and the ones that merely targeted or were accepted by them lose those references, and notification logs keep their history without the team. +- Nobody may remove their own team or the team of the last remaining administrator. The teams page only shows the remove button for the teams that pass these checks; the endpoints enforce them again. Deactivating is the non-destructive alternative. +- A user is removed from `/admin/users` only once no team is assigned to it. + +## Access + +### PDF export + +- Administrators use the **Exportar PDF** controls directly on `/admin/schedules`. + Each schedule card links to those controls with that schedule selected. Other + signed-in users can open `/export/schedule`. Both forms select one active + schedule and exact start/end dates, both inclusive (up to 12 calendar months). +- `/export/schedule.pdf` downloads an A4 PDF directly. It lists day numbers and + teams under Portuguese month headings, with the CRR logo. Font size and columns + adapt to keep the selected range on one page; long names wrap without truncation. +- Generation fills missing assignments in chronological order using the ordinary + monthly generator. Existing assignments, including manual overrides and swaps, + keep their current owners. Only rows for the selected schedule and dates appear + in the PDF. + +### Page access + +- `/` is the rota and is the landing page for everyone, signed in or not. Signed-out visitors see the month read-only: no "mine" highlighting, no change-request actions. +- `/admin/schedules` is the hub of the schedules and the only place that lists them: each card shows its kind and hours, the next dates of a rotation, its rotation order or the night pattern, and links to its configuration page. Signed-out visitors have no navigation at all; the brand logo is the way back to the rota. +- Change requests, `/swaps`, and every `/admin` page still require a signed-in, active user. +- Internal links are built with `settings.url()` (exposed to templates as `app_path`) and redirects go through the same helper, so `ROOT_PATH` keeps every link working under a proxy sub-path. + +## Change request state machine + +`open` → `pending_approval` → `approved` + +Other terminal states: `rejected`, `cancelled`, `reverted`. + +- A swap is always **one shift for one shift**: it is created on `/swaps/new` (its own page, so the rota keeps showing only who works each day) and names both shifts. The team giving the first shift is the requester; the team owning the second one is the target. There are no open offers. +- The two shifts must belong to the same schedule. The teams of a schedule are the ones in its rotation, in its monthly pattern, or already holding one of its shifts. +- When a swap is approved or accepted, **both** assignments change hands at once: the target takes the requester's shift and the requester takes the target's shift. Both become `swap` and both teams are notified. +- One team can work several days in a month, so each side of the exchange is chosen from a list of the month's future shifts, grouped by schedule: the requester picks "O turno que passo" (their own shifts) and "O turno que quero em troca" (another team's shifts of that schedule). A member only sees the shifts of the teams their user is assigned to on the first list; an administrator sees all of them. +- A shift can only be part of one open request at a time, on either side. +- An administrator can open a request for another team's shift. The request is still recorded against the team that owns the shift, so the ownership rule below keeps working; unassigned and past shifts cannot be requested. +- `/admin/assign` is the day-by-day picker for administrators: pick a day, then put a team on one of its shifts with **Atribuir**. This is a direct assignment, never a change request. It sets the assignment source to `manual`, cancels any open request that involves that shift, and notifies both the new and the previous team. `Sem atribuição` clears it. The date must match one of the schedule's weekdays. +- The requester cannot accept their own request. With a user covering several teams, that covers every one of them. +- Only the team the request names can accept it or decline it, and with a user covering several teams that means the user the named team is assigned to. +- If the schedule requires manager approval, acceptance moves to `pending_approval`. +- Nobody can decline their own request; the requester cancels it instead. An administrator may decline any `open` or `pending_approval` request. +- An administrator can approve a `pending_approval` request (it goes to whoever accepted it) or an `open` one, choosing another team of the same schedule when there is no named partner. Approval and decline are available both in `/swaps` and on the rota page next to the shift. +- Approval transfers the assignment and sets its source to `swap`. +- Only an administrator can revert an approved two-sided swap: both shifts go back to the teams that had them, their source becomes `manual` (the original source cannot be recovered) and a note records the revert. If either shift changed after the swap, the revert is refused and the day-by-day assignment must be used instead. +- If either team no longer owns its own shift, the request must not be applied and is cancelled. +- On a schedule's configuration page, an administrator can complete an `open` or `pending_approval` request on a team's behalf by choosing another team that works that same schedule. This is the only way a rota-only team gives up or receives a shift. The same ownership rule applies, other open requests for that assignment are cancelled, and the requester and the new team are both notified. Past dates are refused. + +## Notifications + +Email is optional per team and per installation. + +Events include: +- assignment updated/removed by admin; +- change requested; +- change accepted; +- manager approval required; +- change approved/rejected; +- shift reminder. + +An email error is logged but must not undo a scheduling action. diff --git a/apps/tournament/.env.example b/apps/tournament/.env.example new file mode 100644 index 0000000..40d8446 --- /dev/null +++ b/apps/tournament/.env.example @@ -0,0 +1,5 @@ + +ADMIN_PASSWORD= +SESSION_SECRET= +TRUST_PROXY=true +DEPLOYMENT_BASE_PATH=/jogo/ diff --git a/apps/tournament/AGENTS.md b/apps/tournament/AGENTS.md new file mode 100644 index 0000000..f1759fd --- /dev/null +++ b/apps/tournament/AGENTS.md @@ -0,0 +1,9 @@ +# Tournament + +- Follow the root migration plan and record checks in `migration/README.md`. +- Phase 1 temporarily retains the Vue/Fastify source layout and Node backend. +- Preserve `/api/...`, camelCase payloads, Portuguese errors, session cookies, + HMAC tokens, login limiting, SSE, and sub-path deployment during the Python port. +- Keep deterministic calendar, standings, seeding, and bracket rules testable. +- Run `make tournament-test` and `make tournament-build` from the repository root. +- Version and release this application independently using `tournament-v...` tags. diff --git a/apps/tournament/Dockerfile b/apps/tournament/Dockerfile new file mode 100644 index 0000000..9b7be75 --- /dev/null +++ b/apps/tournament/Dockerfile @@ -0,0 +1,22 @@ +# Phase 1 retains the legacy Node runtime until the FastAPI compatibility exit. +FROM node:22-alpine AS build +WORKDIR /workspace/apps/tournament +RUN apk add --no-cache python3 make g++ +COPY apps/tournament/package.json apps/tournament/package-lock.json ./ +RUN npm ci +COPY apps/tournament/ ./ +COPY packages/crr-brand/ /workspace/packages/crr-brand/ +ARG VITE_BASE_PATH=/jogo/ +ENV VITE_BASE_PATH=${VITE_BASE_PATH} +RUN npm run build && npm prune --omit=dev + +FROM node:22-alpine AS runtime +ENV NODE_ENV=production DATABASE_PATH=/data/tournament.sqlite CLIENT_DIST_PATH=/app/dist/client APP_BASE_PATH=/jogo/ +WORKDIR /app +COPY --from=build --chown=node:node /workspace/apps/tournament/package.json ./ +COPY --from=build --chown=node:node /workspace/apps/tournament/node_modules ./node_modules +COPY --from=build --chown=node:node /workspace/apps/tournament/dist ./dist +RUN mkdir /data && chown node:node /data +USER node +EXPOSE 8080 +CMD ["node", "dist/server/main.js"] diff --git a/apps/tournament/README.md b/apps/tournament/README.md new file mode 100644 index 0000000..92d2fd4 --- /dev/null +++ b/apps/tournament/README.md @@ -0,0 +1,41 @@ +# Tournament + +Phase 1 retains the legacy Vue/Fastify application under `src/client`, `src/server` +and `src/shared`. The planned `frontend/` and Python `backend/` split follows the +API compatibility port; the current backend remains independently runnable. + +Shared CRR assets and CSS come from `packages/crr-brand` through Vite's +`@crr-brand` alias. Tokens/base/components load before `src/client/styles.css`; +the app owns its responsive layout and print-card dimensions. Vite's public +asset directory uses the canonical package, including the favicon. + +From the repository root: + +```sh +make tournament-install +make tournament-test +make tournament-build +make tournament-backend-dev +# In another terminal: +make tournament-frontend-dev +``` + +Copy `apps/tournament/.env.example` to `apps/tournament/.env` and configure +`ADMIN_PASSWORD` and `SESSION_SECRET`. The backend listens on port 8080 and Vite +on 5173. Development data is stored in `apps/tournament/tournament.sqlite`. +Use consistent `APP_BASE_PATH` and `VITE_BASE_PATH`; `/jogo/` remains the example +deployment prefix, while both support `/`. + +```sh +docker compose up --build tournament +``` + +Compose loads `apps/tournament/.env`; `TOURNAMENT_BASE_PATH` in the root shell +controls both the frontend build path and backend path (default `/jogo/`). The +`tournament-data` volume contains `/data/tournament.sqlite`. The image is +`crr-tournament`; the current app version remains `1.0.0` in `package.json`. + +Run checks with Node 22+; the CI/container target is Node 22. The legacy package +has no lint script. Its tests cover HTTP/auth/SSE/sub-path behavior and the +deterministic tournament services. Python migration fixtures live in +`migration/fixtures/` at the repository root. diff --git a/apps/tournament/index.html b/apps/tournament/index.html new file mode 100644 index 0000000..adf6ab7 --- /dev/null +++ b/apps/tournament/index.html @@ -0,0 +1,14 @@ + + + + + + + + Torneio + + +
+ + + diff --git a/apps/tournament/package-lock.json b/apps/tournament/package-lock.json new file mode 100644 index 0000000..c1b0182 --- /dev/null +++ b/apps/tournament/package-lock.json @@ -0,0 +1,4480 @@ +{ + "name": "tournament-manager", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tournament-manager", + "version": "1.0.0", + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/static": "^10.1.3", + "better-sqlite3": "^13.0.3", + "fastify": "^5.2.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.5", + "@vitejs/plugin-vue": "^5.2.1", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^6.0.7", + "vitest": "^2.1.8", + "vue-tsc": "^2.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", + "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", + "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz", + "integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^2.0.1", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.3", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", + "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/tournament/package.json b/apps/tournament/package.json new file mode 100644 index 0000000..8ca1325 --- /dev/null +++ b/apps/tournament/package.json @@ -0,0 +1,39 @@ +{ + "name": "tournament-manager", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev:client": "vite", + "dev:server": "DATABASE_PATH=./tournament.sqlite node --watch --env-file-if-exists=.env --import tsx src/server/main.ts", + "build": "npm run build:client && npm run build:server", + "build:client": "vite build", + "build:server": "tsc --project tsconfig.server.json", + "start": "node dist/server/main.js", + "typecheck": "vue-tsc --noEmit --project tsconfig.client.json && tsc --noEmit --project tsconfig.server.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/static": "^10.1.3", + "better-sqlite3": "^13.0.3", + "fastify": "^5.2.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.5", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "tsx": "^4.19.2", + "vite": "^6.0.7", + "vitest": "^2.1.8", + "vue-tsc": "^2.2.0" + } +} diff --git a/apps/tournament/src/client/App.vue b/apps/tournament/src/client/App.vue new file mode 100644 index 0000000..c36dcb6 --- /dev/null +++ b/apps/tournament/src/client/App.vue @@ -0,0 +1,15 @@ + + + diff --git a/apps/tournament/src/client/components/AdminShell.vue b/apps/tournament/src/client/components/AdminShell.vue new file mode 100644 index 0000000..e827441 --- /dev/null +++ b/apps/tournament/src/client/components/AdminShell.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/tournament/src/client/components/AppLogo.vue b/apps/tournament/src/client/components/AppLogo.vue new file mode 100644 index 0000000..a51ee27 --- /dev/null +++ b/apps/tournament/src/client/components/AppLogo.vue @@ -0,0 +1,15 @@ + + + diff --git a/apps/tournament/src/client/components/AppModal.vue b/apps/tournament/src/client/components/AppModal.vue new file mode 100644 index 0000000..6df38ac --- /dev/null +++ b/apps/tournament/src/client/components/AppModal.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/tournament/src/client/components/ClassificationTable.vue b/apps/tournament/src/client/components/ClassificationTable.vue new file mode 100644 index 0000000..5aff699 --- /dev/null +++ b/apps/tournament/src/client/components/ClassificationTable.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/tournament/src/client/components/DisplayClassificationPanel.vue b/apps/tournament/src/client/components/DisplayClassificationPanel.vue new file mode 100644 index 0000000..18cc54a --- /dev/null +++ b/apps/tournament/src/client/components/DisplayClassificationPanel.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/tournament/src/client/components/DisplayFit.vue b/apps/tournament/src/client/components/DisplayFit.vue new file mode 100644 index 0000000..68e87d2 --- /dev/null +++ b/apps/tournament/src/client/components/DisplayFit.vue @@ -0,0 +1,40 @@ + + + diff --git a/apps/tournament/src/client/components/DisplayLatestResultsPanel.vue b/apps/tournament/src/client/components/DisplayLatestResultsPanel.vue new file mode 100644 index 0000000..f7c537f --- /dev/null +++ b/apps/tournament/src/client/components/DisplayLatestResultsPanel.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/tournament/src/client/components/DisplayNextMatchPanel.vue b/apps/tournament/src/client/components/DisplayNextMatchPanel.vue new file mode 100644 index 0000000..936c7b4 --- /dev/null +++ b/apps/tournament/src/client/components/DisplayNextMatchPanel.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/tournament/src/client/components/FinalBracket.vue b/apps/tournament/src/client/components/FinalBracket.vue new file mode 100644 index 0000000..b79d5f0 --- /dev/null +++ b/apps/tournament/src/client/components/FinalBracket.vue @@ -0,0 +1,169 @@ + + + diff --git a/apps/tournament/src/client/components/MatchResultCard.vue b/apps/tournament/src/client/components/MatchResultCard.vue new file mode 100644 index 0000000..09773e2 --- /dev/null +++ b/apps/tournament/src/client/components/MatchResultCard.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/apps/tournament/src/client/components/match-cards/FourScores.vue b/apps/tournament/src/client/components/match-cards/FourScores.vue new file mode 100644 index 0000000..b025249 --- /dev/null +++ b/apps/tournament/src/client/components/match-cards/FourScores.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/apps/tournament/src/client/components/match-cards/SingleScore.vue b/apps/tournament/src/client/components/match-cards/SingleScore.vue new file mode 100644 index 0000000..1f579f0 --- /dev/null +++ b/apps/tournament/src/client/components/match-cards/SingleScore.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/apps/tournament/src/client/components/match-cards/SixScores.vue b/apps/tournament/src/client/components/match-cards/SixScores.vue new file mode 100644 index 0000000..0fbd76b --- /dev/null +++ b/apps/tournament/src/client/components/match-cards/SixScores.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/apps/tournament/src/client/components/matchCards.ts b/apps/tournament/src/client/components/matchCards.ts new file mode 100644 index 0000000..c9ed5e9 --- /dev/null +++ b/apps/tournament/src/client/components/matchCards.ts @@ -0,0 +1,77 @@ +import type { Component } from 'vue'; +import type { BracketMatch, BracketParticipant, BracketRound, LeagueMatch } from '../types'; + +interface MatchCardLayoutMetadata { + id: string; + name: string; + description: string; + order: number; +} + +interface MatchCardLayoutModule { + default: Component; + layout: MatchCardLayoutMetadata; +} + +export interface MatchCardTemplate extends MatchCardLayoutMetadata { + component: Component; +} + +export interface MatchCardMatch { + id: string; + phase: 'league' | 'final'; + roundNumber: number | null; + context: string; + teamA: BracketParticipant; + teamB: BracketParticipant; +} + +export function matchCardForLeagueMatch(match: LeagueMatch): MatchCardMatch { + return { + id: `league-${match.id}`, + phase: 'league', + roundNumber: match.roundNumber, + context: `Jornada ${match.roundNumber} · Jogo ${match.gameNumber} · ${match.group?.name ?? 'Fase de liga'}`, + teamA: { team: match.teamA, pending: false }, + teamB: { team: match.teamB, pending: false }, + }; +} + +export function matchCardForFinalMatch(round: BracketRound, match: BracketMatch): MatchCardMatch { + return { + id: `final-${round.roundIndex}-${match.matchIndex}`, + phase: 'final', + roundNumber: null, + context: `Fase final · ${round.name} · Jogo ${match.matchIndex}`, + teamA: match.teamA, + teamB: match.teamB, + }; +} + +export function matchCardForThirdPlaceMatch(roundIndex: number, match: BracketMatch): MatchCardMatch { + return { + id: `final-${roundIndex}-${match.matchIndex}`, + phase: 'final', + roundNumber: null, + context: 'Fase final · Jogo do 3.º lugar', + teamA: match.teamA, + teamB: match.teamB, + }; +} + +// A layout becomes selectable by exporting `layout` from any Vue file in this folder. +const modules = import.meta.glob('./match-cards/*.vue', { eager: true }); +const templates = Object.values(modules) + .map(({ default: component, layout }) => ({ ...layout, component })) + .sort((first, second) => first.order - second.order || first.name.localeCompare(second.name, 'pt-PT')); + +if (!templates.length || new Set(templates.map((template) => template.id)).size !== templates.length) { + throw new Error('Os layouts de cartões têm de ter identificadores únicos.'); +} + +export const matchCardTemplates: readonly MatchCardTemplate[] = templates; +export type MatchCardTemplateId = string; + +export function matchCardTemplate(id: MatchCardTemplateId): MatchCardTemplate { + return matchCardTemplates.find((template) => template.id === id) ?? matchCardTemplates[0]; +} diff --git a/apps/tournament/src/client/displayPanels.ts b/apps/tournament/src/client/displayPanels.ts new file mode 100644 index 0000000..552f9d8 --- /dev/null +++ b/apps/tournament/src/client/displayPanels.ts @@ -0,0 +1,35 @@ +import type { Component } from 'vue'; +import { DISPLAY_PANEL_TYPES, type DisplayPanelType } from '../shared/types/tournament'; +import DisplayClassificationPanel from './components/DisplayClassificationPanel.vue'; +import DisplayLatestResultsPanel from './components/DisplayLatestResultsPanel.vue'; +import DisplayNextMatchPanel from './components/DisplayNextMatchPanel.vue'; + +export interface DisplayPanelDefinition { + label: string; + description: string; + component: Component; +} + +const panels = { + 'next-match': { + label: 'Próximo jogo', + description: 'Mostra o próximo jogo por disputar e qual vem a seguir.', + component: DisplayNextMatchPanel, + }, + 'latest-results': { + label: 'Últimos resultados', + description: 'Mostra a jornada mais recente com resultados registados.', + component: DisplayLatestResultsPanel, + }, + classification: { + label: 'Classificação', + description: 'Mostra a classificação actual de todos os grupos.', + component: DisplayClassificationPanel, + }, +} satisfies Record; + +export const displayPanelDefinitions = DISPLAY_PANEL_TYPES.map((type) => ({ type, ...panels[type] })); + +export function displayPanelDefinition(type: DisplayPanelType): DisplayPanelDefinition { + return panels[type] ?? panels['latest-results']; +} diff --git a/apps/tournament/src/client/lib/api.ts b/apps/tournament/src/client/lib/api.ts new file mode 100644 index 0000000..4922067 --- /dev/null +++ b/apps/tournament/src/client/lib/api.ts @@ -0,0 +1,61 @@ +export class ApiError extends Error { + readonly status: number; + readonly fieldErrors: Record; + + constructor(status: number, message: string, fieldErrors: Record = {}) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.fieldErrors = fieldErrors; + } +} + +export function applicationPath(path: string): string { + return `${import.meta.env.BASE_URL.slice(0, -1)}/${path.replace(/^\//, '')}`; +} + +function errorFromPayload(status: number, payload: unknown): ApiError { + if (payload && typeof payload === 'object') { + const value = payload as Record; + const message = typeof value.message === 'string' + ? value.message + : typeof value.error === 'string' + ? value.error + : 'Não foi possível concluir o pedido.'; + const errors = value.errors && typeof value.errors === 'object' + ? Object.fromEntries( + Object.entries(value.errors as Record) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ) + : {}; + return new ApiError(status, message, errors); + } + + return new ApiError(status, 'Não foi possível concluir o pedido.'); +} + +export async function api(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (init.body && !(init.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + let response: Response; + try { + response = await fetch(applicationPath(path), { ...init, headers, credentials: 'include' }); + } catch { + throw new ApiError(0, 'Não foi possível contactar o servidor. Verifique a ligação e tente novamente.'); + } + + const isJson = response.headers.get('content-type')?.includes('application/json'); + const payload: unknown = isJson ? await response.json().catch(() => null) : null; + if (!response.ok) { + throw errorFromPayload(response.status, payload); + } + + return payload as T; +} + +export function messageFor(error: unknown): string { + return error instanceof ApiError ? error.message : 'Ocorreu um erro inesperado. Tente novamente.'; +} diff --git a/apps/tournament/src/client/lib/format.test.ts b/apps/tournament/src/client/lib/format.test.ts new file mode 100644 index 0000000..108d002 --- /dev/null +++ b/apps/tournament/src/client/lib/format.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import type { LeagueMatch } from '../types'; +import { latestResultsRound, unfinishedLeagueMatches } from './format'; + +function match(id: number, roundNumber: number, scoreA: number | null, scoreB: number | null): LeagueMatch { + const group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teamANumber = id * 2 - 1; + const teamBNumber = id * 2; + return { + id, + gameNumber: id, + roundNumber, + countsTowardStandings: false, + group, + teamA: { id: teamANumber, number: teamANumber, name: `Equipa ${teamANumber}`, group }, + teamB: { id: teamBNumber, number: teamBNumber, name: `Equipa ${teamBNumber}`, group }, + scoreA, + scoreB, + }; +} + +describe('latestResultsRound', () => { + it('returns null without a calendar', () => { + expect(latestResultsRound([])).toBeNull(); + }); + + it('shows the first scheduled round before results are entered', () => { + const result = latestResultsRound([ + match(2, 2, null, null), + match(1, 1, null, null), + ]); + + expect(result?.roundNumber).toBe(1); + }); + + it('shows every match from the latest round containing a result', () => { + const result = latestResultsRound([ + match(4, 3, null, null), + match(3, 2, null, null), + match(2, 2, 4, 2), + match(1, 1, 1, 0), + ]); + + expect(result?.roundNumber).toBe(2); + expect(result?.matches.map((scheduledMatch) => scheduledMatch.id)).toEqual([2, 3]); + }); +}); + + +describe('unfinishedLeagueMatches', () => { + it('returns unfinished matches ordered by game number', () => { + const result = unfinishedLeagueMatches([ + match(3, 2, null, null), + match(1, 1, 2, 1), + match(2, 1, null, null), + ]); + + expect(result.map((scheduledMatch) => scheduledMatch.gameNumber)).toEqual([2, 3]); + }); +}); diff --git a/apps/tournament/src/client/lib/format.ts b/apps/tournament/src/client/lib/format.ts new file mode 100644 index 0000000..b21371c --- /dev/null +++ b/apps/tournament/src/client/lib/format.ts @@ -0,0 +1,41 @@ +import type { LeagueMatch } from '../types'; + +export function groupMatches(matches: LeagueMatch[]): Array<{ roundNumber: number; matches: LeagueMatch[] }> { + const groups = new Map(); + for (const match of matches) { + const current = groups.get(match.roundNumber) ?? []; + current.push(match); + groups.set(match.roundNumber, current); + } + + return [...groups.entries()] + .sort(([first], [second]) => first - second) + .map(([roundNumber, groupedMatches]) => ({ + roundNumber, + matches: [...groupedMatches].sort((first, second) => first.gameNumber - second.gameNumber), + })); +} + +export function unfinishedLeagueMatches(matches: readonly LeagueMatch[]): LeagueMatch[] { + return matches + .filter((match) => match.scoreA === null || match.scoreB === null) + .sort((first, second) => first.gameNumber - second.gameNumber); +} + +export function latestResultsRound(matches: LeagueMatch[]): { roundNumber: number; matches: LeagueMatch[] } | null { + const rounds = groupMatches(matches); + for (let index = rounds.length - 1; index >= 0; index -= 1) { + if (rounds[index].matches.some((match) => match.scoreA !== null && match.scoreB !== null)) { + return rounds[index]; + } + } + return rounds[0] ?? null; +} + +export function matchGroupLabel(match: LeagueMatch): string { + return match.group?.name ?? 'Entre grupos'; +} + +export function signedDifference(value: number): string { + return value > 0 ? `+${value}` : String(value); +} diff --git a/apps/tournament/src/client/lib/score.test.ts b/apps/tournament/src/client/lib/score.test.ts new file mode 100644 index 0000000..1922018 --- /dev/null +++ b/apps/tournament/src/client/lib/score.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { parseScoreDraft } from './score'; + +describe('parseScoreDraft', () => { + it('parses non-negative integer scores', () => { + expect(parseScoreDraft({ scoreA: '0', scoreB: '12' })).toEqual({ scoreA: 0, scoreB: 12 }); + }); + + it.each([ + { scoreA: '', scoreB: '1' }, + { scoreA: '-1', scoreB: '1' }, + { scoreA: '1.5', scoreB: '2' }, + { scoreA: 'a', scoreB: '2' }, + ])('rejects invalid score drafts: %o', (draft) => { + expect(parseScoreDraft(draft)).toBeNull(); + }); +}); diff --git a/apps/tournament/src/client/lib/score.ts b/apps/tournament/src/client/lib/score.ts new file mode 100644 index 0000000..a38fa51 --- /dev/null +++ b/apps/tournament/src/client/lib/score.ts @@ -0,0 +1,20 @@ +export interface ScoreDraft { + scoreA: string; + scoreB: string; +} + +export interface ScorePair { + scoreA: number; + scoreB: number; +} + +export function parseScoreDraft(draft: ScoreDraft): ScorePair | null { + if (!/^\d+$/.test(draft.scoreA) || !/^\d+$/.test(draft.scoreB)) { + return null; + } + + return { + scoreA: Number(draft.scoreA), + scoreB: Number(draft.scoreB), + }; +} diff --git a/apps/tournament/src/client/lib/usePublicTournamentState.ts b/apps/tournament/src/client/lib/usePublicTournamentState.ts new file mode 100644 index 0000000..db50924 --- /dev/null +++ b/apps/tournament/src/client/lib/usePublicTournamentState.ts @@ -0,0 +1,78 @@ +import { onBeforeUnmount, onMounted, ref } from 'vue'; +import { api, applicationPath, messageFor } from './api'; +import type { PublicTournamentState } from '../types'; + +export function usePublicTournamentState() { + const state = ref(null); + const initialLoading = ref(true); + const refreshError = ref(''); + const connection = ref<'connecting' | 'connected' | 'reconnecting'>('connecting'); + const lastUpdated = ref(null); + const refreshing = ref(false); + const refreshQueued = ref(false); + let events: EventSource | null = null; + + async function refreshState() { + if (refreshing.value) { + refreshQueued.value = true; + return; + } + + refreshing.value = true; + try { + state.value = await api('/api/public/state'); + refreshError.value = ''; + lastUpdated.value = new Date(); + } catch (caught) { + // Preserve the previous state so a temporary outage never blanks a display. + refreshError.value = messageFor(caught); + } finally { + refreshing.value = false; + initialLoading.value = false; + if (refreshQueued.value) { + refreshQueued.value = false; + void refreshState(); + } + } + } + + function connectEvents() { + events = new EventSource(applicationPath('/api/public/events')); + const invalidate = () => { void refreshState(); }; + events.addEventListener('state-changed', invalidate); + events.onmessage = invalidate; + events.onopen = () => { + connection.value = 'connected'; + void refreshState(); + }; + events.onerror = () => { + // EventSource retries itself. Keep showing the last successful state meanwhile. + connection.value = 'reconnecting'; + }; + } + + function refreshOnVisibility() { + if (document.visibilityState === 'visible') void refreshState(); + } + + onMounted(() => { + void refreshState(); + connectEvents(); + document.addEventListener('visibilitychange', refreshOnVisibility); + }); + + onBeforeUnmount(() => { + events?.close(); + events = null; + document.removeEventListener('visibilitychange', refreshOnVisibility); + }); + + return { + connection, + initialLoading, + lastUpdated, + refreshError, + refreshState, + state, + }; +} diff --git a/apps/tournament/src/client/main.ts b/apps/tournament/src/client/main.ts new file mode 100644 index 0000000..397466c --- /dev/null +++ b/apps/tournament/src/client/main.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router'; +import '@crr-brand/styles/tokens.css'; +import '@crr-brand/styles/base.css'; +import '@crr-brand/styles/components.css'; +import './styles.css'; + +createApp(App).use(router).mount('#app'); diff --git a/apps/tournament/src/client/router.ts b/apps/tournament/src/client/router.ts new file mode 100644 index 0000000..b216919 --- /dev/null +++ b/apps/tournament/src/client/router.ts @@ -0,0 +1,48 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import { api } from './lib/api'; +import LoginView from './views/LoginView.vue'; +import TeamsView from './views/TeamsView.vue'; +import CalendarView from './views/CalendarView.vue'; +import ResultsView from './views/ResultsView.vue'; +import FinalStageView from './views/FinalStageView.vue'; +import PublicResultsView from './views/PublicResultsView.vue'; +import DisplayView from './views/DisplayView.vue'; +import DisplaySettingsView from './views/DisplaySettingsView.vue'; +import MatchCardsView from './views/MatchCardsView.vue'; +import DashboardView from './views/DashboardView.vue'; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { path: '/', redirect: '/results' }, + { path: '/results', component: PublicResultsView }, + { path: '/display', component: DisplayView }, + { path: '/admin', redirect: '/admin/dashboard' }, + { path: '/admin/login', component: LoginView, meta: { login: true } }, + { path: '/admin/dashboard', component: DashboardView, meta: { admin: true } }, + { path: '/admin/teams', component: TeamsView, meta: { admin: true } }, + { path: '/admin/calendar', component: CalendarView, meta: { admin: true } }, + { path: '/admin/results', component: ResultsView, meta: { admin: true } }, + { path: '/admin/final-stage', component: FinalStageView, meta: { admin: true } }, + { path: '/admin/match-cards', component: MatchCardsView, meta: { admin: true } }, + { path: '/admin/display', component: DisplaySettingsView, meta: { admin: true } }, + { path: '/:pathMatch(.*)*', redirect: '/results' }, + ], +}); + +router.beforeEach(async (to) => { + if (!to.meta.admin && !to.meta.login) return true; + + try { + const session = await api<{ authenticated?: boolean; active?: boolean }>('/api/auth/session'); + const authenticated = session.authenticated ?? session.active ?? false; + if (to.meta.admin && !authenticated) return { path: '/admin/login', query: { next: to.fullPath } }; + if (to.meta.login && authenticated) return '/admin/dashboard'; + } catch { + if (to.meta.admin) return { path: '/admin/login', query: { next: to.fullPath } }; + } + + return true; +}); + +export default router; diff --git a/apps/tournament/src/client/styles.css b/apps/tournament/src/client/styles.css new file mode 100644 index 0000000..81493ff --- /dev/null +++ b/apps/tournament/src/client/styles.css @@ -0,0 +1,795 @@ +:root { + --color-primary: var(--crr-primary); + --color-primary-dark: var(--crr-primary-dark); + --color-ink: var(--crr-text); + --color-muted: var(--crr-muted); + --color-line: var(--crr-border); + --color-canvas: var(--crr-background); + --color-panel: var(--crr-surface); + --color-danger: var(--crr-danger); + --shadow-soft: var(--crr-shadow-soft); + --match-card-width: 93mm; + --match-card-height: 52mm; + --match-card-gap: 4mm; + --match-card-scores-width: 64mm; + color: var(--color-ink); + font-family: var(--crr-font-family); + font-synthesis: none; +} + +html { min-width: 320px; background: var(--color-canvas); } + +body { min-width: 320px; min-height: 100vh; margin: 0; background: var(--color-canvas); } + +button:disabled { cursor: not-allowed; opacity: .62; } + +h1, h2, h3, p { margin-top: 0; } + +h1, h2, h3 { letter-spacing: -.035em; } + +h1 { margin-bottom: .5rem; font-size: clamp(2rem, 4vw, 3.25rem); line-height: 1; } + +h2 { margin-bottom: .35rem; font-size: 1.35rem; line-height: 1.12; } + +h3 { margin-bottom: .45rem; font-size: 1rem; } + +input, select { + min-height: 2.7rem; + padding: .55rem .7rem; + transition: border-color .15s ease, box-shadow .15s ease; +} + +label { display: grid; gap: .38rem; color: #35383c; font-size: .86rem; font-weight: 720; } + +.eyebrow { margin-bottom: .45rem; color: var(--color-primary); font-size: .72rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } + +.muted { color: var(--color-muted); line-height: 1.5; } + +.button { + display: inline-flex; + min-height: 2.7rem; + align-items: center; + justify-content: center; + font-size: .9rem; + font-weight: 760; + padding: .55rem .9rem; + transition: background .15s ease, border-color .15s ease, transform .15s ease; +} + +.button:hover:not(:disabled) { transform: translateY(-1px); } + +.button--danger { border-color: var(--color-danger); background: var(--color-danger); } + +.button--danger:hover:not(:disabled) { border-color: var(--crr-danger-dark); background: var(--crr-danger-dark); } + +.button--small { min-height: 2.15rem; border-radius: .42rem; font-size: .79rem; padding: .35rem .65rem; } + +.button--wide { width: 100%; } + +.text-button { border: 0; background: transparent; color: #3c4147; font-size: .84rem; font-weight: 700; padding: .2rem; text-decoration: underline; text-underline-offset: 3px; } + +.text-button:hover { color: var(--color-primary); } + +.text-button--danger { color: var(--color-danger); } + +.form-error, .field-error { color: var(--color-danger); font-size: .85rem; font-weight: 650; line-height: 1.35; } + +.form-error { margin: .6rem 0 0; } + +.field-error { display: block; } + +.form-success { margin: .65rem 0 0; color: #167443; font-size: .86rem; font-weight: 700; } + +.notice { border-radius: .65rem; padding: .9rem 1rem; } + +.notice--error { border: 1px solid var(--crr-danger-border); } + +.loading-state { color: var(--color-muted); padding: 2rem 0; } + +.empty-state { border: 1px dashed #bfc2c7; border-radius: .8rem; background: rgb(255 255 255 / 62%); color: var(--color-muted); padding: 2.1rem; text-align: center; } + +.empty-state h2, .empty-state h3 { color: var(--color-ink); } + +.empty-inline { color: var(--color-muted); font-size: .9rem; } + +.visually-hidden { width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); position: absolute; white-space: nowrap; } + +.app-logo { display: inline-flex; align-items: center; gap: .62rem; } + +.app-logo img { width: 2.2rem; height: 2.2rem; border-radius: .42rem; object-fit: contain; } + +.app-logo__text { color: var(--color-ink); font-size: 1rem; font-weight: 840; letter-spacing: -.04em; } + +.app-logo--large { gap: 0; } + +.app-logo--large img { width: 5rem; height: 5rem; border-radius: .8rem; box-shadow: 0 9px 20px rgb(0 0 0 / 10%); } + +.login-page { display: grid; min-height: 100vh; place-items: center; overflow: hidden; background: #18191b; padding: 1.25rem; position: relative; } + +.login-page::before { width: min(68vw, 55rem); height: min(68vw, 55rem); border-radius: 50%; background: var(--color-primary); content: ""; filter: blur(1px); opacity: .94; position: absolute; right: -24rem; top: -31rem; } + +.login-page::after { width: 20rem; height: 20rem; border: 1px solid rgb(255 255 255 / 14%); border-radius: 50%; content: ""; position: absolute; bottom: -10rem; left: -7rem; } + +.login-card { width: min(100%, 29rem); display: grid; gap: 1.5rem; border: 1px solid #e2e3e5; border-radius: 1.1rem; background: #fff; box-shadow: 0 25px 70px rgb(0 0 0 / 28%); padding: clamp(1.5rem, 5vw, 2.6rem); position: relative; z-index: 1; } + +.stack-form { display: grid; gap: 1rem; } + +.login-public-link { color: var(--color-muted); font-size: .88rem; text-align: center; text-underline-offset: 4px; } + +.admin-layout { min-height: 100vh; } + +.admin-header { width: 100%; min-height: 4.6rem; display: flex; align-items: center; gap: 1.7rem; border-bottom: 1px solid var(--color-line); background: rgb(255 255 255 / 94%); padding: .65rem clamp(1rem, 4vw, 4rem); position: sticky; top: 0; z-index: 10; backdrop-filter: blur(14px); } + +.admin-brand { display: inline-flex; flex: 0 0 auto; text-decoration: none; } + +.admin-nav { display: flex; min-width: 0; align-self: stretch; align-items: stretch; gap: .2rem; } + +.admin-nav a { display: inline-flex; align-items: center; border-bottom: 3px solid transparent; color: #686d74; font-size: .9rem; font-weight: 730; padding: .1rem .72rem; text-decoration: none; white-space: nowrap; } + +.admin-nav a.router-link-active { border-bottom-color: var(--color-primary); color: var(--color-ink); } + +.logout-button { margin-left: auto; border: 0; background: transparent; color: #565c63; font-size: .84rem; font-weight: 720; padding: .5rem 0; white-space: nowrap; } + +.logout-button:hover { color: var(--color-primary); } + +.admin-main { width: min(100% - 2rem, 76rem); margin: 0 auto; padding: clamp(1.7rem, 4vw, 3.8rem) 0 4rem; } + +.admin-page { display: grid; gap: 1.5rem; } + +.page-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; } + +.page-heading p:last-child { margin-bottom: 0; } + +.panel { box-shadow: var(--crr-shadow-panel); padding: clamp(1.1rem, 3vw, 1.65rem); } + +.panel > :last-child { margin-bottom: 0; } + +.team-create-panel, .groups-panel, .settings-panel, .generation-panel, .final-config { display: grid; gap: 1.05rem; } + +.inline-form { display: flex; align-items: end; gap: .8rem; } + +.inline-form > label { min-width: 8rem; } + +.inline-form__grow { flex: 1 1 18rem; } + +.final-config__checkbox { width: fit-content; grid-template-columns: auto 1fr; align-items: center; gap: .5rem; min-height: 2.7rem; } + +.final-config__checkbox input { width: 1.1rem; min-height: 1.1rem; margin: 0; padding: 0; } + +.team-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); gap: 1rem; } + +.group-list { display: flex; flex-wrap: wrap; gap: .65rem; } + +.group-card { display: flex; align-items: center; gap: .7rem; border: 1px solid #e1e2e4; border-radius: .6rem; background: #fafafa; padding: .65rem .75rem; } + +.group-card strong { font-size: .9rem; } + +.group-edit-form { display: grid; gap: .55rem; } + +.group-edit-form .card-actions { margin-top: 0; } + +.team-card { min-height: 16.2rem; border: 1px solid #e1e2e4; border-radius: .8rem; background: #fff; padding: 1.2rem; } + +.team-card__heading, .section-heading { display: flex; align-items: start; justify-content: space-between; gap: 1rem; } + +.team-card h2 { font-size: 1.45rem; } + +.team-number, .game-number { margin-bottom: .3rem; color: var(--color-primary); font-size: .73rem; font-weight: 820; letter-spacing: .1em; text-transform: uppercase; } + +.team-group { color: #5b6067; font-size: .84rem; font-weight: 720; margin-bottom: .8rem; } + +.icon-actions { display: flex; gap: .35rem; } + +.team-card__players { border-top: 1px solid #e7e7e9; margin-top: 1.1rem; padding-top: .95rem; } + +.team-card__players h3 { display: flex; justify-content: space-between; color: #4c5158; font-size: .83rem; letter-spacing: .02em; text-transform: uppercase; } + +.team-card__players h3 span { display: inline-grid; width: 1.35rem; height: 1.35rem; place-items: center; border-radius: 50%; background: #f1f1f1; font-size: .72rem; } + +.player-list { display: grid; gap: .1rem; margin: 0 0 .8rem; padding: 0; list-style: none; } + +.player-list li { display: flex; align-items: center; justify-content: space-between; gap: .5rem; border-radius: .35rem; font-size: .9rem; padding: .33rem .1rem; } + +.player-list li:hover { background: #fafafa; } + +.player-actions { display: flex; gap: .25rem; } + +.player-add, .player-edit { display: flex; align-items: center; gap: .4rem; } + +.player-add input, .player-edit input { min-height: 2.15rem; } + +.team-edit-form { display: grid; gap: .75rem; } + +.card-actions { display: flex; flex-wrap: wrap; gap: .55rem; margin-top: 1rem; } + +.calendar-preview { display: grid; gap: 1rem; border-top: 1px solid var(--color-line); margin-top: .4rem; padding-top: 1.2rem; } + +.preview-summary { display: flex; align-items: baseline; gap: .7rem; } + +.preview-summary strong { font-size: 1.12rem; } + +.preview-summary span { color: var(--color-muted); font-size: .9rem; } + +.calendar-current, .classification-section, .bracket-section { display: grid; gap: 1rem; margin-top: 1rem; } + +.match-card-controls { display: grid; gap: 1.2rem; } + +.match-card-template-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .5rem; } + +.match-card-template-option { display: grid; min-height: 5.8rem; align-content: start; gap: .3rem; border: 1px solid #dedfe2; border-radius: .55rem; background: #fafafa; color: #44494f; padding: .7rem; text-align: left; transition: border-color .15s ease, background .15s ease, box-shadow .15s ease; } + +.match-card-template-option:hover { border-color: #aeb2b7; background: #fff; } + +.match-card-template-option--active { border-color: var(--color-primary); background: #fff7f7; box-shadow: inset 0 0 0 1px var(--color-primary); color: var(--color-ink); } + +.match-card-template-option strong { font-size: .9rem; } + +.match-card-template-option span { color: var(--color-muted); font-size: .75rem; line-height: 1.3; } + +.match-card-actions { display: flex; align-items: end; gap: .8rem; } + +.match-card-actions label { min-width: min(100%, 22rem); } + +.match-card-controls__hint { color: var(--color-muted); font-size: .82rem; margin: -.45rem 0 0; } + +.match-card-preview { display: grid; gap: 1rem; } + +.match-card-preview h2 { margin-bottom: .35rem; } + +.match-card-preview__viewport { overflow-x: auto; overflow-y: hidden; padding: .3rem; } + +.match-card-preview__card { width: var(--match-card-width); height: var(--match-card-height); box-shadow: var(--shadow-soft); } + +.match-card-preview__card .match-result-card { width: 100%; height: 100%; } + +.match-cards-print { display: grid; gap: 1rem; } + +@media screen { + .match-cards-print { display: none; } +} + +.match-cards-print__heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; } + +.match-cards-print__heading h2 { margin: 0; } + +.match-cards-print__heading span { color: var(--color-muted); font-size: .85rem; font-weight: 720; } + +.match-cards-print__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } + +.calendar-groups { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1rem; } + +.calendar-groups--preview { max-height: 33rem; overflow: auto; padding-right: .2rem; } + +.round-card { border: 1px solid #e2e3e6; border-radius: .65rem; background: #fff; overflow: hidden; } + +.round-card h3 { border-bottom: 1px solid #e7e7e9; background: #f7f7f6; margin: 0; padding: .75rem .85rem; } + +.round-card ol { display: grid; gap: .85rem; margin: 0; padding: .85rem; } + +.round-card li { color: #3b3e43; font-size: .88rem; line-height: 1.35; } + +.calendar-match { display: grid; min-width: 0; gap: .18rem; list-style: none; } + +.calendar-match__team { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.calendar-match__versus { color: var(--color-primary); font-size: .7rem; line-height: 1; text-transform: uppercase; } + +.round-card .game-number { display: block; color: #838891; font-size: .63rem; margin-bottom: .15rem; } + +.result-rounds { display: grid; gap: 1.1rem; } + +.result-round { display: grid; gap: 1rem; } + +.result-round__standings { min-height: 2.8rem; border: 1px solid #b8b8c2; border-radius: .55rem; background: #fff; color: #4d4d58; padding: .6rem .85rem; font: inherit; font-size: .86rem; font-weight: 700; cursor: pointer; } + +.result-round__standings--enabled { border-color: #167443; background: #e8f7ed; color: #105b34; } + +.result-round__standings:disabled { cursor: wait; opacity: .7; } + +.result-round__notice { margin: -.45rem 0 0; color: #706f7d; font-size: .9rem; } + +.result-editor { border-top: 1px solid #ececee; padding-top: 1rem; } + +.result-editor:first-of-type { border-top: 0; padding-top: 0; } + +.match-group-label { color: #68707a; font-size: .7rem; font-weight: 760; letter-spacing: .04em; text-transform: uppercase; } + +.game-number .match-group-label { color: #737880; font-size: .66rem; margin-left: .35rem; } + +.score-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: end; gap: .55rem; } + +.score-entry__team { display: grid; grid-template-columns: minmax(0, 1fr) 3.4rem; align-items: center; gap: .6rem; } + +.score-entry__team--away { grid-template-columns: 3.4rem minmax(0, 1fr); text-align: right; } + +.score-entry__team span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.score-entry__team input { min-height: 2.65rem; font-size: 1.25rem; font-weight: 800; padding: .25rem; text-align: center; } + +.score-separator { color: #757a82; font-size: 1.25rem; font-weight: 700; padding-bottom: .45rem; } + +.result-editor__actions { display: flex; gap: .5rem; justify-content: end; margin-top: .75rem; } + +.table-scroll { width: 100%; overflow-x: auto; } + +.classification-table { min-width: 42rem; background: var(--crr-surface); font-size: .9rem; } + +.classification-table--points-only { min-width: 20rem; } + +.classification-table th { border-bottom: 2px solid #d9dbde; color: #656a71; font-size: .7rem; font-weight: 820; letter-spacing: .05em; padding: .6rem .45rem; text-align: center; white-space: nowrap; } + +.classification-table th:nth-child(2) { text-align: left; } + +.classification-table td { border-bottom: 1px solid #e7e8e9; padding: .68rem .45rem; text-align: center; } + +.classification-table tbody tr:last-child td { border-bottom: 0; } + +.classification-table tbody tr:nth-child(odd) { background: #fafafa; } + +.classification-table__position { color: var(--color-primary); font-weight: 820; } + +.classification-table__team { min-width: 13rem; font-weight: 720; text-align: left !important; } + +.classification-table__points { font-weight: 850; } + +.group-standings { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 31rem), 1fr)); gap: 1rem; } + +.group-standing { border: 1px solid #e0e1e3; border-radius: .7rem; background: #fff; overflow: hidden; } + +.group-standing h3 { border-bottom: 2px solid var(--color-primary); color: #464b52; font-size: .82rem; letter-spacing: .06em; margin: 0; padding: .8rem .9rem; text-transform: uppercase; } + +.classification-table--compact { min-width: 0; table-layout: fixed; } + +.classification-table--compact th, .classification-table--compact td { padding: clamp(.32rem, .75vh, .62rem) clamp(.2rem, .5vw, .45rem); } + +.classification-table--compact .classification-table__team { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.modal-backdrop { display: grid; place-items: center; background: rgb(15 15 16 / 52%); inset: 0; padding: 1rem; position: fixed; z-index: 50; } + +.modal { width: min(100%, 31rem); border-radius: .9rem; background: #fff; box-shadow: 0 25px 70px rgb(0 0 0 / 30%); padding: clamp(1.25rem, 5vw, 1.8rem); } + +.modal h2 { font-size: 1.55rem; } + +.modal__body { color: #3e4349; line-height: 1.5; } + +.modal__body p:last-child { margin-bottom: 0; } + +.modal__actions { display: flex; justify-content: end; gap: .6rem; margin-top: 1.5rem; } + +.seed-panel { display: grid; gap: 1rem; } + +.seed-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .75rem; } + +.seed-match { display: grid; gap: .7rem; border: 1px solid #e0e1e3; border-radius: .65rem; background: #fafafa; padding: .85rem; } + +.seed-match h3 { color: #4f545b; font-size: .78rem; letter-spacing: .06em; text-transform: uppercase; } + +.bracket-scroll { width: 100%; border: 1px solid #dddedf; border-radius: .8rem; background: linear-gradient(115deg, #f7f7f6, #fff); overflow-x: auto; overscroll-behavior-x: contain; padding: 1.1rem; } + +.bracket-grid { display: grid; grid-auto-columns: minmax(15.8rem, 18rem); grid-auto-flow: column; align-items: stretch; gap: 4rem; min-width: max-content; } + +.bracket-column { display: flex; flex-direction: column; min-height: 100%; } + +.bracket-column > h3 { border-bottom: 2px solid var(--color-primary); color: #535860; font-size: .74rem; font-weight: 850; letter-spacing: .09em; margin-bottom: .8rem; padding-bottom: .5rem; text-transform: uppercase; } + +.bracket-match-list { display: flex; min-height: 14rem; flex: 1; flex-direction: column; justify-content: space-around; gap: 1.2rem; } + +.bracket-match { border: 1px solid #dcdde0; border-radius: .55rem; background: #fff; box-shadow: 0 4px 12px rgb(0 0 0 / 4%); padding: .55rem; position: relative; } + +.bracket-column:not(:last-child) .bracket-match::after { width: 2.05rem; border-top: 2px solid #aeb2b7; content: ""; position: absolute; right: -2.12rem; top: 50%; } + +.bracket-column:not(:first-child) .bracket-match::before { width: 2.05rem; border-top: 2px solid #aeb2b7; content: ""; position: absolute; left: -2.12rem; top: 50%; } + +.bracket-match__number { color: #868b92; font-size: .63rem; font-weight: 780; letter-spacing: .07em; margin: 0 0 .34rem; text-transform: uppercase; } + +.bracket-team { min-height: 2.25rem; display: grid; grid-template-columns: minmax(0, 1fr) 2rem; align-items: center; border-top: 1px solid #ececee; gap: .35rem; font-size: .82rem; padding: .35rem 0; } + +.bracket-team:first-of-type { border-top: 0; } + +.bracket-team span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.bracket-team strong { color: #3d4147; font-size: 1.05rem; text-align: center; } + +.bracket-team input { width: 2rem; min-height: 1.8rem; border-radius: .3rem; font-size: .95rem; font-weight: 800; padding: .1rem; text-align: center; } + +.bracket-team--winner { color: var(--color-primary); font-weight: 820; } + +.bracket-team--winner strong { color: var(--color-primary); } + +.bracket-match__bye { color: #757a81; font-size: .71rem; font-style: italic; margin: .4rem 0 0; text-align: center; } + +.bracket-match__actions { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-top: .55rem; } + +.bracket-match .form-error, .bracket-match .form-success { font-size: .73rem; } + +.third-place-match { width: min(100%, 21rem); border-color: #c8a653; margin: .25rem auto 0; } + +.third-place-match__title { color: #755d20; font-size: .82rem; font-weight: 850; letter-spacing: .05em; margin-bottom: .55rem; text-transform: uppercase; } + +.champion-card { width: fit-content; min-width: min(100%, 18rem); border: 2px solid var(--color-primary); border-radius: .7rem; background: #fff; margin: 1.25rem auto 0; padding: 1.15rem 2rem; text-align: center; } + +.champion-card p { color: var(--color-primary); font-size: .7rem; font-weight: 850; letter-spacing: .12em; margin-bottom: .3rem; text-transform: uppercase; } + +.champion-card strong { font-size: 1.45rem; } + +.public-page { min-height: 100vh; background: #f8f8f6; } + +.public-hero { min-height: min(38vw, 23rem); display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: clamp(1rem, 4vw, 4rem); overflow: hidden; background: #191a1c; color: #fff; padding: clamp(1.5rem, 5vw, 4.5rem) max(1.25rem, calc((100vw - 84rem) / 2)); position: relative; } + +.public-hero::after { width: min(42vw, 40rem); height: min(42vw, 40rem); border: 3rem solid rgb(237 28 36 / 95%); border-radius: 50%; content: ""; position: absolute; right: -18rem; top: -22rem; } + +.public-hero .app-logo { position: relative; z-index: 1; } + +.public-hero__title { min-width: 0; position: relative; z-index: 1; } + +.public-hero__title .eyebrow { color: #ffa1a5; } + +.public-hero h1 { max-width: 21ch; font-size: clamp(2.3rem, 6vw, 5.5rem); margin: 0; overflow-wrap: anywhere; } + +.live-status { display: inline-flex; align-items: center; gap: .5rem; align-self: start; border: 1px solid rgb(255 255 255 / 18%); border-radius: 999px; background: rgb(255 255 255 / 9%); font-size: .77rem; font-weight: 730; padding: .45rem .7rem; position: relative; white-space: nowrap; z-index: 1; } + +.live-status__dot { width: .48rem; height: .48rem; border-radius: 50%; background: #58df9a; box-shadow: 0 0 0 .2rem rgb(88 223 154 / 12%); } + +.live-status--waiting .live-status__dot { background: #ffbd4a; box-shadow: 0 0 0 .2rem rgb(255 189 74 / 12%); } + +.public-loading, .public-unavailable { width: min(100% - 2rem, 40rem); border-radius: .85rem; background: #fff; box-shadow: var(--shadow-soft); margin: 3rem auto; padding: 2rem; text-align: center; } + +.public-unavailable { display: grid; gap: .7rem; } + +.public-unavailable h2 { font-size: 1.5rem; } + +.public-unavailable p { color: var(--color-muted); } + +.public-content { width: min(100% - 2rem, 84rem); margin: 0 auto; padding: clamp(2rem, 6vw, 5.5rem) 0; } + +.updated-at { color: #848991; font-size: .75rem; margin: 0 0 .5rem; text-align: right; } + +.public-offline-notice { border: 1px solid #f4d798; border-radius: .5rem; background: #fff9e9; color: #805600; font-size: .86rem; margin: 0 0 1.4rem; padding: .75rem .85rem; } + +.public-section { border-top: 1px solid #d9dbdc; padding: clamp(2rem, 5vw, 4.5rem) 0; } + +.public-section__heading { margin-bottom: clamp(1.2rem, 3vw, 2.2rem); } + +.public-section__heading h2 { font-size: clamp(1.8rem, 3.2vw, 3.15rem); margin: 0; } + +.public-rounds { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); gap: 1.2rem; } + +.public-round { border: 1px solid #e0e1e2; border-radius: .8rem; background: #fff; overflow: hidden; } + +.public-round h3 { border-bottom: 2px solid var(--color-primary); color: #40454b; font-size: .83rem; letter-spacing: .08em; margin: 0; padding: .95rem 1rem; text-transform: uppercase; } + +.public-match { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; border-top: 1px solid #eeeff0; gap: .6rem; padding: .85rem 1rem; } + +.public-match:first-of-type { border-top: 0; } + +.public-match__group { grid-column: 1 / -1; color: #777c83; font-size: .66rem; font-weight: 780; letter-spacing: .07em; margin: 0 -.1rem -.2rem; text-transform: uppercase; } + +.public-match__team { min-width: 0; font-size: clamp(.92rem, 1.4vw, 1.08rem); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.public-match__team--away { text-align: right; } + +.public-match__score { min-width: 4rem; color: var(--color-primary); font-size: clamp(1.15rem, 2.2vw, 1.6rem); letter-spacing: -.04em; text-align: center; } + +.public-match__score i { color: #989da4; font-style: normal; margin: 0 .12rem; } + +.public-empty { border: 1px dashed #c9cbd0; border-radius: .65rem; background: #fff; color: #737880; padding: 1.2rem; text-align: center; } + +.public-section .classification-table { font-size: clamp(.86rem, 1.3vw, 1.02rem); } + +.public-section .classification-table th { font-size: .68em; } + +.public-group-standings { display: grid; gap: 1.2rem; } + +.public-group-standing { border: 1px solid #e0e1e2; border-radius: .8rem; background: #fff; overflow: hidden; } + +.public-group-standing h3 { border-bottom: 2px solid var(--color-primary); color: #44494f; font-size: .84rem; letter-spacing: .08em; margin: 0; padding: .9rem 1rem; text-transform: uppercase; } + +.public-section--final { padding-bottom: 3rem; } + +.public-section .bracket-scroll { border-color: #d7d9da; background: #fff; box-shadow: var(--shadow-soft); padding: clamp(1rem, 3vw, 2rem); } + +.public-section .bracket-grid { grid-auto-columns: minmax(17rem, 20rem); gap: 4.5rem; } + +.public-section .bracket-team { font-size: .91rem; min-height: 2.5rem; } + +.public-section .bracket-team strong { font-size: 1.25rem; } + +.public-champion { display: grid; justify-items: center; border-radius: 1rem; background: var(--color-primary); box-shadow: 0 14px 30px rgb(185 15 23 / 22%); color: #fff; margin-top: 1.5rem; padding: clamp(2rem, 6vw, 4.5rem) 1.5rem; position: relative; text-align: center; } + +.public-champion::before { width: 4.5rem; height: 4.5rem; border: 2px solid rgb(255 255 255 / 55%); border-radius: 50%; content: ""; position: absolute; top: -2.25rem; } + +.public-champion p { font-size: .78rem; font-weight: 850; letter-spacing: .16em; margin: 0 0 .5rem; text-transform: uppercase; } + +.public-champion strong { font-size: clamp(2rem, 5vw, 4rem); letter-spacing: -.05em; } + +.display-link-panel { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; } + +.display-link-panel h2 { margin-bottom: .45rem; } + +.display-link-panel p { margin-bottom: 0; } + +.display-link-panel > a { max-width: 100%; color: var(--color-primary-dark); font-size: .92rem; font-weight: 750; overflow-wrap: anywhere; text-underline-offset: 4px; } + +.display-control-panel { display: grid; gap: 1.2rem; } + +.display-control-panel .section-heading p { margin: .35rem 0 0; } + +.display-setting-section { display: grid; gap: 1rem; } + +.display-setting-section + .display-setting-section { border-top: 1px solid var(--color-line); padding-top: 1.2rem; } + +.display-option-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); gap: .85rem; } + +.display-option { display: grid; grid-template-rows: auto auto 1fr auto; align-items: start; border: 1px solid #dedfe2; border-radius: .75rem; background: #fafafa; padding: 1rem; } + +.display-option--active { border-color: var(--color-primary); background: #fff7f7; box-shadow: inset 0 0 0 1px var(--color-primary); } + +.display-option__status { width: fit-content; border-radius: 999px; background: var(--color-primary); color: #fff !important; font-size: .66rem !important; font-weight: 820; letter-spacing: .07em; margin-bottom: .8rem !important; padding: .28rem .48rem; text-transform: uppercase; } + +.display-option h3 { font-size: 1.2rem; } + +.display-option p { color: var(--color-muted); font-size: .88rem; line-height: 1.45; } + +.display-option .button { justify-self: start; } + +.display-page { height: 100vh; height: 100dvh; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; background: #f2f1ed; } + +.display-header { min-height: 5.4rem; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 1.1rem; background: #18191b; color: #fff; padding: .8rem clamp(1.25rem, 3.2vw, 4rem); } + +.display-header .app-logo__text { color: #fff; } + +.display-header__title { min-width: 0; border-left: 1px solid rgb(255 255 255 / 18%); padding-left: 1.1rem; } + +.display-header__title .eyebrow { color: #ffa1a5; margin-bottom: .2rem; } + +.display-header__title > p:last-child { margin: 0; font-size: clamp(1.05rem, 1.6vw, 1.45rem); font-weight: 780; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.display-connection { display: inline-flex; align-items: center; gap: .5rem; color: #d9dadd; font-size: .76rem; font-weight: 720; white-space: nowrap; } + +.display-connection > span { width: .52rem; height: .52rem; border-radius: 50%; background: #58df9a; box-shadow: 0 0 0 .22rem rgb(88 223 154 / 13%); } + +.display-connection--waiting > span { background: #ffbd4a; box-shadow: 0 0 0 .22rem rgb(255 189 74 / 13%); } + +.display-content { min-height: 0; display: grid; position: relative; padding: clamp(1.25rem, 2.6vw, 3rem) clamp(1.25rem, 3.2vw, 4rem) clamp(1.6rem, 3vw, 3.5rem); } + +.display-zoom { min-width: 0; min-height: 0; display: grid; transform-origin: top left; } + +.display-message { width: min(100% - 2rem, 40rem); align-self: center; justify-self: center; border: 1px solid #dddde0; border-radius: 1rem; background: #fff; box-shadow: var(--shadow-soft); color: var(--color-muted); font-size: 1.2rem; padding: 2.5rem; text-align: center; } + +.display-message h1 { color: var(--color-ink); font-size: 2rem; } + +.display-message p { line-height: 1.5; } + +.display-offline-notice { border-radius: .4rem; background: #ffbd4a; color: #392600; font-size: .72rem; font-weight: 760; left: 50%; margin: 0; padding: .35rem .65rem; position: absolute; top: .45rem; transform: translateX(-50%); z-index: 2; } + +.display-updated-at { color: #858990; font-size: .67rem; margin: 0; position: absolute; bottom: .65rem; right: clamp(1.25rem, 3.2vw, 4rem); } + +.display-panel { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: clamp(1rem, 2vh, 2rem); } + +.display-panel__heading { display: flex; align-items: end; justify-content: space-between; gap: 1.5rem; } + +.display-panel__heading .eyebrow { margin-bottom: .32rem; } + +.display-panel__heading h1 { font-size: clamp(2.1rem, 4.2vw, 4.8rem); margin: 0; } + +.display-panel__context { border: 1px solid #d0d1d3; border-radius: 999px; background: #fff; color: #555a61; font-size: clamp(.78rem, 1.1vw, 1rem); font-weight: 780; margin: 0 0 .35rem; padding: .52rem .8rem; white-space: nowrap; } + +.display-fit { min-height: 0; overflow: hidden; } + +.display-fit__content { width: 100%; transform-origin: top center; } + +.display-results-grid { min-height: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); align-content: center; gap: clamp(.7rem, 1.5vh, 1.25rem); overflow: hidden; } + +.display-match { border: 1px solid #dcdde0; border-radius: clamp(.65rem, 1vw, 1rem); background: #fff; box-shadow: 0 7px 24px rgb(25 25 25 / 5%); padding: clamp(.85rem, 1.6vh, 1.45rem) clamp(1rem, 1.8vw, 1.7rem); } + +.display-match__meta { color: #71767d; font-size: clamp(.62rem, .85vw, .78rem); font-weight: 800; letter-spacing: .07em; margin-bottom: clamp(.55rem, 1vh, .9rem); text-transform: uppercase; } + +.display-match__scoreline { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: clamp(.5rem, 1.2vw, 1.2rem); } + +.display-match__team { min-width: 0; font-size: clamp(1rem, 1.75vw, 1.75rem); font-weight: 730; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.display-match__team--away { text-align: right; } + +.display-match__scoreline strong { min-width: clamp(4.5rem, 7vw, 7rem); color: var(--color-primary); font-size: clamp(1.65rem, 3.2vw, 3.2rem); letter-spacing: -.05em; text-align: center; } + +.display-match__scoreline i { color: #a0a4aa; font-style: normal; } + +.display-classification-grid { min-height: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 32rem), 1fr)); align-content: start; gap: clamp(.8rem, 1.6vw, 1.5rem); overflow: hidden; } + +.display-standing { min-width: 0; border: 1px solid #dcdde0; border-radius: .85rem; background: #fff; box-shadow: 0 7px 24px rgb(25 25 25 / 5%); overflow: hidden; } + +.display-standing h2 { border-bottom: 3px solid var(--color-primary); color: #454a50; font-size: clamp(.76rem, 1vw, 1rem); letter-spacing: .09em; margin: 0; padding: clamp(.6rem, 1vh, .9rem) 1rem; text-transform: uppercase; } + +.display-standing .table-scroll { overflow: hidden; } + +.display-standing .classification-table { font-size: clamp(.68rem, .9vw, .94rem); } + +.display-standing .classification-table th { font-size: .65em; } + +.display-standing__empty { color: var(--color-muted); margin: 0; padding: 1rem; text-align: center; } + +.display-empty { align-self: center; border: 1px dashed #bfc2c7; border-radius: .8rem; background: rgb(255 255 255 / 75%); color: var(--color-muted); font-size: clamp(1rem, 1.5vw, 1.35rem); padding: 2rem; text-align: center; } + +@media (min-width: 761px) and (max-width: 1080px) { + .match-card-template-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 760px) { + .admin-header { gap: .7rem; padding: .6rem 1rem; } + .admin-brand .app-logo__text { display: none; } + .admin-nav { flex: 1; overflow-x: auto; } + .admin-nav a { padding: .1rem .45rem; } + .logout-button { font-size: 0; } + .logout-button::after { content: "Sair"; font-size: .84rem; } + .inline-form { align-items: stretch; flex-direction: column; } + .inline-form > label { min-width: 0; } + .inline-form__grow { flex-basis: auto; } + .inline-form .button { width: 100%; } + .match-card-template-grid { grid-template-columns: 1fr; } + .match-card-actions { align-items: stretch; flex-direction: column; } + .match-card-actions label { min-width: 0; } + .match-card-actions .button { width: 100%; } + .match-cards-print__grid { grid-template-columns: 1fr; } + .score-entry { gap: .3rem; } + .score-entry__team { grid-template-columns: minmax(0, 1fr) 2.75rem; gap: .35rem; } + .score-entry__team--away { grid-template-columns: 2.75rem minmax(0, 1fr); } + .score-entry__team input { font-size: 1.1rem; } + .score-entry__team span { font-size: .82rem; } + .modal__actions { flex-direction: column-reverse; } + .modal__actions .button { width: 100%; } + .public-hero { grid-template-columns: auto 1fr; min-height: 17rem; } + .public-hero .app-logo--large img { width: 3.8rem; height: 3.8rem; } + .live-status { grid-column: 1 / -1; justify-self: start; } + .public-match { gap: .35rem; padding: .75rem; } + .public-section .bracket-grid { grid-auto-columns: minmax(15.5rem, 17rem); gap: 3.2rem; } + .bracket-column:not(:last-child) .bracket-match::after { width: 1.65rem; right: -1.72rem; } + .bracket-column:not(:first-child) .bracket-match::before { width: 1.65rem; left: -1.72rem; } + .display-link-panel { align-items: start; flex-direction: column; } + .display-header { grid-template-columns: auto minmax(0, 1fr); } + .display-header .app-logo__text { display: none; } + .display-connection { grid-column: 1 / -1; } +} + +@media print { + @page { size: A4 portrait; margin: 10mm; } + + html, body { background: #fff !important; } + .admin-header, .match-cards-page > :not(.match-cards-print), .match-cards-print__heading { display: none !important; } + .admin-main { width: auto; margin: 0; padding: 0; } + .match-cards-page, .match-cards-print { display: block; } + .match-cards-print__grid { grid-template-columns: repeat(2, var(--match-card-width)); gap: var(--match-card-gap); } + .match-cards-print--rows-5 .match-cards-print__grid { grid-auto-rows: var(--match-card-height); } + .match-cards-print--rows-5 .match-result-card { height: var(--match-card-height); min-height: 0; padding: .45rem .75rem 0; } + .match-cards-print--rows-5 .match-result-card__header { gap: .15rem; padding-bottom: .3rem; } + .match-cards-print--rows-5 .match-result-card__header strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .match-cards-print--rows-5 .match-result-card__team { padding: .1rem 0; } + .match-cards-print--rows-5 .match-result-card__final-score > span { height: 2.3rem; } +} + +@media (max-height: 720px) and (min-width: 761px) { + .display-header { min-height: 4.3rem; padding-bottom: .5rem; padding-top: .5rem; } + .display-header .app-logo img { width: 1.9rem; height: 1.9rem; } + .display-content { padding-bottom: 1.8rem; padding-top: 1rem; } + .display-panel { gap: .75rem; } + .display-panel__heading h1 { font-size: clamp(1.9rem, 3.6vw, 3.4rem); } +} + +@media (max-width: 430px) { + .admin-main { width: min(100% - 1.25rem, 76rem); } + .admin-nav a { font-size: .8rem; } + .team-card { padding: 1rem; } + .player-list li { align-items: start; flex-direction: column; } + .player-actions { margin-left: -.2rem; } + .public-content { width: min(100% - 1.25rem, 84rem); } + .public-hero { padding-left: 1.25rem; padding-right: 1.25rem; } + .public-hero h1 { font-size: 2.2rem; } +} + +/* Dashboard */ +.dashboard-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .85rem; } +.dashboard-stat { display: grid; align-content: start; gap: .45rem; } +.dashboard-stat p { color: var(--color-muted); font-size: .78rem; font-weight: 760; margin: 0; text-transform: uppercase; letter-spacing: .05em; } +.dashboard-stat strong { font-size: clamp(1.45rem, 2.8vw, 2.25rem); letter-spacing: -.045em; } +.dashboard-stat small { color: var(--color-muted); line-height: 1.4; } +.dashboard-progress { height: .38rem; overflow: hidden; border-radius: 999px; background: #ececef; } +.dashboard-progress span { display: block; height: 100%; border-radius: inherit; background: var(--color-primary); transition: width .2s ease; } +.dashboard-main-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(18rem, .65fr); gap: 1rem; align-items: start; } +.dashboard-current, .dashboard-upcoming, .dashboard-leaders { display: grid; gap: 1rem; } +.dashboard-current__meta { color: var(--color-muted); font-size: .78rem; font-weight: 760; letter-spacing: .04em; margin: 0; text-transform: uppercase; } +.dashboard-current__teams { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 1rem; border: 1px solid #e3e4e6; border-radius: .75rem; background: #fafafa; padding: 1.15rem; } +.dashboard-current__teams > div { display: grid; gap: .18rem; } +.dashboard-current__teams span { color: var(--color-muted); font-size: .72rem; font-weight: 750; text-transform: uppercase; } +.dashboard-current__teams strong { font-size: clamp(1.05rem, 2vw, 1.45rem); } +.dashboard-current__teams b { color: var(--color-primary); font-size: 1rem; } +.dashboard-current__away { text-align: right; } +.dashboard-score-form { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto; align-items: end; gap: .65rem; } +.dashboard-score-form > span { align-self: center; color: #9a9ea4; font-weight: 800; padding-top: 1.35rem; } +.dashboard-score-form input { max-width: 7rem; font-size: 1.15rem; font-weight: 800; text-align: center; } +.dashboard-current__empty { padding: 1.4rem; } +.dashboard-upcoming-list { display: grid; gap: .65rem; margin: 0; padding: 0; list-style: none; } +.dashboard-upcoming-list li { display: grid; gap: .2rem; border-bottom: 1px solid #e8e9eb; padding: 0 0 .7rem; } +.dashboard-upcoming-list li:last-child { border-bottom: 0; padding-bottom: 0; } +.dashboard-upcoming-list span { color: var(--color-primary); font-size: .7rem; font-weight: 820; letter-spacing: .06em; text-transform: uppercase; } +.dashboard-upcoming-list strong { font-size: .95rem; } +.dashboard-upcoming-list strong i { color: #9b9fa5; font-size: .75rem; font-style: normal; font-weight: 700; text-transform: uppercase; } +.dashboard-upcoming-list small { color: var(--color-muted); } +.dashboard-leader-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: .75rem; } +.dashboard-leader-card { display: grid; gap: .28rem; border: 1px solid #e3e4e6; border-radius: .7rem; background: #fafafa; padding: .9rem; } +.dashboard-leader-card p { color: var(--color-primary); font-size: .7rem; font-weight: 820; letter-spacing: .07em; margin: 0; text-transform: uppercase; } +.dashboard-leader-card strong { font-size: 1.05rem; } +.dashboard-leader-card span { color: var(--color-muted); font-size: .82rem; } +.dashboard-warning { border: 1px solid #ead8a9; background: #fff9e8; color: #70520a; line-height: 1.5; } + +/* Automatic final-stage qualification */ +.auto-seed-box { display: grid; gap: .85rem; border: 1px solid #dedfe2; border-radius: .75rem; background: #fafafa; padding: 1rem; } +.auto-seed-box h3 { margin-bottom: .25rem; font-size: 1.08rem; } +.auto-seed-box .muted { margin-bottom: 0; } +.auto-seed-preview { border-left: 3px solid var(--color-primary); background: #fff; padding: .75rem .85rem; } +.auto-seed-preview > p { margin-bottom: .6rem; font-size: .85rem; } +.auto-seed-qualifiers { display: flex; flex-wrap: wrap; gap: .4rem; } +.auto-seed-qualifiers span { border: 1px solid #e0e1e3; border-radius: 999px; background: #f7f7f7; color: #4f545a; font-size: .76rem; font-weight: 700; padding: .35rem .55rem; } + +/* Public team-focused view */ +.public-team-filter { display: flex; align-items: end; gap: .65rem; border: 1px solid #e0e1e3; border-radius: .8rem; background: #fff; padding: .9rem 1rem; } +.public-team-filter label { min-width: min(100%, 22rem); } +.public-team-summary { display: grid; grid-template-columns: minmax(14rem, .8fr) minmax(20rem, 1.2fr) minmax(15rem, .9fr); align-items: center; gap: 1rem; border-radius: 1rem; background: #18191b; color: #fff; padding: clamp(1.15rem, 3vw, 1.75rem); } +.public-team-summary .eyebrow { color: #ffa1a5; } +.public-team-summary h2 { margin-bottom: .25rem; font-size: clamp(1.45rem, 3vw, 2.2rem); } +.public-team-summary > div:first-child > p:last-child { color: #c6c8cc; margin: 0; } +.public-team-summary__stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .55rem; } +.public-team-summary__stats article { display: grid; gap: .2rem; border: 1px solid rgb(255 255 255 / 14%); border-radius: .65rem; background: rgb(255 255 255 / 5%); padding: .75rem; } +.public-team-summary__stats span, .public-team-summary__next > span { color: #afb2b7; font-size: .69rem; font-weight: 760; letter-spacing: .05em; text-transform: uppercase; } +.public-team-summary__stats strong { font-size: 1.2rem; } +.public-team-summary__next { display: grid; gap: .3rem; border-left: 1px solid rgb(255 255 255 / 15%); padding-left: 1rem; } +.public-team-summary__next strong { font-size: .96rem; } +.public-team-summary__next p { color: #c6c8cc; font-size: .82rem; margin: 0; } +.public-team-link { appearance: none; border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 3px; transition: color .15s ease, text-decoration-color .15s ease; } +.public-team-link:hover { color: var(--color-primary); text-decoration-color: currentColor; } +.classification-table__team-button { appearance: none; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-weight: inherit; padding: 0; text-align: left; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 3px; } +.classification-table__team-button:hover { color: var(--color-primary); text-decoration-color: currentColor; } +.classification-table tbody tr.classification-table__row--highlight { background: #fff1f1; box-shadow: inset 3px 0 0 var(--color-primary); } + +/* Next-match display panel */ +.display-next-match-layout { min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: clamp(.8rem, 2vh, 1.5rem); } +.display-next-match-card { min-height: 0; display: grid; align-content: center; gap: clamp(1rem, 3vh, 2.2rem); border: 1px solid #d8d9dc; border-radius: clamp(.8rem, 1.4vw, 1.3rem); background: #fff; box-shadow: 0 10px 30px rgb(25 25 25 / 6%); padding: clamp(1.5rem, 4vw, 4rem); } +.display-next-match-card__meta { color: var(--color-primary); font-size: clamp(.75rem, 1.2vw, 1.05rem); font-weight: 820; letter-spacing: .1em; margin: 0; text-align: center; text-transform: uppercase; } +.display-next-match-card__teams { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: clamp(1rem, 3vw, 3rem); } +.display-next-match-card__teams > div { min-width: 0; display: grid; gap: .35rem; } +.display-next-match-card__teams span { color: #777b82; font-size: clamp(.7rem, 1vw, 1rem); font-weight: 780; letter-spacing: .06em; text-transform: uppercase; } +.display-next-match-card__teams strong { min-width: 0; font-size: clamp(2rem, 5.3vw, 5.8rem); line-height: .96; overflow-wrap: anywhere; } +.display-next-match-card__teams b { color: var(--color-primary); font-size: clamp(1.4rem, 3vw, 3rem); } +.display-next-match-card__team--away { text-align: right; } +.display-following-match { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; border: 1px solid #dcdde0; border-radius: .8rem; background: #fff; padding: clamp(.8rem, 1.5vw, 1.2rem) clamp(1rem, 2vw, 1.5rem); } +.display-following-match p { color: var(--color-primary); font-size: .68rem; font-weight: 820; letter-spacing: .08em; margin-bottom: .2rem; text-transform: uppercase; } +.display-following-match strong { font-size: clamp(.85rem, 1.2vw, 1.1rem); } +.display-following-match > span { min-width: 0; font-size: clamp(.95rem, 1.5vw, 1.35rem); font-weight: 720; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.display-following-match i { color: #9b9fa5; font-size: .75em; font-style: normal; text-transform: uppercase; } + +@media (max-width: 980px) { + .dashboard-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-main-grid { grid-template-columns: 1fr; } + .public-team-summary { grid-template-columns: 1fr; } + .public-team-summary__next { border-left: 0; border-top: 1px solid rgb(255 255 255 / 15%); padding-left: 0; padding-top: .85rem; } +} + +@media (max-width: 760px) { + .dashboard-summary-grid { grid-template-columns: 1fr 1fr; } + .dashboard-score-form { grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); } + .dashboard-score-form .button { grid-column: 1 / -1; width: 100%; } + .dashboard-current__teams { gap: .5rem; padding: .85rem; } + .public-team-filter { align-items: stretch; flex-direction: column; } + .public-team-filter label { min-width: 0; } + .public-team-summary__stats { grid-template-columns: 1fr; } + .display-next-match-card__teams { gap: .65rem; } + .display-following-match { align-items: start; flex-direction: column; gap: .4rem; } + .display-following-match > span { white-space: normal; } +} + +@media (max-width: 460px) { + .dashboard-summary-grid { grid-template-columns: 1fr; } + .dashboard-current__teams { grid-template-columns: 1fr; text-align: center; } + .dashboard-current__away { text-align: center; } + .dashboard-score-form { grid-template-columns: 1fr auto 1fr; } + .display-next-match-card__teams { grid-template-columns: 1fr; text-align: center; } + .display-next-match-card__team--away { text-align: center; } +} diff --git a/apps/tournament/src/client/types.ts b/apps/tournament/src/client/types.ts new file mode 100644 index 0000000..b8379fa --- /dev/null +++ b/apps/tournament/src/client/types.ts @@ -0,0 +1 @@ +export * from '../shared/types/tournament'; diff --git a/apps/tournament/src/client/views/CalendarView.vue b/apps/tournament/src/client/views/CalendarView.vue new file mode 100644 index 0000000..134a19f --- /dev/null +++ b/apps/tournament/src/client/views/CalendarView.vue @@ -0,0 +1,305 @@ + + + diff --git a/apps/tournament/src/client/views/DashboardView.vue b/apps/tournament/src/client/views/DashboardView.vue new file mode 100644 index 0000000..02512bf --- /dev/null +++ b/apps/tournament/src/client/views/DashboardView.vue @@ -0,0 +1,234 @@ + + + diff --git a/apps/tournament/src/client/views/DisplaySettingsView.vue b/apps/tournament/src/client/views/DisplaySettingsView.vue new file mode 100644 index 0000000..1d9a6c2 --- /dev/null +++ b/apps/tournament/src/client/views/DisplaySettingsView.vue @@ -0,0 +1,166 @@ + + + diff --git a/apps/tournament/src/client/views/DisplayView.vue b/apps/tournament/src/client/views/DisplayView.vue new file mode 100644 index 0000000..3441f3b --- /dev/null +++ b/apps/tournament/src/client/views/DisplayView.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/tournament/src/client/views/FinalStageView.vue b/apps/tournament/src/client/views/FinalStageView.vue new file mode 100644 index 0000000..f24ce14 --- /dev/null +++ b/apps/tournament/src/client/views/FinalStageView.vue @@ -0,0 +1,508 @@ + + + diff --git a/apps/tournament/src/client/views/LoginView.vue b/apps/tournament/src/client/views/LoginView.vue new file mode 100644 index 0000000..d3b36fa --- /dev/null +++ b/apps/tournament/src/client/views/LoginView.vue @@ -0,0 +1,56 @@ + + + diff --git a/apps/tournament/src/client/views/MatchCardsView.vue b/apps/tournament/src/client/views/MatchCardsView.vue new file mode 100644 index 0000000..28a0c8c --- /dev/null +++ b/apps/tournament/src/client/views/MatchCardsView.vue @@ -0,0 +1,149 @@ + + + diff --git a/apps/tournament/src/client/views/PublicResultsView.vue b/apps/tournament/src/client/views/PublicResultsView.vue new file mode 100644 index 0000000..a8d5f85 --- /dev/null +++ b/apps/tournament/src/client/views/PublicResultsView.vue @@ -0,0 +1,209 @@ + + + diff --git a/apps/tournament/src/client/views/ResultsView.vue b/apps/tournament/src/client/views/ResultsView.vue new file mode 100644 index 0000000..57d73eb --- /dev/null +++ b/apps/tournament/src/client/views/ResultsView.vue @@ -0,0 +1,196 @@ + + + diff --git a/apps/tournament/src/client/views/TeamsView.vue b/apps/tournament/src/client/views/TeamsView.vue new file mode 100644 index 0000000..d88be8d --- /dev/null +++ b/apps/tournament/src/client/views/TeamsView.vue @@ -0,0 +1,498 @@ + + + diff --git a/apps/tournament/src/server/app.integration.test.ts b/apps/tournament/src/server/app.integration.test.ts new file mode 100644 index 0000000..f18e686 --- /dev/null +++ b/apps/tournament/src/server/app.integration.test.ts @@ -0,0 +1,1181 @@ +import type { FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { + CalendarGeneration, + CalendarGenerationPreview, + CalendarResponse, + FinalSeedPreview, + FinalStage, + Group, + LeagueMatch, + PublicTournamentState, + Team, +} from '../shared/types/tournament.js'; +import { SESSION_COOKIE_NAME } from './auth.js'; +import { createApp } from './app.js'; + +const authentication = { + adminPassword: 'integration-test-password', + sessionSecret: 'integration-test-session-secret', +}; + +let app: FastifyInstance | undefined; + +function testApp(): FastifyInstance { + if (!app) { + throw new Error('The test application has not been created.'); + } + return app; +} + +async function login(): Promise { + const response = await testApp().inject({ + method: 'POST', + url: '/api/auth/login', + payload: { password: authentication.adminPassword }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ authenticated: true }); + const session = response.cookies.find((cookie) => cookie.name === SESSION_COOKIE_NAME); + if (!session) { + throw new Error('The login response did not set a session cookie.'); + } + return `${SESSION_COOKIE_NAME}=${session.value}`; +} + +async function createTeam(session: string, number: number, name: string, groupId?: number): Promise { + if (groupId === undefined) { + const groupsResponse = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + expect(groupsResponse.statusCode).toBe(200); + const [defaultGroup] = groupsResponse.json(); + groupId = defaultGroup.id; + } + const response = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { number, name, groupId }, + }); + + expect(response.statusCode).toBe(201); + return response.json(); +} + +async function generatePreview(session: string, legs: 1 | 2): Promise { + const response = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate-preview', + headers: { cookie: session }, + payload: { legs }, + }); + expect(response.statusCode).toBe(200); + return response.json(); +} + +async function saveGeneration( + session: string, + generation: CalendarGeneration, + confirmReplace = false, +): Promise { + const response = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate', + headers: { cookie: session }, + payload: { generation, ...(confirmReplace ? { confirmReplace: true } : {}) }, + }); + expect(response.statusCode).toBe(200); + return response.json(); +} + +async function includeRoundInStandings(session: string, roundNumber: number): Promise { + const response = await testApp().inject({ + method: 'PUT', + url: `/api/admin/rounds/${roundNumber}/standings`, + headers: { cookie: session }, + payload: { countsTowardStandings: true }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ roundNumber, countsTowardStandings: true }); +} + +type SchedulableMatch = Pick; + +function scheduleOf(matches: readonly SchedulableMatch[]) { + return matches.map((match) => ({ + gameNumber: match.gameNumber, + roundNumber: match.roundNumber, + groupId: match.group?.id ?? null, + teamAId: match.teamA.id, + teamBId: match.teamB.id, + })); +} + +function unorderedPair(teamAId: number, teamBId: number): string { + return [teamAId, teamBId].sort((first, second) => first - second).join('-'); +} + +async function readStreamChunk(reader: ReadableStreamDefaultReader): Promise { + let timeout: ReturnType | undefined; + try { + const result = await Promise.race([ + reader.read(), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error('Timed out waiting for the SSE event.')), 2_000); + }), + ]); + return new TextDecoder().decode(result.value); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +describe('tournament manager HTTP API', () => { + beforeEach(async () => { + app = await createApp({ + databasePath: ':memory:', + authentication, + basePath: '/jogo/', + }); + await app.ready(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + app = undefined; + } + }); + + it('rejects unauthenticated administration and accepts only the configured login password', async () => { + const unauthenticated = await testApp().inject({ + method: 'GET', + url: '/api/admin/teams', + }); + expect(unauthenticated.statusCode).toBe(401); + expect(unauthenticated.json()).toEqual({ error: expect.any(String) }); + + const invalidLogin = await testApp().inject({ + method: 'POST', + url: '/api/auth/login', + payload: { password: 'incorrect-password' }, + }); + expect(invalidLogin.statusCode).toBe(401); + expect(invalidLogin.json()).toEqual({ error: expect.any(String) }); + + const session = await login(); + const authenticatedSession = await testApp().inject({ + method: 'GET', + url: '/api/auth/session', + headers: { cookie: session }, + }); + expect(authenticatedSession.statusCode).toBe(200); + expect(authenticatedSession.json()).toEqual({ authenticated: true }); + }); + + it('scopes the administration session cookie to the deployment path', async () => { + const response = await testApp().inject({ + method: 'POST', + url: '/api/auth/login', + payload: { password: authentication.adminPassword }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['set-cookie']).toContain('Path=/jogo/'); + }); + + it('publishes the admin-selected panel and zoom to the public display state', async () => { + const initialPublicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(initialPublicState.statusCode).toBe(200); + expect(initialPublicState.json().display).toEqual({ activePanel: 'latest-results', zoomPercent: 100 }); + + const unauthenticated = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + payload: { activePanel: 'classification', zoomPercent: 137 }, + }); + expect(unauthenticated.statusCode).toBe(401); + + const session = await login(); + const invalid = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'unknown-panel', zoomPercent: 137 }, + }); + expect(invalid.statusCode).toBe(400); + + const invalidZoom = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'classification', zoomPercent: 401 }, + }); + expect(invalidZoom.statusCode).toBe(400); + + const updated = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'classification', zoomPercent: 137 }, + }); + expect(updated.statusCode).toBe(200); + expect(updated.json()).toEqual({ activePanel: 'classification', zoomPercent: 137 }); + + const adminSettings = await testApp().inject({ + method: 'GET', + url: '/api/admin/display', + headers: { cookie: session }, + }); + expect(adminSettings.statusCode).toBe(200); + expect(adminSettings.json()).toEqual({ activePanel: 'classification', zoomPercent: 137 }); + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + expect(publicState.json().display).toEqual({ activePanel: 'classification', zoomPercent: 137 }); + }); + + it('notifies connected displays when the selected panel changes', async () => { + const session = await login(); + await testApp().listen({ host: '127.0.0.1', port: 0 }); + const address = testApp().server.address(); + if (!address || typeof address === 'string') throw new Error('The test server did not expose a TCP address.'); + + const controller = new AbortController(); + const response = await fetch(`http://127.0.0.1:${address.port}/api/public/events`, { signal: controller.signal }); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + if (!reader) throw new Error('The SSE response did not expose a readable stream.'); + + try { + expect(await readStreamChunk(reader)).toContain('retry: 3000'); + + const updated = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'classification', zoomPercent: 137 }, + }); + expect(updated.statusCode).toBe(200); + expect(await readStreamChunk(reader)).toContain('event: state-changed'); + } finally { + await reader.cancel(); + controller.abort(); + } + }); + + it('creates teams in Grupo A and rejects globally duplicated team numbers', async () => { + const session = await login(); + const created = await createTeam(session, 10, 'Falcons'); + expect(created).toMatchObject({ + id: expect.any(Number), + number: 10, + name: 'Falcons', + group: expect.objectContaining({ id: expect.any(Number), name: 'Grupo A', sortOrder: 0 }), + players: [], + }); + + const createdGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + expect(createdGroup.statusCode).toBe(201); + const groupB = createdGroup.json(); + + const duplicate = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { number: 10, name: 'Duplicate Falcons', groupId: groupB.id }, + }); + expect(duplicate.statusCode).toBe(409); + expect(duplicate.json()).toEqual({ error: expect.any(String) }); + + const moved = await testApp().inject({ + method: 'PUT', + url: `/api/admin/teams/${created.id}`, + headers: { cookie: session }, + payload: { number: 10, name: 'Falcons', groupId: groupB.id }, + }); + expect(moved.statusCode).toBe(200); + expect(moved.json()).toMatchObject({ id: created.id, group: groupB }); + + const withoutGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { number: 11, name: 'No Group' }, + }); + expect(withoutGroup.statusCode).toBe(400); + + const unknownGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { number: 11, name: 'Unknown Group', groupId: 99_999 }, + }); + expect(unknownGroup.statusCode).toBe(404); + }); + + it('assigns the next team number when creating a team without one', async () => { + const session = await login(); + const groupsResponse = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + const [group] = groupsResponse.json(); + + const first = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { name: 'Primeira', groupId: group.id }, + }); + const second = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams', + headers: { cookie: session }, + payload: { name: 'Segunda', groupId: group.id }, + }); + + expect(first.statusCode).toBe(201); + expect(first.json()).toMatchObject({ number: 1, name: 'Primeira' }); + expect(second.statusCode).toBe(201); + expect(second.json()).toMatchObject({ number: 2, name: 'Segunda' }); + }); + + it('manages groups and prevents deleting nonempty or final groups', async () => { + const session = await login(); + const initial = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + expect(initial.statusCode).toBe(200); + const [groupA] = initial.json(); + expect(groupA).toMatchObject({ name: 'Grupo A' }); + + const emptyName = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: ' ' }, + }); + expect(emptyName.statusCode).toBe(400); + + const created = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + expect(created.statusCode).toBe(201); + const groupB = created.json(); + + const duplicate = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'grupo b' }, + }); + expect(duplicate.statusCode).toBe(409); + + const renamed = await testApp().inject({ + method: 'PUT', + url: `/api/admin/groups/${groupB.id}`, + headers: { cookie: session }, + payload: { name: 'Grupo Norte' }, + }); + expect(renamed.statusCode).toBe(200); + expect(renamed.json()).toEqual({ id: groupB.id, name: 'Grupo Norte', sortOrder: groupB.sortOrder }); + + const deleted = await testApp().inject({ + method: 'DELETE', + url: `/api/admin/groups/${groupB.id}`, + headers: { cookie: session }, + }); + expect(deleted.statusCode).toBe(200); + + const finalGroup = await testApp().inject({ + method: 'DELETE', + url: `/api/admin/groups/${groupA.id}`, + headers: { cookie: session }, + }); + expect(finalGroup.statusCode).toBe(409); + expect(finalGroup.json()).toEqual({ error: 'Não é possível eliminar o último grupo.' }); + + const anotherGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo Sul' }, + }); + expect(anotherGroup.statusCode).toBe(201); + + await createTeam(session, 1, 'Lions'); + const nonempty = await testApp().inject({ + method: 'DELETE', + url: `/api/admin/groups/${groupA.id}`, + headers: { cookie: session }, + }); + expect(nonempty.statusCode).toBe(409); + expect(nonempty.json()).toEqual({ error: 'Não é possível eliminar um grupo com equipas.' }); + }); + + it('previews and saves the exact one-leg schedule for four teams', async () => { + const session = await login(); + const teams = [ + await createTeam(session, 1, 'Lions'), + await createTeam(session, 2, 'Tigers'), + await createTeam(session, 3, 'Bears'), + await createTeam(session, 4, 'Wolves'), + ]; + + const preview = await generatePreview(session, 1); + expect(preview).toMatchObject({ matchCount: 6, roundCount: 3 }); + expect(preview.generation.legs).toBe(1); + expect([...preview.generation.teamOrder].sort((first, second) => first.teamId - second.teamId)).toEqual( + teams + .map((team) => ({ teamId: team.id, groupId: team.group.id })) + .sort((first, second) => first.teamId - second.teamId), + ); + + const [firstId, secondId, thirdId, fourthId] = preview.generation.teamOrder.map(({ teamId }) => teamId); + const groupId = teams[0].group.id; + expect(scheduleOf(preview.matches)).toEqual([ + { gameNumber: 1, roundNumber: 1, groupId, teamAId: firstId, teamBId: fourthId }, + { gameNumber: 2, roundNumber: 1, groupId, teamAId: secondId, teamBId: thirdId }, + { gameNumber: 3, roundNumber: 2, groupId, teamAId: thirdId, teamBId: firstId }, + { gameNumber: 4, roundNumber: 2, groupId, teamAId: fourthId, teamBId: secondId }, + { gameNumber: 5, roundNumber: 3, groupId, teamAId: firstId, teamBId: secondId }, + { gameNumber: 6, roundNumber: 3, groupId, teamAId: thirdId, teamBId: fourthId }, + ]); + + const expectedPairs = teams.flatMap((team, index) => ( + teams.slice(index + 1).map((opponent) => unorderedPair(team.id, opponent.id)) + )).sort(); + expect(preview.matches.map((match) => unorderedPair(match.teamA.id, match.teamB.id)).sort()).toEqual(expectedPairs); + for (const roundNumber of [1, 2, 3]) { + const participants = preview.matches + .filter((match) => match.roundNumber === roundNumber) + .flatMap((match) => [match.teamA.id, match.teamB.id]); + expect(new Set(participants).size).toBe(participants.length); + } + + const saved = await saveGeneration(session, preview.generation); + expect(saved).toMatchObject({ matchCount: 6, roundCount: 3 }); + expect(scheduleOf(saved.matches)).toEqual(scheduleOf(preview.matches)); + expect(saved.matches.every((match) => match.scoreA === null && match.scoreB === null)).toBe(true); + + const persisted = await testApp().inject({ + method: 'GET', + url: '/api/admin/calendar', + headers: { cookie: session }, + }); + expect(persisted.statusCode).toBe(200); + expect(persisted.json()).toEqual(saved); + }); + + it('rejects generation when no group can produce a match', async () => { + const session = await login(); + await createTeam(session, 1, 'Only team'); + + const response = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate-preview', + headers: { cookie: session }, + payload: { legs: 1 }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toEqual({ + error: 'São necessárias pelo menos duas equipas no mesmo grupo para gerar o calendário.', + }); + }); + + it('keeps a round out of the standings until an administrator enables it', async () => { + const session = await login(); + await createTeam(session, 1, 'Lions'); + await createTeam(session, 2, 'Tigers'); + const preview = await generatePreview(session, 1); + const [match] = (await saveGeneration(session, preview.generation)).matches; + expect(match.countsTowardStandings).toBe(false); + + const result = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${match.id}/result`, + headers: { cookie: session }, + payload: { scoreA: 4, scoreB: 1 }, + }); + expect(result.statusCode).toBe(200); + expect(result.json()).toMatchObject({ + id: match.id, + scoreA: 4, + scoreB: 1, + }); + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + const disabledState = publicState.json(); + expect(disabledState.matches).toEqual([ + expect.objectContaining({ id: match.id, scoreA: 4, scoreB: 1, countsTowardStandings: false }), + ]); + expect(disabledState.groupStandings[0].rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ team: expect.objectContaining({ id: match.teamA.id }), played: 0, points: 0 }), + expect.objectContaining({ team: expect.objectContaining({ id: match.teamB.id }), played: 0, points: 0 }), + ])); + + await includeRoundInStandings(session, match.roundNumber); + + const enabledPublicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(enabledPublicState.statusCode).toBe(200); + expect(enabledPublicState.json()).toMatchObject({ + matches: [expect.objectContaining({ id: match.id, countsTowardStandings: true })], + groupStandings: [{ + group: expect.objectContaining({ name: 'Grupo A' }), + rows: [ + expect.objectContaining({ + position: 1, + team: expect.objectContaining({ id: match.teamA.id }), + played: 1, + wins: 1, + draws: 0, + losses: 0, + goalsFor: 4, + goalsAgainst: 1, + goalDifference: 3, + points: 3, + }), + expect.objectContaining({ + position: 2, + team: expect.objectContaining({ id: match.teamB.id }), + played: 1, + wins: 0, + draws: 0, + losses: 1, + goalsFor: 1, + goalsAgainst: 4, + goalDifference: -3, + points: 0, + }), + ], + }], + }); + }); + + it('ranks total game points and resolves equal totals by the direct match', async () => { + const session = await login(); + const teamA = await createTeam(session, 1, 'Lions'); + const teamB = await createTeam(session, 2, 'Tigers'); + const teamC = await createTeam(session, 3, 'Bears'); + const preview = await generatePreview(session, 1); + const calendar = await saveGeneration(session, preview.generation); + for (const roundNumber of new Set(calendar.matches.map((match) => match.roundNumber))) { + await includeRoundInStandings(session, roundNumber); + } + + const settings = await testApp().inject({ + method: 'PUT', + url: '/api/admin/settings', + headers: { cookie: session }, + payload: { name: 'Torneio', classificationMode: 'total-points' }, + }); + expect(settings.statusCode).toBe(200); + expect(settings.json()).toMatchObject({ classificationMode: 'total-points' }); + + const results = [ + { teams: [teamA, teamB], scores: new Map([[teamA.id, 1], [teamB.id, 0]]) }, + { teams: [teamA, teamC], scores: new Map([[teamA.id, 4], [teamC.id, 10]]) }, + { teams: [teamB, teamC], scores: new Map([[teamB.id, 5], [teamC.id, 0]]) }, + ]; + for (const expected of results) { + const pair = unorderedPair(expected.teams[0].id, expected.teams[1].id); + const match = calendar.matches.find((candidate) => ( + unorderedPair(candidate.teamA.id!, candidate.teamB.id!) === pair + )); + expect(match).toBeDefined(); + const response = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${match!.id}/result`, + headers: { cookie: session }, + payload: { + scoreA: expected.scores.get(match!.teamA.id!)!, + scoreB: expected.scores.get(match!.teamB.id!)!, + }, + }); + expect(response.statusCode).toBe(200); + } + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + const state = publicState.json(); + expect(state.tournament.classificationMode).toBe('total-points'); + expect(state.groupStandings[0].rows.map((row) => ({ + teamId: row.team.id, + points: row.points, + goalDifference: row.goalDifference, + }))).toEqual([ + { teamId: teamC.id, points: 10, goalDifference: 1 }, + { teamId: teamA.id, points: 5, goalDifference: -5 }, + { teamId: teamB.id, points: 5, goalDifference: 4 }, + ]); + }); + + it('generates only intra-group games with persisted rounds and aligned public classifications', async () => { + const session = await login(); + const groups = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + const [groupA] = groups.json(); + const createdGroup = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + const groupB = createdGroup.json(); + const groupATeams = [ + await createTeam(session, 1, 'Lions', groupA.id), + await createTeam(session, 2, 'Tigers', groupA.id), + await createTeam(session, 3, 'Bears', groupA.id), + ]; + const groupBTeams = [ + await createTeam(session, 4, 'Wolves', groupB.id), + await createTeam(session, 5, 'Eagles', groupB.id), + ]; + const allTeams = [...groupATeams, ...groupBTeams]; + + const preview = await generatePreview(session, 1); + expect(preview).toMatchObject({ matchCount: 4, roundCount: 3 }); + expect(preview.matches.map((match) => match.roundNumber)).toEqual([1, 1, 2, 3]); + expect(preview.matches.every((match) => ( + match.group.id === match.teamA.group.id && match.group.id === match.teamB.group.id + ))).toBe(true); + + const expectedPairs = [groupATeams, groupBTeams].flatMap((groupTeams) => ( + groupTeams.flatMap((team, index) => ( + groupTeams.slice(index + 1).map((opponent) => unorderedPair(team.id, opponent.id)) + )) + )).sort(); + expect(preview.matches.map((match) => unorderedPair(match.teamA.id, match.teamB.id)).sort()).toEqual(expectedPairs); + + const calendar = await saveGeneration(session, preview.generation); + expect(scheduleOf(calendar.matches)).toEqual(scheduleOf(preview.matches)); + for (const roundNumber of new Set(calendar.matches.map((match) => match.roundNumber))) { + await includeRoundInStandings(session, roundNumber); + } + + const attemptedReassignment = await testApp().inject({ + method: 'PUT', + url: `/api/admin/teams/${groupATeams[0].id}`, + headers: { cookie: session }, + payload: { number: groupATeams[0].number, name: groupATeams[0].name, groupId: groupB.id }, + }); + expect(attemptedReassignment.statusCode).toBe(409); + expect(attemptedReassignment.json()).toEqual({ + error: 'Não é possível alterar o grupo de uma equipa utilizada no calendário. Limpe ou substitua o calendário primeiro.', + }); + + for (const match of calendar.matches) { + const result = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${match.id}/result`, + headers: { cookie: session }, + payload: { scoreA: 2, scoreB: 0 }, + }); + expect(result.statusCode).toBe(200); + } + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + const state = publicState.json(); + expect(state.groups).toEqual([groupA, groupB]); + expect(scheduleOf(state.matches)).toEqual(scheduleOf(calendar.matches)); + expect(state.matches.every((match) => match.scoreA === 2 && match.scoreB === 0)).toBe(true); + + for (const standing of state.groupStandings) { + const groupTeams = allTeams.filter((team) => team.group.id === standing.group.id); + const groupMatches = state.matches.filter((match) => match.group?.id === standing.group.id); + expect(standing.rows.map((row) => row.team.id).sort((first, second) => first - second)).toEqual( + groupTeams.map((team) => team.id).sort((first, second) => first - second), + ); + for (const row of standing.rows) { + const wins = groupMatches.filter((match) => match.teamA.id === row.team.id).length; + const losses = groupMatches.filter((match) => match.teamB.id === row.team.id).length; + expect(row).toMatchObject({ + played: wins + losses, + wins, + draws: 0, + losses, + goalsFor: wins * 2, + goalsAgainst: losses * 2, + goalDifference: (wins - losses) * 2, + points: wins * 3, + }); + } + expect(standing.rows.reduce((total, row) => total + row.played, 0)).toBe(groupMatches.length * 2); + } + }); + + it('previews a second leg that exactly mirrors the first with reversed venues', async () => { + const session = await login(); + await createTeam(session, 1, 'Lions'); + await createTeam(session, 2, 'Tigers'); + await createTeam(session, 3, 'Bears'); + await createTeam(session, 4, 'Wolves'); + + const preview = await generatePreview(session, 2); + expect(preview).toMatchObject({ matchCount: 12, roundCount: 6 }); + expect(preview.generation.legs).toBe(2); + + const firstLegMatchCount = preview.matchCount / 2; + const roundOffset = preview.roundCount / 2; + const firstLeg = preview.matches.slice(0, firstLegMatchCount); + const secondLeg = preview.matches.slice(firstLegMatchCount); + expect(scheduleOf(secondLeg)).toEqual(firstLeg.map((match, index) => ({ + gameNumber: firstLegMatchCount + index + 1, + roundNumber: match.roundNumber + roundOffset, + groupId: match.group.id, + teamAId: match.teamB.id, + teamBId: match.teamA.id, + }))); + }); + + it('rejects tampered and stale calendar generation descriptors', async () => { + const session = await login(); + await createTeam(session, 1, 'Lions'); + await createTeam(session, 2, 'Tigers'); + await createTeam(session, 3, 'Bears'); + await createTeam(session, 4, 'Wolves'); + const preview = await generatePreview(session, 1); + + const tamperedTeamOrder = preview.generation.teamOrder.map((entry, index) => ( + index === 1 ? { ...entry, ...preview.generation.teamOrder[0] } : entry + )); + const tampered = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate', + headers: { cookie: session }, + payload: { generation: { ...preview.generation, teamOrder: tamperedTeamOrder } }, + }); + expect(tampered.statusCode).toBe(409); + expect(tampered.json()).toEqual({ + error: 'As equipas ou os grupos foram alterados. Sorteie novamente o calendário.', + }); + + await createTeam(session, 5, 'Eagles'); + const stale = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate', + headers: { cookie: session }, + payload: { generation: preview.generation }, + }); + expect(stale.statusCode).toBe(409); + expect(stale.json()).toEqual({ error: 'As equipas foram alteradas. Sorteie novamente o calendário.' }); + }); + + it('requires confirmation to replace a scored calendar and clears scores when confirmed', async () => { + const session = await login(); + await createTeam(session, 1, 'Lions'); + await createTeam(session, 2, 'Tigers'); + await createTeam(session, 3, 'Bears'); + await createTeam(session, 4, 'Wolves'); + + const initialPreview = await generatePreview(session, 1); + const initialCalendar = await saveGeneration(session, initialPreview.generation); + const scoredMatch = initialCalendar.matches[0]; + const result = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${scoredMatch.id}/result`, + headers: { cookie: session }, + payload: { scoreA: 3, scoreB: 1 }, + }); + expect(result.statusCode).toBe(200); + + const replacementPreview = await generatePreview(session, 1); + const unconfirmed = await testApp().inject({ + method: 'POST', + url: '/api/admin/calendar/generate', + headers: { cookie: session }, + payload: { generation: replacementPreview.generation }, + }); + expect(unconfirmed.statusCode).toBe(409); + expect(unconfirmed.json()).toEqual({ + error: 'Já existem resultados registados. Confirme a substituição do calendário para eliminar esses resultados.', + }); + + const unchanged = await testApp().inject({ + method: 'GET', + url: '/api/admin/calendar', + headers: { cookie: session }, + }); + expect(unchanged.statusCode).toBe(200); + expect(unchanged.json().matches.find((match) => match.id === scoredMatch.id)).toMatchObject({ + scoreA: 3, + scoreB: 1, + }); + + const replaced = await saveGeneration(session, replacementPreview.generation, true); + expect(scheduleOf(replaced.matches)).toEqual(scheduleOf(replacementPreview.matches)); + expect(replaced.matches.every((match) => match.scoreA === null && match.scoreB === null)).toBe(true); + }); + + it('randomizes existing team numbers and group assignments after confirmation', async () => { + const session = await login(); + const groupsResponse = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + const [groupA] = groupsResponse.json(); + const groupBResponse = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + const groupB = groupBResponse.json(); + const teams = await Promise.all([ + createTeam(session, 11, 'Lions'), + createTeam(session, 24, 'Tigers'), + createTeam(session, 37, 'Bears'), + ]); + const preview = await generatePreview(session, 1); + const scheduled = await saveGeneration(session, preview.generation); + expect(scheduled.matches.length).toBeGreaterThan(0); + + const unconfirmed = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams/randomize', + headers: { cookie: session }, + payload: {}, + }); + expect(unconfirmed.statusCode).toBe(409); + + const randomized = await testApp().inject({ + method: 'POST', + url: '/api/admin/teams/randomize', + headers: { cookie: session }, + payload: { confirm: true }, + }); + expect(randomized.statusCode).toBe(200); + const result = randomized.json<{ teams: Team[]; calendarCleared: boolean }>(); + expect(result.calendarCleared).toBe(true); + expect(result.teams.map((team) => team.number).sort((a, b) => a - b)).toEqual([11, 24, 37]); + expect(new Set(result.teams.map((team) => team.number)).size).toBe(3); + expect(result.teams.map((team) => team.id).sort((a, b) => a - b)).toEqual(teams.map((team) => team.id).sort((a, b) => a - b)); + expect(result.teams.map((team) => team.group.id)).toEqual(expect.arrayContaining([groupA.id, groupB.id])); + expect(Math.abs( + result.teams.filter((team) => team.group.id === groupA.id).length - + result.teams.filter((team) => team.group.id === groupB.id).length, + )).toBeLessThanOrEqual(1); + + const calendar = await testApp().inject({ + method: 'GET', + url: '/api/admin/calendar', + headers: { cookie: session }, + }); + expect(calendar.json().matches).toEqual([]); + }); + + it('configures and advances a final stage that is exposed through public state', async () => { + const session = await login(); + const lions = await createTeam(session, 1, 'Lions'); + const tigers = await createTeam(session, 2, 'Tigers'); + const bears = await createTeam(session, 3, 'Bears'); + const wolves = await createTeam(session, 4, 'Wolves'); + + const invalidThirdPlace = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: { roundCount: 1, thirdPlaceEnabled: true }, + }); + expect(invalidThirdPlace.statusCode).toBe(400); + expect(invalidThirdPlace.json()).toEqual({ error: 'O jogo do 3.º lugar requer pelo menos duas rondas.' }); + + const configured = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: { roundCount: 2, thirdPlaceEnabled: true }, + }); + expect(configured.statusCode).toBe(200); + expect(configured.json()).toMatchObject({ + roundCount: 2, + thirdPlaceEnabled: true, + champion: null, + rounds: [ + { roundIndex: 1, name: 'Meias-finais', matches: [{ matchIndex: 1 }, { matchIndex: 2 }] }, + { roundIndex: 2, name: 'Final', matches: [{ matchIndex: 1 }] }, + ], + thirdPlaceMatch: { matchIndex: 2 }, + }); + + const seeded = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/seeds', + headers: { cookie: session }, + payload: { + seeds: [ + { slotIndex: 1, teamId: lions.id }, + { slotIndex: 2, teamId: tigers.id }, + { slotIndex: 3, teamId: bears.id }, + { slotIndex: 4, teamId: wolves.id }, + ], + }, + }); + expect(seeded.statusCode).toBe(200); + expect(seeded.json().rounds[0].matches).toMatchObject([ + { teamA: { team: { id: lions.id } }, teamB: { team: { id: tigers.id } } }, + { teamA: { team: { id: bears.id } }, teamB: { team: { id: wolves.id } } }, + ]); + + const firstSemiFinal = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/1/1/result', + headers: { cookie: session }, + payload: { scoreA: 3, scoreB: 1 }, + }); + expect(firstSemiFinal.statusCode).toBe(200); + expect(firstSemiFinal.json().rounds[1].matches[0]).toMatchObject({ + teamA: { team: { id: lions.id }, pending: false }, + teamB: { team: null, pending: true }, + }); + + const secondSemiFinal = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/1/2/result', + headers: { cookie: session }, + payload: { scoreA: 1, scoreB: 3 }, + }); + expect(secondSemiFinal.statusCode).toBe(200); + expect(secondSemiFinal.json().rounds[1].matches[0]).toMatchObject({ + teamA: { team: { id: lions.id } }, + teamB: { team: { id: wolves.id } }, + }); + + const thirdPlace = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/2/2/result', + headers: { cookie: session }, + payload: { scoreA: 1, scoreB: 3 }, + }); + expect(thirdPlace.statusCode).toBe(200); + expect(thirdPlace.json().thirdPlaceMatch).toMatchObject({ + teamA: { team: { id: tigers.id } }, + teamB: { team: { id: bears.id } }, + scoreA: 1, + scoreB: 3, + winner: { id: bears.id }, + }); + + const final = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/2/1/result', + headers: { cookie: session }, + payload: { scoreA: 2, scoreB: 1 }, + }); + expect(final.statusCode).toBe(200); + const completedFinalStage = final.json(); + expect(completedFinalStage.champion).toMatchObject({ id: lions.id, number: 1, name: 'Lions' }); + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.statusCode).toBe(200); + expect(publicState.json().finalStage).toEqual(completedFinalStage); + + const correctedSemiFinal = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/matches/1/1/result', + headers: { cookie: session }, + payload: { scoreA: 1, scoreB: 3 }, + }); + expect(correctedSemiFinal.statusCode).toBe(200); + const correctedFinalStage = correctedSemiFinal.json(); + expect(correctedFinalStage.champion).toBeNull(); + expect(correctedFinalStage.rounds[0].matches[0]).toMatchObject({ winner: { id: tigers.id } }); + expect(correctedFinalStage.rounds[1].matches[0]).toMatchObject({ + teamA: { team: { id: tigers.id } }, + teamB: { team: { id: wolves.id } }, + scoreA: null, + scoreB: null, + winner: null, + }); + expect(correctedFinalStage.thirdPlaceMatch).toMatchObject({ + teamA: { team: { id: lions.id } }, + teamB: { team: { id: bears.id } }, + scoreA: null, + scoreB: null, + winner: null, + }); + + const correctedPublicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(correctedPublicState.statusCode).toBe(200); + expect(correctedPublicState.json().finalStage?.champion).toBeNull(); + + const unconfirmedClear = await testApp().inject({ + method: 'DELETE', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: {}, + }); + expect(unconfirmedClear.statusCode).toBe(409); + + const cleared = await testApp().inject({ + method: 'DELETE', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: { confirm: true }, + }); + expect(cleared.statusCode).toBe(200); + expect(cleared.json()).toEqual({ + roundCount: null, + thirdPlaceEnabled: false, + rounds: [], + thirdPlaceMatch: null, + champion: null, + }); + + const clearedPublicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(clearedPublicState.statusCode).toBe(200); + expect(clearedPublicState.json().finalStage).toEqual({ + roundCount: null, + thirdPlaceEnabled: false, + rounds: [], + thirdPlaceMatch: null, + champion: null, + }); + }); + + it('accepts the next-match display panel', async () => { + const session = await login(); + const updated = await testApp().inject({ + method: 'PUT', + url: '/api/admin/display', + headers: { cookie: session }, + payload: { activePanel: 'next-match', zoomPercent: 100 }, + }); + + expect(updated.statusCode).toBe(200); + expect(updated.json()).toEqual({ activePanel: 'next-match', zoomPercent: 100 }); + + const publicState = await testApp().inject({ method: 'GET', url: '/api/public/state' }); + expect(publicState.json().display.activePanel).toBe('next-match'); + }); + + it('previews automatic final-stage qualification from group standings', async () => { + const session = await login(); + const groupsResponse = await testApp().inject({ + method: 'GET', + url: '/api/admin/groups', + headers: { cookie: session }, + }); + const [groupA] = groupsResponse.json(); + const groupBResponse = await testApp().inject({ + method: 'POST', + url: '/api/admin/groups', + headers: { cookie: session }, + payload: { name: 'Grupo B' }, + }); + const groupB = groupBResponse.json(); + + const a1 = await createTeam(session, 1, 'A1', groupA.id); + const a2 = await createTeam(session, 2, 'A2', groupA.id); + const b1 = await createTeam(session, 3, 'B1', groupB.id); + const b2 = await createTeam(session, 4, 'B2', groupB.id); + + const preview = await generatePreview(session, 1); + const calendar = await saveGeneration(session, preview.generation); + for (const roundNumber of new Set(calendar.matches.map((match) => match.roundNumber))) { + await includeRoundInStandings(session, roundNumber); + } + + for (const match of calendar.matches) { + const preferredWinner = match.group?.id === groupA.id ? a1.id : b1.id; + const response = await testApp().inject({ + method: 'PUT', + url: `/api/admin/matches/${match.id}/result`, + headers: { cookie: session }, + payload: { + scoreA: match.teamA.id === preferredWinner ? 3 : 1, + scoreB: match.teamB.id === preferredWinner ? 3 : 1, + }, + }); + expect(response.statusCode).toBe(200); + } + + const configured = await testApp().inject({ + method: 'PUT', + url: '/api/admin/final-stage/config', + headers: { cookie: session }, + payload: { roundCount: 2, thirdPlaceEnabled: false }, + }); + expect(configured.statusCode).toBe(200); + + const autoPreview = await testApp().inject({ + method: 'POST', + url: '/api/admin/final-stage/auto-seed-preview', + headers: { cookie: session }, + payload: { mode: 'per-group', qualifiersPerGroup: 2 }, + }); + expect(autoPreview.statusCode).toBe(200); + expect(autoPreview.json()).toMatchObject({ + mode: 'per-group', + qualifiersPerGroup: 2, + qualifierCount: 4, + qualifiers: [ + { seedNumber: 1, groupPosition: 1, team: { id: a1.id } }, + { seedNumber: 2, groupPosition: 1, team: { id: b1.id } }, + { seedNumber: 3, groupPosition: 2, team: { id: a2.id } }, + { seedNumber: 4, groupPosition: 2, team: { id: b2.id } }, + ], + seeds: [ + { slotIndex: 1, teamId: a1.id }, + { slotIndex: 2, teamId: b2.id }, + { slotIndex: 3, teamId: b1.id }, + { slotIndex: 4, teamId: a2.id }, + ], + }); + + const tooMany = await testApp().inject({ + method: 'POST', + url: '/api/admin/final-stage/auto-seed-preview', + headers: { cookie: session }, + payload: { mode: 'per-group', qualifiersPerGroup: 3 }, + }); + expect(tooMany.statusCode).toBe(400); + + const overallPreview = await testApp().inject({ + method: 'POST', + url: '/api/admin/final-stage/auto-seed-preview', + headers: { cookie: session }, + payload: { mode: 'overall', qualifierCount: 2 }, + }); + expect(overallPreview.statusCode).toBe(200); + expect(overallPreview.json()).toMatchObject({ + mode: 'overall', + qualifiersPerGroup: null, + qualifierCount: 2, + qualifiers: [ + { seedNumber: 1, team: { id: a1.id } }, + { seedNumber: 2, team: { id: b1.id } }, + ], + seeds: [ + { slotIndex: 1, teamId: a1.id }, + { slotIndex: 2, teamId: null }, + { slotIndex: 3, teamId: b1.id }, + { slotIndex: 4, teamId: null }, + ], + }); + }); + +}); diff --git a/apps/tournament/src/server/app.ts b/apps/tournament/src/server/app.ts new file mode 100644 index 0000000..ac20437 --- /dev/null +++ b/apps/tournament/src/server/app.ts @@ -0,0 +1,892 @@ +import cookie from '@fastify/cookie'; +import fastifyStatic from '@fastify/static'; +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify'; +import { randomInt } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { z, ZodError, type ZodType } from 'zod'; +import { + createSessionToken, + isSecureRequest, + isValidSessionToken, + LoginRateLimiter, + passwordMatches, + SESSION_COOKIE_NAME, + type AuthenticationConfig, +} from './auth.js'; +import { openDatabase, type SqliteDatabase } from './db/database.js'; +import { AppError } from './errors.js'; +import { StateChangeEvents } from './events.js'; +import { downstreamMatches, getBracketMatch } from './services/bracket.js'; +import { buildGroupQualificationPreview, buildOverallQualificationPreview } from '../shared/finalSeeding.js'; +import { generateRoundRobinSchedule } from './services/calendar.js'; +import { + getFinalStage, + getDisplaySettings, + getGroups, + getLeagueMatches, + getPublicState, + getSettings, + getTeamSummaries, + getTeamSummary, +} from './services/state.js'; +import { + CLASSIFICATION_MODES, + DISPLAY_PANEL_TYPES, + isDisplayZoomPercent, + type CalendarGeneration, + type Group, + type Player, + type Team, + type TeamSummary, +} from '../shared/types/tournament.js'; + +const positiveIntegerSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER); +const positiveIdParameterSchema = z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER); +const scoreSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); +const teamInputSchema = z.object({ + number: positiveIntegerSchema, + name: z.string().trim().min(1).max(200), + groupId: positiveIdParameterSchema, +}); +const teamCreateInputSchema = teamInputSchema.extend({ number: positiveIntegerSchema.optional() }); +const groupInputSchema = z.object({ + name: z.string().trim().min(1).max(200), +}); +const playerInputSchema = z.object({ + name: z.string().trim().min(1).max(200), +}); +const settingsInputSchema = z.object({ + name: z.string().trim().min(1).max(200), + classificationMode: z.enum(CLASSIFICATION_MODES), +}); +const displaySettingsInputSchema = z.object({ + activePanel: z.enum(DISPLAY_PANEL_TYPES), + zoomPercent: z.number().int().refine(isDisplayZoomPercent), +}); +const resultInputSchema = z.object({ scoreA: scoreSchema, scoreB: scoreSchema }); +const roundStandingsInputSchema = z.object({ countsTowardStandings: z.boolean() }); +const confirmationSchema = z.object({ confirm: z.literal(true).optional() }); +const calendarLegsSchema = z.union([z.literal(1), z.literal(2)]); +const calendarGenerationSchema = z.object({ + legs: calendarLegsSchema, + teamOrder: z.array(z.object({ + teamId: positiveIdParameterSchema, + groupId: positiveIdParameterSchema, + })), +}); +const calendarGenerationSaveSchema = z.object({ + generation: calendarGenerationSchema, + confirmReplace: z.literal(true).optional(), +}); +const finalConfigSchema = z.object({ + roundCount: positiveIntegerSchema.max(8), + thirdPlaceEnabled: z.boolean().default(false), + confirmClearResults: z.literal(true).optional(), +}); +const autoSeedPreviewSchema = z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal('per-group'), + qualifiersPerGroup: positiveIntegerSchema, + }), + z.object({ + mode: z.literal('overall'), + qualifierCount: positiveIntegerSchema, + }), +]); +const finalSeedsSchema = z.object({ + seeds: z.array(z.object({ + slotIndex: positiveIntegerSchema, + teamId: positiveIntegerSchema.nullable(), + })), + confirmClearResults: z.literal(true).optional(), +}); + +interface PlayerRow { + id: number; + team_id: number; + name: string; + sort_order: number; +} + +export interface CreateAppOptions { + database?: SqliteDatabase; + databasePath?: string; + authentication?: AuthenticationConfig; + basePath?: string; + staticRoot?: string; + trustProxy?: boolean; + logger?: boolean; +} + +function parse(schema: ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) { + throw new AppError(400, 'Os dados enviados não são válidos.'); + } + return result.data; +} + +function authenticationFromOptions(options: CreateAppOptions): AuthenticationConfig { + const config = options.authentication ?? { + adminPassword: process.env.ADMIN_PASSWORD ?? '', + sessionSecret: process.env.SESSION_SECRET ?? '', + }; + if (!config.adminPassword || !config.sessionSecret) { + throw new Error('ADMIN_PASSWORD e SESSION_SECRET têm de estar definidos.'); + } + return config; +} + +function normalizedBasePath(basePath: string): string { + if (!basePath.startsWith('/')) { + throw new Error('APP_BASE_PATH tem de começar por "/".'); + } + return basePath.endsWith('/') ? basePath : `${basePath}/`; +} + +function cookieOptions(request: FastifyRequest, authentication: AuthenticationConfig, basePath: string) { + return { + path: basePath, + httpOnly: true, + sameSite: 'strict' as const, + secure: isSecureRequest(request), + maxAge: Math.floor((authentication.sessionLifetimeMs ?? 7 * 24 * 60 * 60 * 1000) / 1000), + }; +} + +function clearCookieOptions(request: FastifyRequest, basePath: string) { + return { + path: basePath, + httpOnly: true, + sameSite: 'strict' as const, + secure: isSecureRequest(request), + }; +} + +function playerFromRow(row: PlayerRow): Player { + return { + id: row.id, + teamId: row.team_id, + name: row.name, + sortOrder: row.sort_order, + }; +} + +function getAdminTeams(database: SqliteDatabase): Team[] { + const teams = getTeamSummaries(database).map((team) => ({ ...team, players: [] as Player[] })); + const teamsById = new Map(teams.map((team) => [team.id, team])); + const players = database.prepare(` + SELECT id, team_id, name, sort_order + FROM players + ORDER BY team_id ASC, sort_order ASC, id ASC + `).all() as PlayerRow[]; + for (const player of players) { + teamsById.get(player.team_id)?.players.push(playerFromRow(player)); + } + return teams; +} + +function assertExistingTeam(database: SqliteDatabase, teamId: number): TeamSummary { + const team = getTeamSummary(database, teamId); + if (!team) { + throw new AppError(404, 'A equipa não existe.'); + } + return team; +} + +function nextTeamNumber(database: SqliteDatabase): number { + const row = database.prepare('SELECT COALESCE(MAX(number), 0) AS number FROM teams').get() as { number: number }; + if (row.number >= Number.MAX_SAFE_INTEGER) { + throw new AppError(409, 'Não é possível atribuir outro número de equipa.'); + } + return row.number + 1; +} + +function assertExistingGroup(database: SqliteDatabase, groupId: number): Group { + const group = database.prepare(` + SELECT id, name, sort_order AS sortOrder + FROM league_groups + WHERE id = ? + `).get(groupId) as Group | undefined; + if (!group) { + throw new AppError(404, 'O grupo não existe.'); + } + return group; +} + +function assertAvailableGroupName(database: SqliteDatabase, name: string, exceptId?: number): void { + const group = database.prepare(` + SELECT id FROM league_groups + WHERE name = ? COLLATE NOCASE AND (? IS NULL OR id <> ?) + `).get(name, exceptId ?? null, exceptId ?? null) as { id: number } | undefined; + if (group) { + throw new AppError(409, 'Já existe um grupo com esse nome.'); + } +} + +function finalResultExists(database: SqliteDatabase): boolean { + return database.prepare('SELECT 1 FROM final_match_results LIMIT 1').get() !== undefined; +} + +function requireConfirmation(confirmed: boolean | undefined, message: string): void { + if (!confirmed) { + throw new AppError(409, message); + } +} + +function clearInvalidatedFinalResults( + database: SqliteDatabase, + roundCount: number, + thirdPlaceEnabled: boolean, + roundIndex: number, + matchIndex: number, + previousWinner: number | null, +): void { + const nextWinner = getBracketMatch( + getFinalStage(database, roundCount, thirdPlaceEnabled), + roundIndex, + matchIndex, + )?.winner?.id ?? null; + + if (previousWinner === nextWinner) { + return; + } + + const clear = database.prepare('DELETE FROM final_match_results WHERE round_index = ? AND match_index = ?'); + downstreamMatches(roundCount, roundIndex, matchIndex) + .forEach((downstream) => clear.run(downstream.roundIndex, downstream.matchIndex)); + + if (thirdPlaceEnabled && roundIndex === roundCount - 1 && matchIndex <= 2) { + clear.run(roundCount, 2); + } +} + +function teamsForGeneration(teams: readonly TeamSummary[], generation: CalendarGeneration): TeamSummary[] { + if (generation.teamOrder.length !== teams.length) { + throw new AppError(409, 'As equipas foram alteradas. Sorteie novamente o calendário.'); + } + + const teamsById = new Map(teams.map((team) => [team.id, team])); + const seen = new Set(); + return generation.teamOrder.map(({ teamId, groupId }) => { + const team = teamsById.get(teamId); + if (!team || team.group.id !== groupId || seen.has(teamId)) { + throw new AppError(409, 'As equipas ou os grupos foram alterados. Sorteie novamente o calendário.'); + } + seen.add(teamId); + return team; + }); +} + +function calendarSummary(matches: readonly T[]) { + return { + matches, + matchCount: matches.length, + roundCount: Math.max(0, ...matches.map((match) => match.roundNumber)), + }; +} + +function shuffled(values: readonly T[]): T[] { + const result = [...values]; + for (let index = result.length - 1; index > 0; index -= 1) { + const replacementIndex = randomInt(index + 1); + [result[index], result[replacementIndex]] = [result[replacementIndex], result[index]]; + } + return result; +} + +export async function createApp(options: CreateAppOptions = {}): Promise { + const authentication = authenticationFromOptions(options); + const basePath = normalizedBasePath(options.basePath ?? process.env.APP_BASE_PATH ?? '/'); + const ownsDatabase = !options.database; + const database = options.database ?? openDatabase({ + path: options.databasePath, + }); + const events = new StateChangeEvents(); + const loginRateLimiter = new LoginRateLimiter(); + const app = Fastify({ + logger: options.logger ?? false, + trustProxy: options.trustProxy ?? process.env.TRUST_PROXY === 'true', + }); + + await app.register(cookie); + + app.setErrorHandler((error, _request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ error: error.message }); + } + if (error instanceof ZodError) { + return reply.status(400).send({ error: 'Os dados enviados não são válidos.' }); + } + if ((error as { code?: string; message?: string }).code?.startsWith('SQLITE_CONSTRAINT_UNIQUE') && + (error as { message?: string }).message?.includes('league_groups.name')) { + return reply.status(409).send({ error: 'Já existe um grupo com esse nome.' }); + } + if ((error as { code?: string }).code?.startsWith('SQLITE_CONSTRAINT_UNIQUE')) { + return reply.status(409).send({ error: 'Já existe uma equipa com esse número.' }); + } + app.log.error(error); + return reply.status(500).send({ error: 'Ocorreu um erro inesperado. Tente novamente.' }); + }); + + app.setNotFoundHandler((_request, reply) => reply.status(404).send({ error: 'O recurso pedido não existe.' })); + + const requireAdmin = async (request: FastifyRequest): Promise => { + if (!isValidSessionToken(request.cookies[SESSION_COOKIE_NAME], authentication)) { + throw new AppError(401, 'É necessário iniciar sessão para aceder à administração.'); + } + }; + const stateChanged = () => events.broadcastStateChanged(); + + app.post('/api/auth/login', async (request, reply) => { + const body = parse(z.object({ password: z.string().min(1).max(1_000) }), request.body); + const key = request.ip; + if (loginRateLimiter.isLimited(key)) { + throw new AppError(429, 'Foram feitas demasiadas tentativas. Tente novamente mais tarde.'); + } + if (!passwordMatches(body.password, authentication.adminPassword)) { + loginRateLimiter.recordFailure(key); + throw new AppError(401, 'A palavra-passe está incorreta.'); + } + loginRateLimiter.clear(key); + reply.setCookie(SESSION_COOKIE_NAME, createSessionToken(authentication), cookieOptions(request, authentication, basePath)); + return { authenticated: true }; + }); + + app.post('/api/auth/logout', async (request, reply) => { + reply.clearCookie(SESSION_COOKIE_NAME, clearCookieOptions(request, basePath)); + return { authenticated: false }; + }); + + app.get('/api/auth/session', async (request) => ({ + authenticated: isValidSessionToken(request.cookies[SESSION_COOKIE_NAME], authentication), + })); + + app.get('/api/admin/settings', { preHandler: requireAdmin }, async () => getSettings(database)); + + app.put('/api/admin/settings', { preHandler: requireAdmin }, async (request) => { + const body = parse(settingsInputSchema, request.body); + database.prepare(` + UPDATE tournament_settings + SET name = ?, classification_mode = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(body.name, body.classificationMode); + stateChanged(); + return getSettings(database); + }); + + app.get('/api/admin/display', { preHandler: requireAdmin }, async () => getDisplaySettings(database)); + + app.put('/api/admin/display', { preHandler: requireAdmin }, async (request) => { + const body = parse(displaySettingsInputSchema, request.body); + database.prepare(` + UPDATE display_settings + SET active_panel = ?, zoom_percent = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(body.activePanel, body.zoomPercent); + stateChanged(); + return getDisplaySettings(database); + }); + + app.get('/api/admin/groups', { preHandler: requireAdmin }, async () => getGroups(database)); + + app.post('/api/admin/groups', { preHandler: requireAdmin }, async (request, reply) => { + const body = parse(groupInputSchema, request.body); + assertAvailableGroupName(database, body.name); + const result = database.prepare(` + INSERT INTO league_groups (name, sort_order, created_at, updated_at) + VALUES (?, COALESCE((SELECT MAX(sort_order) + 1 FROM league_groups), 0), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `).run(body.name); + const group = assertExistingGroup(database, Number(result.lastInsertRowid)); + stateChanged(); + return reply.status(201).send(group); + }); + + app.put('/api/admin/groups/:id', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ id: positiveIdParameterSchema }), request.params); + const body = parse(groupInputSchema, request.body); + assertExistingGroup(database, params.id); + assertAvailableGroupName(database, body.name, params.id); + database.prepare(` + UPDATE league_groups + SET name = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(body.name, params.id); + stateChanged(); + return assertExistingGroup(database, params.id); + }); + + app.delete('/api/admin/groups/:id', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ id: positiveIdParameterSchema }), request.params); + assertExistingGroup(database, params.id); + if (database.prepare('SELECT 1 FROM teams WHERE group_id = ? LIMIT 1').get(params.id)) { + throw new AppError(409, 'Não é possível eliminar um grupo com equipas.'); + } + const groupCount = database.prepare('SELECT COUNT(*) AS count FROM league_groups').get() as { count: number }; + if (groupCount.count <= 1) { + throw new AppError(409, 'Não é possível eliminar o último grupo.'); + } + database.prepare('DELETE FROM league_groups WHERE id = ?').run(params.id); + stateChanged(); + return replyEmpty(); + }); + + app.get('/api/admin/teams', { preHandler: requireAdmin }, async () => getAdminTeams(database)); + + app.post('/api/admin/teams/randomize', { preHandler: requireAdmin }, async (request) => { + const body = parse(confirmationSchema, request.body ?? {}); + requireConfirmation(body.confirm, 'Confirme que pretende randomizar os números e os grupos das equipas.'); + const teams = getTeamSummaries(database); + const groups = getGroups(database); + const hasCalendar = database.prepare('SELECT 1 FROM league_matches LIMIT 1').get() !== undefined; + + database.transaction(() => { + if (hasCalendar) { + database.prepare('DELETE FROM league_matches').run(); + database.prepare('DELETE FROM league_rounds').run(); + } + + // Move unique numbers out of their current range before assigning the shuffled values. + const maximumNumber = Math.max(0, ...teams.map((team) => team.number)); + const reserveNumber = database.prepare('UPDATE teams SET number = ? WHERE id = ?'); + teams.forEach((team, index) => reserveNumber.run(maximumNumber + index + 1, team.id)); + + const assignedNumbers = shuffled(teams.map((team) => team.number)); + const assignedGroups = shuffled(groups); + const updateTeam = database.prepare('UPDATE teams SET number = ?, group_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'); + teams.forEach((team, index) => { + updateTeam.run(assignedNumbers[index], assignedGroups[index % assignedGroups.length].id, team.id); + }); + })(); + stateChanged(); + return { + teams: getAdminTeams(database), + calendarCleared: hasCalendar, + }; + }); + + app.post('/api/admin/teams', { preHandler: requireAdmin }, async (request, reply) => { + const body = parse(teamCreateInputSchema, request.body); + assertExistingGroup(database, body.groupId); + const result = database.prepare(` + INSERT INTO teams (number, name, group_id, created_at, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `).run(body.number ?? nextTeamNumber(database), body.name, body.groupId); + const team = assertExistingTeam(database, Number(result.lastInsertRowid)); + stateChanged(); + return reply.status(201).send({ ...team, players: [] }); + }); + + app.put('/api/admin/teams/:id', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ id: positiveIdParameterSchema }), request.params); + const body = parse(teamInputSchema, request.body); + const currentTeam = assertExistingTeam(database, params.id); + assertExistingGroup(database, body.groupId); + if (currentTeam.group.id !== body.groupId && + database.prepare('SELECT 1 FROM league_matches WHERE team_a_id = ? OR team_b_id = ? LIMIT 1').get(params.id, params.id)) { + throw new AppError(409, 'Não é possível alterar o grupo de uma equipa utilizada no calendário. Limpe ou substitua o calendário primeiro.'); + } + const result = database.prepare(` + UPDATE teams + SET number = ?, name = ?, group_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(body.number, body.name, body.groupId, params.id); + if (result.changes === 0) { + throw new AppError(404, 'A equipa não existe.'); + } + stateChanged(); + return { ...assertExistingTeam(database, params.id), players: getAdminTeams(database).find((team) => team.id === params.id)?.players ?? [] }; + }); + + app.delete('/api/admin/teams/:id', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ id: positiveIdParameterSchema }), request.params); + assertExistingTeam(database, params.id); + if (database.prepare('SELECT 1 FROM league_matches WHERE team_a_id = ? OR team_b_id = ? LIMIT 1').get(params.id, params.id)) { + throw new AppError(409, 'Não é possível eliminar esta equipa porque está a ser utilizada no calendário.'); + } + if (database.prepare('SELECT 1 FROM final_seeds WHERE team_id = ? LIMIT 1').get(params.id)) { + throw new AppError(409, 'Não é possível eliminar esta equipa porque está a ser utilizada na fase final.'); + } + database.prepare('DELETE FROM teams WHERE id = ?').run(params.id); + stateChanged(); + return replyEmpty(); + }); + + app.post('/api/admin/teams/:teamId/players', { preHandler: requireAdmin }, async (request, reply) => { + const params = parse(z.object({ teamId: positiveIdParameterSchema }), request.params); + const body = parse(playerInputSchema, request.body); + assertExistingTeam(database, params.teamId); + const result = database.prepare(` + INSERT INTO players (team_id, name, sort_order) + VALUES (?, ?, COALESCE((SELECT MAX(sort_order) + 1 FROM players WHERE team_id = ?), 0)) + `).run(params.teamId, body.name, params.teamId); + const player = database.prepare('SELECT id, team_id, name, sort_order FROM players WHERE id = ?').get(result.lastInsertRowid) as PlayerRow; + stateChanged(); + return reply.status(201).send(playerFromRow(player)); + }); + + app.put('/api/admin/players/:id', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ id: positiveIdParameterSchema }), request.params); + const body = parse(playerInputSchema, request.body); + const result = database.prepare('UPDATE players SET name = ? WHERE id = ?').run(body.name, params.id); + if (result.changes === 0) { + throw new AppError(404, 'O jogador não existe.'); + } + const player = database.prepare('SELECT id, team_id, name, sort_order FROM players WHERE id = ?').get(params.id) as PlayerRow; + stateChanged(); + return playerFromRow(player); + }); + + app.delete('/api/admin/players/:id', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ id: positiveIdParameterSchema }), request.params); + const result = database.prepare('DELETE FROM players WHERE id = ?').run(params.id); + if (result.changes === 0) { + throw new AppError(404, 'O jogador não existe.'); + } + stateChanged(); + return replyEmpty(); + }); + + app.get('/api/admin/calendar', { preHandler: requireAdmin }, async () => { + return calendarSummary(getLeagueMatches(database)); + }); + + app.post('/api/admin/calendar/generate-preview', { preHandler: requireAdmin }, async (request) => { + const body = parse(z.object({ legs: calendarLegsSchema }), request.body); + const teams = getTeamSummaries(database); + const teamOrder = getGroups(database).flatMap((group) => shuffled( + teams.filter((team) => team.group.id === group.id), + ).map((team) => ({ teamId: team.id, groupId: group.id }))); + const generation = { legs: body.legs, teamOrder }; + const matches = generateRoundRobinSchedule(teamsForGeneration(teams, generation), generation.legs); + if (matches.length === 0) { + throw new AppError(409, 'São necessárias pelo menos duas equipas no mesmo grupo para gerar o calendário.'); + } + return { generation, ...calendarSummary(matches) }; + }); + + app.post('/api/admin/calendar/generate', { preHandler: requireAdmin }, async (request) => { + const body = parse(calendarGenerationSaveSchema, request.body); + const matchesToSave = generateRoundRobinSchedule( + teamsForGeneration(getTeamSummaries(database), body.generation), + body.generation.legs, + ); + if (matchesToSave.length === 0) { + throw new AppError(409, 'São necessárias pelo menos duas equipas no mesmo grupo para gerar o calendário.'); + } + const hasScores = database.prepare('SELECT 1 FROM league_matches WHERE score_a IS NOT NULL LIMIT 1').get() !== undefined; + if (hasScores) { + requireConfirmation(body.confirmReplace, 'Já existem resultados registados. Confirme a substituição do calendário para eliminar esses resultados.'); + } + database.transaction(() => { + database.prepare('DELETE FROM league_matches').run(); + database.prepare('DELETE FROM league_rounds').run(); + const insertRound = database.prepare('INSERT INTO league_rounds (round_index) VALUES (?)'); + [...new Set(matchesToSave.map((match) => match.roundNumber))].forEach((roundNumber) => insertRound.run(roundNumber)); + const insert = database.prepare(` + INSERT INTO league_matches (order_index, round_index, team_a_id, team_b_id, score_a, score_b) + VALUES (?, ?, ?, ?, NULL, NULL) + `); + matchesToSave.forEach((match) => insert.run( + match.gameNumber, + match.roundNumber, + match.teamA.id, + match.teamB.id, + )); + })(); + stateChanged(); + return calendarSummary(getLeagueMatches(database)); + }); + + app.delete('/api/admin/calendar', { preHandler: requireAdmin }, async (request) => { + const body = parse(confirmationSchema, request.body ?? {}); + const hasMatches = database.prepare('SELECT 1 FROM league_matches LIMIT 1').get() !== undefined; + if (hasMatches) { + requireConfirmation(body.confirm, 'Confirme que pretende limpar o calendário e todos os seus resultados.'); + database.prepare('DELETE FROM league_matches').run(); + database.prepare('DELETE FROM league_rounds').run(); + stateChanged(); + } + return replyEmpty(); + }); + + app.put('/api/admin/matches/:id/result', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ id: positiveIdParameterSchema }), request.params); + const body = parse(resultInputSchema, request.body); + const result = database.prepare('UPDATE league_matches SET score_a = ?, score_b = ? WHERE id = ?').run(body.scoreA, body.scoreB, params.id); + if (result.changes === 0) { + throw new AppError(404, 'O jogo não existe.'); + } + stateChanged(); + return getLeagueMatches(database).find((match) => match.id === params.id); + }); + + app.delete('/api/admin/matches/:id/result', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ id: positiveIdParameterSchema }), request.params); + const result = database.prepare('UPDATE league_matches SET score_a = NULL, score_b = NULL WHERE id = ?').run(params.id); + if (result.changes === 0) { + throw new AppError(404, 'O jogo não existe.'); + } + stateChanged(); + return replyEmpty(); + }); + + app.put('/api/admin/rounds/:round/standings', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ round: positiveIdParameterSchema }), request.params); + const body = parse(roundStandingsInputSchema, request.body); + const result = database.prepare(` + UPDATE league_rounds + SET counts_toward_standings = ? + WHERE round_index = ? + `).run(body.countsTowardStandings ? 1 : 0, params.round); + if (result.changes === 0) { + throw new AppError(404, 'A jornada não existe.'); + } + stateChanged(); + return { roundNumber: params.round, countsTowardStandings: body.countsTowardStandings }; + }); + + app.get('/api/admin/final-stage', { preHandler: requireAdmin }, async () => getFinalStage(database)); + + app.put('/api/admin/final-stage/config', { preHandler: requireAdmin }, async (request) => { + const body = parse(finalConfigSchema, request.body); + if (body.thirdPlaceEnabled && body.roundCount < 2) { + throw new AppError(400, 'O jogo do 3.º lugar requer pelo menos duas rondas.'); + } + const current = getSettings(database); + const changed = current.finalRoundCount !== body.roundCount || current.thirdPlaceEnabled !== body.thirdPlaceEnabled; + if (changed && current.finalRoundCount !== null) { + requireConfirmation(body.confirmClearResults, 'Alterar a configuração elimina as equipas e os resultados da fase final. Confirme esta alteração.'); + } + if (changed) { + database.transaction(() => { + database.prepare(` + UPDATE tournament_settings + SET final_round_count = ?, third_place_enabled = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(body.roundCount, body.thirdPlaceEnabled ? 1 : 0); + database.prepare('DELETE FROM final_seeds').run(); + database.prepare('DELETE FROM final_match_results').run(); + })(); + stateChanged(); + } + return getFinalStage(database); + }); + + app.delete('/api/admin/final-stage/config', { preHandler: requireAdmin }, async (request) => { + const body = parse(confirmationSchema, request.body ?? {}); + const settings = getSettings(database); + if (settings.finalRoundCount === null) { + return getFinalStage(database); + } + requireConfirmation(body.confirm, 'Confirme que pretende limpar a configuração e todos os resultados da fase final.'); + database.transaction(() => { + database.prepare(` + UPDATE tournament_settings + SET final_round_count = NULL, third_place_enabled = 0, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(); + database.prepare('DELETE FROM final_seeds').run(); + database.prepare('DELETE FROM final_match_results').run(); + })(); + stateChanged(); + return getFinalStage(database); + }); + + app.post('/api/admin/final-stage/auto-seed-preview', { preHandler: requireAdmin }, async (request) => { + const body = parse(autoSeedPreviewSchema, request.body); + const settings = getSettings(database); + if (settings.finalRoundCount === null) { + throw new AppError(409, 'Configure primeiro o número de rondas da fase final.'); + } + + const tournamentState = getPublicState(database); + const standings = tournamentState.groupStandings.filter((standing) => standing.rows.length > 0); + if (standings.length === 0) { + throw new AppError(409, 'Ainda não existem equipas classificadas para preencher a fase final.'); + } + if (!standings.some((standing) => standing.rows.some((row) => row.played > 0))) { + throw new AppError(409, 'Ainda não existem resultados contabilizados na classificação.'); + } + if (tournamentState.matches.some( + (match) => match.countsTowardStandings && (match.scoreA === null || match.scoreB === null), + )) { + throw new AppError(409, 'Conclua primeiro todos os jogos que contam para a classificação.'); + } + + const slotCount = 2 ** settings.finalRoundCount; + if (body.mode === 'per-group') { + const groupWithoutEnoughTeams = standings.find((standing) => standing.rows.length < body.qualifiersPerGroup); + if (groupWithoutEnoughTeams) { + throw new AppError( + 400, + `${groupWithoutEnoughTeams.group.name} não tem ${body.qualifiersPerGroup} equipas para apurar.`, + ); + } + if (standings.length * body.qualifiersPerGroup > slotCount) { + throw new AppError(400, `A fase final só tem ${slotCount} lugares. Reduza o número de apurados por grupo.`); + } + return buildGroupQualificationPreview(standings, body.qualifiersPerGroup, slotCount); + } + + const classifiedTeamCount = standings.reduce((total, standing) => total + standing.rows.length, 0); + if (body.qualifierCount > slotCount) { + throw new AppError(400, `A fase final só tem ${slotCount} lugares. Reduza o número total de apurados.`); + } + if (body.qualifierCount > classifiedTeamCount) { + throw new AppError(400, `Só existem ${classifiedTeamCount} equipas classificadas.`); + } + + return buildOverallQualificationPreview( + standings, + body.qualifierCount, + slotCount, + tournamentState.tournament.classificationMode, + ); + }); + + app.put('/api/admin/final-stage/seeds', { preHandler: requireAdmin }, async (request) => { + const body = parse(finalSeedsSchema, request.body); + const settings = getSettings(database); + const roundCount = settings.finalRoundCount; + if (roundCount === null) { + throw new AppError(409, 'Configure primeiro o número de rondas da fase final.'); + } + const slotCount = 2 ** roundCount; + const slotIndexes = new Set(body.seeds.map((seed) => seed.slotIndex)); + if (body.seeds.length !== slotCount || slotIndexes.size !== slotCount || [...slotIndexes].some((slot) => slot < 1 || slot > slotCount)) { + throw new AppError(400, `Indique exatamente as ${slotCount} posições iniciais da fase final.`); + } + const assignedTeamIds = body.seeds.flatMap((seed) => seed.teamId === null ? [] : [seed.teamId]); + if (new Set(assignedTeamIds).size !== assignedTeamIds.length) { + throw new AppError(400, 'A mesma equipa não pode ser atribuída a mais do que uma posição.'); + } + for (const teamId of assignedTeamIds) { + assertExistingTeam(database, teamId); + } + + const current = database.prepare('SELECT slot_index, team_id FROM final_seeds').all() as Array<{ slot_index: number; team_id: number | null }>; + const currentBySlot = new Map(current.map((seed) => [seed.slot_index, seed.team_id])); + const changed = body.seeds.some((seed) => (currentBySlot.get(seed.slotIndex) ?? null) !== seed.teamId) || current.length !== body.seeds.length; + if (changed && finalResultExists(database)) { + requireConfirmation(body.confirmClearResults, 'Alterar as equipas iniciais elimina todos os resultados da fase final. Confirme esta alteração.'); + } + if (changed) { + database.transaction(() => { + database.prepare('DELETE FROM final_seeds').run(); + const insert = database.prepare('INSERT INTO final_seeds (slot_index, team_id) VALUES (?, ?)'); + body.seeds.forEach((seed) => insert.run(seed.slotIndex, seed.teamId)); + database.prepare('DELETE FROM final_match_results').run(); + })(); + stateChanged(); + } + return getFinalStage(database, roundCount); + }); + + app.put('/api/admin/final-stage/matches/:round/:match/result', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ round: positiveIdParameterSchema, match: positiveIdParameterSchema }), request.params); + const body = parse(resultInputSchema, request.body); + if (body.scoreA === body.scoreB) { + throw new AppError(400, 'Os jogos da fase final não podem terminar empatados.'); + } + const settings = getSettings(database); + const roundCount = settings.finalRoundCount; + if (roundCount === null) { + throw new AppError(409, 'A fase final ainda não está configurada.'); + } + const before = getFinalStage(database, roundCount, settings.thirdPlaceEnabled); + const match = getBracketMatch(before, params.round, params.match); + if (!match) { + throw new AppError(404, 'O jogo da fase final não existe.'); + } + if (!match.teamA.team || !match.teamB.team || match.teamA.pending || match.teamB.pending) { + throw new AppError(409, 'Só pode registar um resultado quando as duas equipas estiverem apuradas.'); + } + const previousWinner = match.winner?.id ?? null; + database.transaction(() => { + database.prepare(` + INSERT INTO final_match_results (round_index, match_index, score_a, score_b) + VALUES (?, ?, ?, ?) + ON CONFLICT(round_index, match_index) + DO UPDATE SET score_a = excluded.score_a, score_b = excluded.score_b + `).run(params.round, params.match, body.scoreA, body.scoreB); + clearInvalidatedFinalResults( + database, + roundCount, + settings.thirdPlaceEnabled, + params.round, + params.match, + previousWinner, + ); + })(); + stateChanged(); + return getFinalStage(database, roundCount); + }); + + app.delete('/api/admin/final-stage/matches/:round/:match/result', { preHandler: requireAdmin }, async (request) => { + const params = parse(z.object({ round: positiveIdParameterSchema, match: positiveIdParameterSchema }), request.params); + const settings = getSettings(database); + const roundCount = settings.finalRoundCount; + if (roundCount === null) { + throw new AppError(409, 'A fase final ainda não está configurada.'); + } + const before = getFinalStage(database, roundCount, settings.thirdPlaceEnabled); + const match = getBracketMatch(before, params.round, params.match); + if (!match) { + throw new AppError(404, 'O jogo da fase final não existe.'); + } + const previousWinner = match.winner?.id ?? null; + database.transaction(() => { + database.prepare('DELETE FROM final_match_results WHERE round_index = ? AND match_index = ?').run(params.round, params.match); + clearInvalidatedFinalResults( + database, + roundCount, + settings.thirdPlaceEnabled, + params.round, + params.match, + previousWinner, + ); + })(); + stateChanged(); + return getFinalStage(database, roundCount); + }); + + app.get('/api/public/state', async () => getPublicState(database)); + + app.get('/api/public/events', async (request, reply) => { + reply.hijack(); + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + events.add(reply.raw); + request.raw.on('close', () => events.remove(reply.raw)); + }); + + const staticRoot = options.staticRoot ?? process.env.CLIENT_DIST_PATH ?? resolve(process.cwd(), 'dist', 'client'); + if (existsSync(join(staticRoot, 'index.html'))) { + await app.register(fastifyStatic, { root: staticRoot, prefix: '/' }); + const sendClientApplication = async (_request: FastifyRequest, reply: FastifyReply) => reply.sendFile('index.html'); + app.get('/results', sendClientApplication); + app.get('/display', sendClientApplication); + app.get('/display/', sendClientApplication); + app.get('/admin', sendClientApplication); + app.get('/admin/*', sendClientApplication); + } + + app.addHook('onClose', async () => { + events.close(); + if (ownsDatabase) { + database.close(); + } + }); + + return app; +} + +function replyEmpty(): { ok: true } { + return { ok: true }; +} diff --git a/apps/tournament/src/server/auth.ts b/apps/tournament/src/server/auth.ts new file mode 100644 index 0000000..aa5f741 --- /dev/null +++ b/apps/tournament/src/server/auth.ts @@ -0,0 +1,100 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { FastifyRequest } from 'fastify'; + +export const SESSION_COOKIE_NAME = 'tournament_admin_session'; + +export interface AuthenticationConfig { + adminPassword: string; + sessionSecret: string; + sessionLifetimeMs?: number; +} + +interface SessionPayload { + expiresAt: number; +} + +function constantTimeEquals(actual: string, expected: string): boolean { + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); +} + +function sign(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('base64url'); +} + +export function createSessionToken(config: AuthenticationConfig, now = Date.now()): string { + const payload = Buffer.from(JSON.stringify({ + expiresAt: now + (config.sessionLifetimeMs ?? 7 * 24 * 60 * 60 * 1000), + } satisfies SessionPayload)).toString('base64url'); + return `${payload}.${sign(payload, config.sessionSecret)}`; +} + +export function isValidSessionToken(token: string | undefined, config: AuthenticationConfig, now = Date.now()): boolean { + if (!token) { + return false; + } + const separatorIndex = token.indexOf('.'); + if (separatorIndex < 1 || separatorIndex !== token.lastIndexOf('.')) { + return false; + } + const payload = token.slice(0, separatorIndex); + const signature = token.slice(separatorIndex + 1); + if (!constantTimeEquals(signature, sign(payload, config.sessionSecret))) { + return false; + } + try { + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as SessionPayload; + return Number.isFinite(decoded.expiresAt) && decoded.expiresAt > now; + } catch { + return false; + } +} + +export function passwordMatches(password: string, expectedPassword: string): boolean { + return constantTimeEquals(password, expectedPassword); +} + +export function isSecureRequest(request: FastifyRequest): boolean { + return request.protocol === 'https'; +} + +interface LoginAttempt { + failures: number; + resetAt: number; +} + +/** Small in-memory limiter. A process restart deliberately resets it. */ +export class LoginRateLimiter { + private readonly attempts = new Map(); + + public constructor( + private readonly maximumFailures = 5, + private readonly windowMs = 15 * 60 * 1000, + ) {} + + public isLimited(key: string, now = Date.now()): boolean { + const attempt = this.attempts.get(key); + if (!attempt) { + return false; + } + if (attempt.resetAt <= now) { + this.attempts.delete(key); + return false; + } + return attempt.failures >= this.maximumFailures; + } + + public recordFailure(key: string, now = Date.now()): void { + const previous = this.attempts.get(key); + if (!previous || previous.resetAt <= now) { + this.attempts.set(key, { failures: 1, resetAt: now + this.windowMs }); + return; + } + previous.failures += 1; + } + + public clear(key: string): void { + this.attempts.delete(key); + } +} diff --git a/apps/tournament/src/server/db/database.test.ts b/apps/tournament/src/server/db/database.test.ts new file mode 100644 index 0000000..d601ab3 --- /dev/null +++ b/apps/tournament/src/server/db/database.test.ts @@ -0,0 +1,69 @@ +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import { initializeSchema, type SqliteDatabase } from './database.js'; +import { getDisplaySettings } from '../services/state.js'; + +let database: SqliteDatabase | undefined; + +afterEach(() => { + database?.close(); + database = undefined; +}); + +describe('database initialization', () => { + it('creates the current schema and its default rows', () => { + database = new Database(':memory:'); + database.pragma('foreign_keys = ON'); + initializeSchema(database); + + expect(database.prepare('SELECT id, name, sort_order FROM league_groups').all()).toEqual([ + { id: 1, name: 'Grupo A', sort_order: 0 }, + ]); + expect(database.prepare(` + SELECT name, final_round_count, third_place_enabled, classification_mode + FROM tournament_settings + `).get()).toEqual({ + name: 'Torneio', + final_round_count: null, + third_place_enabled: 0, + classification_mode: 'standard', + }); + expect(database.prepare('SELECT id, active_panel, zoom_percent FROM display_settings').get()).toEqual({ + id: 1, + active_panel: 'latest-results', + zoom_percent: 100, + }); + expect(database.prepare('SELECT * FROM league_rounds').all()).toEqual([]); + expect(() => database!.prepare(` + INSERT INTO teams (number, name, created_at, updated_at) + VALUES (1, 'Sem grupo', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `).run()).toThrow('NOT NULL constraint failed'); + }); + + it('enforces the current schema constraints', () => { + database = new Database(':memory:'); + database.pragma('foreign_keys = ON'); + initializeSchema(database); + + database.prepare(` + INSERT INTO teams (id, number, name, group_id, created_at, updated_at) + VALUES (1, 1, 'Equipa 1', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `).run(); + expect(() => database!.prepare(` + INSERT INTO league_matches (order_index, round_index, team_a_id, team_b_id) + VALUES (1, 0, 1, 1) + `).run()).toThrow('CHECK constraint failed'); + expect(() => database!.prepare(` + UPDATE tournament_settings SET classification_mode = 'invalid' WHERE id = 1 + `).run()).toThrow('CHECK constraint failed'); + expect(() => database!.prepare(` + UPDATE display_settings SET zoom_percent = 401 WHERE id = 1 + `).run()).toThrow('CHECK constraint failed'); + expect(() => database!.prepare(` + INSERT INTO league_rounds (round_index, counts_toward_standings) + VALUES (1, 2) + `).run()).toThrow('CHECK constraint failed'); + expect(getDisplaySettings(database)).toEqual({ activePanel: 'latest-results', zoomPercent: 100 }); + }); + +}); diff --git a/apps/tournament/src/server/db/database.ts b/apps/tournament/src/server/db/database.ts new file mode 100644 index 0000000..c4d2a6e --- /dev/null +++ b/apps/tournament/src/server/db/database.ts @@ -0,0 +1,134 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export type SqliteDatabase = Database.Database; + +export interface DatabaseOptions { + path?: string; +} + +const currentSchema = ` + CREATE TABLE IF NOT EXISTS tournament_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + final_round_count INTEGER CHECK (final_round_count IS NULL OR (final_round_count BETWEEN 1 AND 8)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + third_place_enabled INTEGER NOT NULL DEFAULT 0 CHECK (third_place_enabled IN (0, 1)), + classification_mode TEXT NOT NULL DEFAULT 'standard' CHECK (classification_mode IN ('standard', 'total-points')) + ); + + INSERT OR IGNORE INTO tournament_settings ( + id, name, final_round_count, created_at, updated_at, third_place_enabled, classification_mode + ) VALUES (1, 'Torneio', NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0, 'standard'); + + CREATE TABLE IF NOT EXISTS display_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active_panel TEXT NOT NULL CHECK (length(trim(active_panel)) > 0), + zoom_percent INTEGER NOT NULL DEFAULT 100 CHECK (zoom_percent BETWEEN 50 AND 400), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + INSERT OR IGNORE INTO display_settings (id, active_panel, zoom_percent, created_at, updated_at) + VALUES (1, 'latest-results', 100, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + + CREATE TABLE IF NOT EXISTS league_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK (length(trim(name)) > 0), + sort_order INTEGER NOT NULL UNIQUE CHECK (sort_order >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + INSERT OR IGNORE INTO league_groups (id, name, sort_order, created_at, updated_at) + VALUES (1, 'Grupo A', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + + CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + number INTEGER NOT NULL UNIQUE CHECK (number > 0), + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + group_id INTEGER NOT NULL REFERENCES league_groups(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + sort_order INTEGER NOT NULL CHECK (sort_order >= 0), + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS league_matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_index INTEGER NOT NULL UNIQUE CHECK (order_index >= 1), + round_index INTEGER NOT NULL CHECK (round_index >= 1), + team_a_id INTEGER NOT NULL, + team_b_id INTEGER NOT NULL, + score_a INTEGER, + score_b INTEGER, + CHECK (team_a_id <> team_b_id), + CHECK ( + (score_a IS NULL AND score_b IS NULL) OR + (score_a IS NOT NULL AND score_b IS NOT NULL AND score_a >= 0 AND score_b >= 0) + ), + FOREIGN KEY (team_a_id) REFERENCES teams(id), + FOREIGN KEY (team_b_id) REFERENCES teams(id) + ); + + CREATE TABLE IF NOT EXISTS final_seeds ( + slot_index INTEGER PRIMARY KEY CHECK (slot_index >= 1), + team_id INTEGER, + FOREIGN KEY (team_id) REFERENCES teams(id) + ); + + CREATE TABLE IF NOT EXISTS final_match_results ( + round_index INTEGER NOT NULL CHECK (round_index >= 1), + match_index INTEGER NOT NULL CHECK (match_index >= 1), + score_a INTEGER, + score_b INTEGER, + PRIMARY KEY (round_index, match_index), + CHECK ( + (score_a IS NULL AND score_b IS NULL) OR + (score_a IS NOT NULL AND score_b IS NOT NULL AND score_a >= 0 AND score_b >= 0) + ) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_final_seeds_unique_team + ON final_seeds(team_id) + WHERE team_id IS NOT NULL; + + CREATE INDEX IF NOT EXISTS idx_teams_group ON teams(group_id); + CREATE INDEX IF NOT EXISTS idx_players_team_order ON players(team_id, sort_order); + CREATE INDEX IF NOT EXISTS idx_league_matches_order ON league_matches(order_index); + + CREATE TABLE IF NOT EXISTS league_rounds ( + round_index INTEGER PRIMARY KEY CHECK (round_index >= 1), + counts_toward_standings INTEGER NOT NULL DEFAULT 0 CHECK (counts_toward_standings IN (0, 1)) + ); +`; + +function defaultDatabasePath(): string { + return process.env.DATABASE_PATH ?? '/data/tournament.sqlite'; +} + +export function initializeSchema(database: SqliteDatabase): void { + database.transaction(() => database.exec(currentSchema))(); +} + +/** Opens SQLite with the connection settings required for durable concurrent HTTP use. */ +export function openDatabase(options: DatabaseOptions = {}): SqliteDatabase { + const databasePath = options.path ?? defaultDatabasePath(); + if (databasePath !== ':memory:') { + mkdirSync(dirname(databasePath), { recursive: true }); + } + const database = new Database(databasePath); + database.pragma('foreign_keys = ON'); + database.pragma('journal_mode = WAL'); + database.pragma('busy_timeout = 5000'); + initializeSchema(database); + return database; +} diff --git a/apps/tournament/src/server/errors.ts b/apps/tournament/src/server/errors.ts new file mode 100644 index 0000000..3a14586 --- /dev/null +++ b/apps/tournament/src/server/errors.ts @@ -0,0 +1,9 @@ +export class AppError extends Error { + public readonly statusCode: number; + + public constructor(statusCode: number, message: string) { + super(message); + this.name = 'AppError'; + this.statusCode = statusCode; + } +} diff --git a/apps/tournament/src/server/events.ts b/apps/tournament/src/server/events.ts new file mode 100644 index 0000000..3678156 --- /dev/null +++ b/apps/tournament/src/server/events.ts @@ -0,0 +1,43 @@ +import type { ServerResponse } from 'node:http'; + +/** Keeps SSE connections as notification-only streams, never state replicas. */ +export class StateChangeEvents { + private readonly clients = new Set(); + private readonly heartbeat: NodeJS.Timeout; + + public constructor(heartbeatMs = 25_000) { + this.heartbeat = setInterval(() => this.writeToAll(': heartbeat\n\n'), heartbeatMs); + this.heartbeat.unref(); + } + + public add(response: ServerResponse): void { + this.clients.add(response); + response.write('retry: 3000\n\n'); + } + + public remove(response: ServerResponse): void { + this.clients.delete(response); + } + + public broadcastStateChanged(): void { + this.writeToAll('event: state-changed\ndata: {"type":"state-changed"}\n\n'); + } + + public close(): void { + clearInterval(this.heartbeat); + for (const client of this.clients) { + client.end(); + } + this.clients.clear(); + } + + private writeToAll(message: string): void { + for (const client of this.clients) { + if (client.destroyed) { + this.clients.delete(client); + continue; + } + client.write(message); + } + } +} diff --git a/apps/tournament/src/server/main.ts b/apps/tournament/src/server/main.ts new file mode 100644 index 0000000..ed47655 --- /dev/null +++ b/apps/tournament/src/server/main.ts @@ -0,0 +1,17 @@ +import { createApp } from './app.js'; + +const port = 8080; +const host = process.env.HOST ?? '0.0.0.0'; + +async function start(): Promise { + const app = await createApp({ logger: true }); + try { + await app.listen({ host, port }); + } catch (error) { + app.log.error(error); + process.exitCode = 1; + await app.close(); + } +} + +void start(); diff --git a/apps/tournament/src/server/services/bracket.test.ts b/apps/tournament/src/server/services/bracket.test.ts new file mode 100644 index 0000000..704c3c7 --- /dev/null +++ b/apps/tournament/src/server/services/bracket.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { Group, TeamSummary } from '../../shared/types/tournament.js'; +import { calculateBracket, downstreamMatches } from './bracket.js'; + +const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; +const team = (id: number): TeamSummary => ({ id, number: id, name: `Equipa ${id}`, group }); +const seeds = [1, 2, 3, 4].map((slotIndex) => ({ slotIndex, team: team(slotIndex) })); + +describe('calculateBracket', () => { + it('faz avançar vencedores entre várias rondas e determina o campeão', () => { + const bracket = calculateBracket(2, seeds, [ + { roundIndex: 1, matchIndex: 1, scoreA: 5, scoreB: 2 }, + { roundIndex: 1, matchIndex: 2, scoreA: 1, scoreB: 3 }, + { roundIndex: 2, matchIndex: 1, scoreA: 4, scoreB: 2 }, + ]); + expect(bracket.rounds[1].matches[0].teamA.team?.id).toBe(1); + expect(bracket.rounds[1].matches[0].teamB.team?.id).toBe(4); + expect(bracket.champion?.id).toBe(1); + }); + + it('não faz avançar um empate que a API deve rejeitar', () => { + const bracket = calculateBracket(1, seeds.slice(0, 2), [ + { roundIndex: 1, matchIndex: 1, scoreA: 3, scoreB: 3 }, + ]); + expect(bracket.rounds[0].matches[0].winner).toBeNull(); + expect(bracket.champion).toBeNull(); + }); + + it('faz avançar uma equipa automaticamente num bye e deixa jogos vazios por apurar', () => { + const bracket = calculateBracket(2, [ + { slotIndex: 1, team: team(1) }, + { slotIndex: 2, team: null }, + { slotIndex: 3, team: null }, + { slotIndex: 4, team: null }, + ], []); + expect(bracket.rounds[0].matches[0]).toMatchObject({ isBye: true, winner: team(1) }); + expect(bracket.rounds[0].matches[1].winner).toBeNull(); + expect(bracket.rounds[1].matches[0].teamA.team?.id).toBe(1); + expect(bracket.rounds[1].matches[0].teamB.pending).toBe(false); + }); + + it('cria o jogo do 3.º lugar com os derrotados das meias-finais', () => { + const bracket = calculateBracket(2, seeds, [ + { roundIndex: 1, matchIndex: 1, scoreA: 5, scoreB: 2 }, + { roundIndex: 1, matchIndex: 2, scoreA: 1, scoreB: 3 }, + { roundIndex: 2, matchIndex: 2, scoreA: 2, scoreB: 4 }, + ], true); + expect(bracket.thirdPlaceEnabled).toBe(true); + expect(bracket.thirdPlaceMatch).toMatchObject({ + matchIndex: 2, + teamA: { team: { id: 2 } }, + teamB: { team: { id: 3 } }, + scoreA: 2, + scoreB: 4, + winner: { id: 3 }, + }); + }); + + it('identifica todos os resultados a invalidar após mudar um jogo a montante', () => { + expect(downstreamMatches(4, 1, 3)).toEqual([ + { roundIndex: 2, matchIndex: 2 }, + { roundIndex: 3, matchIndex: 1 }, + { roundIndex: 4, matchIndex: 1 }, + ]); + }); +}); diff --git a/apps/tournament/src/server/services/bracket.ts b/apps/tournament/src/server/services/bracket.ts new file mode 100644 index 0000000..73f101b --- /dev/null +++ b/apps/tournament/src/server/services/bracket.ts @@ -0,0 +1,182 @@ +import type { + BracketMatch, + BracketParticipant, + BracketRound, + FinalStage, + TeamSummary, +} from '../../shared/types/tournament.js'; + +export interface BracketSeed { + slotIndex: number; + team: TeamSummary | null; +} + +export interface BracketResult { + roundIndex: number; + matchIndex: number; + scoreA: number | null; + scoreB: number | null; +} + +interface CalculatedMatch extends BracketMatch { + canProduceWinner: boolean; +} + +function roundName(roundCount: number, roundIndex: number): string { + const participantCount = 2 ** (roundCount - roundIndex + 1); + if (participantCount === 2) { + return 'Final'; + } + if (participantCount === 4) { + return 'Meias-finais'; + } + if (participantCount === 8) { + return 'Quartos de final'; + } + if (participantCount === 16) { + return 'Oitavos de final'; + } + return `Ronda de ${participantCount}`; +} + +function participant(team: TeamSummary | null, pending = false): BracketParticipant { + return { team, pending }; +} + +function resultFor( + results: ReadonlyMap, + roundIndex: number, + matchIndex: number, +): BracketResult | undefined { + return results.get(`${roundIndex}:${matchIndex}`); +} + +/** Builds a bracket exclusively from initial seeds and stored score rows. */ +export function calculateBracket( + roundCount: number | null, + seeds: readonly BracketSeed[], + results: readonly BracketResult[], + thirdPlaceEnabled = false, +): FinalStage { + if (roundCount === null) { + return { roundCount: null, thirdPlaceEnabled: false, rounds: [], thirdPlaceMatch: null, champion: null }; + } + if (!Number.isInteger(roundCount) || roundCount < 1 || roundCount > 8) { + throw new RangeError('O número de rondas tem de estar entre 1 e 8.'); + } + + const seedsBySlot = new Map(seeds.map((seed) => [seed.slotIndex, seed.team])); + const resultsByMatch = new Map(results.map((result) => [`${result.roundIndex}:${result.matchIndex}`, result])); + const calculatedRounds: CalculatedMatch[][] = []; + + for (let roundIndex = 1; roundIndex <= roundCount; roundIndex += 1) { + const matchCount = 2 ** (roundCount - roundIndex); + const matches: CalculatedMatch[] = []; + for (let matchIndex = 1; matchIndex <= matchCount; matchIndex += 1) { + let teamA: BracketParticipant; + let teamB: BracketParticipant; + if (roundIndex === 1) { + teamA = participant(seedsBySlot.get((matchIndex - 1) * 2 + 1) ?? null); + teamB = participant(seedsBySlot.get((matchIndex - 1) * 2 + 2) ?? null); + } else { + const previousA = calculatedRounds[roundIndex - 2][(matchIndex - 1) * 2]; + const previousB = calculatedRounds[roundIndex - 2][(matchIndex - 1) * 2 + 1]; + teamA = previousA.winner + ? participant(previousA.winner) + : participant(null, previousA.canProduceWinner); + teamB = previousB.winner + ? participant(previousB.winner) + : participant(null, previousB.canProduceWinner); + } + + const result = resultFor(resultsByMatch, roundIndex, matchIndex); + const scoresAreValid = result && result.scoreA !== null && result.scoreB !== null && result.scoreA !== result.scoreB; + const hasBothTeams = teamA.team !== null && teamB.team !== null; + const isBye = !teamA.pending && !teamB.pending && ((teamA.team === null) !== (teamB.team === null)); + const isEmpty = !teamA.pending && !teamB.pending && teamA.team === null && teamB.team === null; + let winner: TeamSummary | null = null; + if (isBye) { + winner = teamA.team ?? teamB.team; + } else if (hasBothTeams && scoresAreValid) { + winner = result.scoreA! > result.scoreB! ? teamA.team : teamB.team; + } + + matches.push({ + matchIndex, + teamA, + teamB, + scoreA: hasBothTeams ? result?.scoreA ?? null : null, + scoreB: hasBothTeams ? result?.scoreB ?? null : null, + winner, + isBye, + canProduceWinner: !isEmpty && (winner !== null || teamA.pending || teamB.pending || hasBothTeams), + }); + } + calculatedRounds.push(matches); + } + + const rounds: BracketRound[] = calculatedRounds.map((matches, index) => ({ + roundIndex: index + 1, + name: roundName(roundCount, index + 1), + matches: matches.map(({ canProduceWinner: _canProduceWinner, ...match }) => match), + })); + const finalMatch = calculatedRounds[calculatedRounds.length - 1][0]; + const thirdPlaceMatch = thirdPlaceEnabled && roundCount >= 2 + ? calculateThirdPlaceMatch(calculatedRounds[roundCount - 2], resultFor(resultsByMatch, roundCount, 2)) + : null; + return { roundCount, thirdPlaceEnabled, rounds, thirdPlaceMatch, champion: finalMatch.winner }; +} + +export function getBracketMatch(finalStage: FinalStage, roundIndex: number, matchIndex: number): BracketMatch | null { + if (finalStage.thirdPlaceMatch && roundIndex === finalStage.roundCount && matchIndex === finalStage.thirdPlaceMatch.matchIndex) { + return finalStage.thirdPlaceMatch; + } + return finalStage.rounds[roundIndex - 1]?.matches[matchIndex - 1] ?? null; +} + +function calculateThirdPlaceMatch( + semiFinals: readonly CalculatedMatch[], + result: BracketResult | undefined, +): BracketMatch { + const teamA = loserOf(semiFinals[0]); + const teamB = loserOf(semiFinals[1]); + const hasBothTeams = teamA.team !== null && teamB.team !== null; + const scoresAreValid = result && result.scoreA !== null && result.scoreB !== null && result.scoreA !== result.scoreB; + const winner = hasBothTeams && scoresAreValid + ? result.scoreA! > result.scoreB! ? teamA.team : teamB.team + : null; + return { + matchIndex: 2, + teamA, + teamB, + scoreA: hasBothTeams ? result?.scoreA ?? null : null, + scoreB: hasBothTeams ? result?.scoreB ?? null : null, + winner, + isBye: false, + }; +} + +function loserOf(match: CalculatedMatch): BracketParticipant { + if (match.teamA.pending || match.teamB.pending) { + return participant(null, true); + } + if (!match.teamA.team || !match.teamB.team || !match.winner) { + return participant(null, Boolean(match.teamA.team && match.teamB.team)); + } + return participant(match.winner.id === match.teamA.team.id ? match.teamB.team : match.teamA.team); +} + +/** Lists all match result rows whose participants depend on a match. */ +export function downstreamMatches( + roundCount: number, + roundIndex: number, + matchIndex: number, +): Array<{ roundIndex: number; matchIndex: number }> { + const matches: Array<{ roundIndex: number; matchIndex: number }> = []; + let nextMatchIndex = matchIndex; + for (let nextRound = roundIndex + 1; nextRound <= roundCount; nextRound += 1) { + nextMatchIndex = Math.ceil(nextMatchIndex / 2); + matches.push({ roundIndex: nextRound, matchIndex: nextMatchIndex }); + } + return matches; +} diff --git a/apps/tournament/src/server/services/calendar.test.ts b/apps/tournament/src/server/services/calendar.test.ts new file mode 100644 index 0000000..6985017 --- /dev/null +++ b/apps/tournament/src/server/services/calendar.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import type { Group, TeamSummary } from '../../shared/types/tournament.js'; +import { generateRoundRobinSchedule } from './calendar.js'; + +const team = (id: number, group: Group): TeamSummary => ({ + id, + number: id, + name: `Equipa ${id}`, + group, +}); + +function pairCounts(schedule: ReturnType): Map { + const counts = new Map(); + for (const game of schedule) { + const key = [game.teamA.id, game.teamB.id].sort((a, b) => a - b).join('-'); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; +} + +describe('generateRoundRobinSchedule', () => { + it('gera as três jornadas de um grupo par com quatro equipas', () => { + const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teams = [1, 2, 3, 4].map((id) => team(id, group)); + + const schedule = generateRoundRobinSchedule(teams, 1); + + expect(schedule.map((game) => ({ + gameNumber: game.gameNumber, + roundNumber: game.roundNumber, + groupId: game.group.id, + teams: [game.teamA.id, game.teamB.id], + }))).toEqual([ + { gameNumber: 1, roundNumber: 1, groupId: 1, teams: [1, 4] }, + { gameNumber: 2, roundNumber: 1, groupId: 1, teams: [2, 3] }, + { gameNumber: 3, roundNumber: 2, groupId: 1, teams: [3, 1] }, + { gameNumber: 4, roundNumber: 2, groupId: 1, teams: [4, 2] }, + { gameNumber: 5, roundNumber: 3, groupId: 1, teams: [1, 2] }, + { gameNumber: 6, roundNumber: 3, groupId: 1, teams: [3, 4] }, + ]); + }); + + it('suporta um grupo ímpar através de uma folga por jornada', () => { + const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teams = [1, 2, 3].map((id) => team(id, group)); + + const schedule = generateRoundRobinSchedule(teams, 1); + + expect(schedule.map((game) => [game.roundNumber, game.teamA.id, game.teamB.id])).toEqual([ + [1, 2, 3], + [2, 3, 1], + [3, 1, 2], + ]); + expect(schedule.map((game) => game.roundNumber)).toEqual([1, 2, 3]); + }); + + it('combina grupos ordenados nas mesmas jornadas sem cruzar nem repetir equipas', () => { + const group2: Group = { id: 2, name: 'Grupo 2', sortOrder: 0 }; + const group1: Group = { id: 1, name: 'Grupo 1', sortOrder: 0 }; + const group3: Group = { id: 3, name: 'Grupo 3', sortOrder: 1 }; + const teams = [ + team(21, group2), + team(11, group1), + team(31, group3), + team(22, group2), + team(12, group1), + team(32, group3), + team(23, group2), + team(13, group1), + team(14, group1), + ]; + + const schedule = generateRoundRobinSchedule(teams, 1); + + expect(schedule.map((game) => [game.roundNumber, game.group.id])).toEqual([ + [1, 1], [1, 1], [1, 2], [1, 3], + [2, 1], [2, 1], [2, 2], + [3, 1], [3, 1], [3, 2], + ]); + expect(schedule.every((game) => ( + game.teamA.group.id === game.group.id && game.teamB.group.id === game.group.id + ))).toBe(true); + + for (const roundNumber of [1, 2, 3]) { + const participants = schedule + .filter((game) => game.roundNumber === roundNumber) + .flatMap((game) => [game.teamA.id, game.teamB.id]); + expect(new Set(participants).size).toBe(participants.length); + } + }); + + it('anexa uma segunda volta que espelha exatamente a primeira', () => { + const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teams = [1, 2, 3, 4].map((id) => team(id, group)); + const firstLeg = generateRoundRobinSchedule(teams, 1); + + const schedule = generateRoundRobinSchedule(teams, 2); + + expect(schedule.slice(0, firstLeg.length)).toEqual(firstLeg); + expect(schedule.slice(firstLeg.length)).toEqual(firstLeg.map((game, index) => ({ + gameNumber: firstLeg.length + index + 1, + roundNumber: game.roundNumber + 3, + group: game.group, + teamA: game.teamB, + teamB: game.teamA, + }))); + }); + + it('agenda todos os pares uma vez por volta', () => { + const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; + const teams = [1, 2, 3, 4].map((id) => team(id, group)); + const expectedPairs = ['1-2', '1-3', '1-4', '2-3', '2-4', '3-4']; + + expect(pairCounts(generateRoundRobinSchedule(teams, 1))).toEqual( + new Map(expectedPairs.map((pair) => [pair, 1])), + ); + expect(pairCounts(generateRoundRobinSchedule(teams, 2))).toEqual( + new Map(expectedPairs.map((pair) => [pair, 2])), + ); + }); + + it('não altera a lista nem a ordem recebidas', () => { + const groupA: Group = { id: 1, name: 'Grupo A', sortOrder: 1 }; + const groupB: Group = { id: 2, name: 'Grupo B', sortOrder: 0 }; + const teams = [team(1, groupA), team(4, groupB), team(3, groupA), team(2, groupB)]; + const snapshot = structuredClone(teams); + + generateRoundRobinSchedule(teams, 2); + + expect(teams).toEqual(snapshot); + expect(teams.map(({ id }) => id)).toEqual([1, 4, 3, 2]); + }); + + it('rejeita um número de voltas inválido mesmo quando o tipo é contornado', () => { + expect(() => generateRoundRobinSchedule([], 3 as 1 | 2)).toThrowError(RangeError); + expect(() => generateRoundRobinSchedule([], 3 as 1 | 2)).toThrow('1 ou 2'); + }); +}); diff --git a/apps/tournament/src/server/services/calendar.ts b/apps/tournament/src/server/services/calendar.ts new file mode 100644 index 0000000..b7c38e5 --- /dev/null +++ b/apps/tournament/src/server/services/calendar.ts @@ -0,0 +1,93 @@ +import type { CalendarPreviewMatch, Group, TeamSummary } from '../../shared/types/tournament.js'; + +export type ScheduledGame = CalendarPreviewMatch; + +interface Pairing { + teamA: TeamSummary; + teamB: TeamSummary; +} + +function generateGroupRounds(teams: readonly TeamSummary[]): Pairing[][] { + if (teams.length < 2) { + return []; + } + + const rotation: Array = [...teams]; + if (rotation.length % 2 !== 0) { + rotation.push(null); + } + + const rounds: Pairing[][] = []; + for (let roundIndex = 0; roundIndex < rotation.length - 1; roundIndex += 1) { + const pairings: Pairing[] = []; + for (let index = 0; index < rotation.length / 2; index += 1) { + const teamA = rotation[index]; + const teamB = rotation[rotation.length - index - 1]; + if (teamA && teamB) { + pairings.push(roundIndex % 2 === 1 && index === 0 + ? { teamA: teamB, teamB: teamA } + : { teamA, teamB }); + } + } + rounds.push(pairings); + + const last = rotation.pop()!; + rotation.splice(1, 0, last); + } + + return rounds; +} + +/** Builds a global round-robin calendar from the supplied per-group team order. */ +export function generateRoundRobinSchedule( + teams: readonly TeamSummary[], + legs: 1 | 2, +): ScheduledGame[] { + if (legs !== 1 && legs !== 2) { + throw new RangeError('O número de voltas tem de ser 1 ou 2.'); + } + + const groupedTeams = new Map(); + for (const team of teams) { + const existing = groupedTeams.get(team.group.id); + if (existing) { + existing.teams.push(team); + } else { + groupedTeams.set(team.group.id, { group: team.group, teams: [team] }); + } + } + + const groups = [...groupedTeams.values()] + .sort((first, second) => first.group.sortOrder - second.group.sortOrder || first.group.id - second.group.id) + .map(({ group, teams: groupTeams }) => ({ group, rounds: generateGroupRounds(groupTeams) })); + const firstLegRoundCount = Math.max(0, ...groups.map(({ rounds }) => rounds.length)); + const firstLeg: ScheduledGame[] = []; + + for (let roundIndex = 0; roundIndex < firstLegRoundCount; roundIndex += 1) { + for (const { group, rounds } of groups) { + for (const pairing of rounds[roundIndex] ?? []) { + firstLeg.push({ + gameNumber: firstLeg.length + 1, + roundNumber: roundIndex + 1, + group, + ...pairing, + }); + } + } + } + + if (legs === 1) { + return firstLeg; + } + + return [ + ...firstLeg, + ...firstLeg.map((game, index) => ({ + gameNumber: firstLeg.length + index + 1, + roundNumber: game.roundNumber + firstLegRoundCount, + group: game.group, + teamA: game.teamB, + teamB: game.teamA, + })), + ]; +} diff --git a/apps/tournament/src/server/services/classification.test.ts b/apps/tournament/src/server/services/classification.test.ts new file mode 100644 index 0000000..6f54c35 --- /dev/null +++ b/apps/tournament/src/server/services/classification.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { Group, TeamSummary } from '../../shared/types/tournament.js'; +import { calculateClassification, type ClassificationMatch } from './classification.js'; + +const group: Group = { id: 1, name: 'Grupo A', sortOrder: 0 }; +const team = (id: number, number = id): TeamSummary => ({ id, number, name: `Equipa ${number}`, group }); +const teams = [team(1), team(2), team(3), team(4)]; +const match = (teamA: TeamSummary, teamB: TeamSummary, scoreA: number | null, scoreB: number | null): ClassificationMatch => ({ + teamA, + teamB, + scoreA, + scoreB, +}); + +describe('calculateClassification', () => { + it('mantém a classificação standard como modo predefinido', () => { + const matches = [ + match(teams[0], teams[1], 2, 0), + match(teams[2], teams[1], 4, 1), + ]; + + const standard = calculateClassification(teams.slice(0, 3), matches, 'standard'); + expect(calculateClassification(teams.slice(0, 3), matches)).toEqual(standard); + expect(standard.map((row) => ({ id: row.team.id, points: row.points }))).toEqual([ + { id: 3, points: 3 }, + { id: 1, points: 3 }, + { id: 2, points: 0 }, + ]); + }); + + it('calcula vitórias, empates, derrotas e ignora resultados incompletos', () => { + const table = calculateClassification(teams.slice(0, 3), [ + match(teams[0], teams[1], 3, 1), + match(teams[0], teams[2], 2, 2), + match(teams[1], teams[2], null, null), + ]); + expect(table[0]).toMatchObject({ team: teams[0], played: 2, wins: 1, draws: 1, losses: 0, goalsFor: 5, goalsAgainst: 3, goalDifference: 2, points: 4 }); + expect(table.find((row) => row.team.id === 2)).toMatchObject({ played: 1, losses: 1, points: 0 }); + expect(table.find((row) => row.team.id === 3)).toMatchObject({ played: 1, draws: 1, points: 1 }); + }); + + it('ordena por pontos, diferença de golos e golos marcados', () => { + const table = calculateClassification(teams.slice(0, 3), [ + match(teams[0], teams[1], 2, 0), + match(teams[2], teams[1], 4, 1), + ]); + expect(table.map((row) => row.team.id)).toEqual([3, 1, 2]); + }); + + it('usa confronto direto para equipas empatadas nos critérios principais', () => { + const table = calculateClassification(teams, [ + match(teams[0], teams[1], 1, 0), + match(teams[0], teams[2], 0, 1), + match(teams[1], teams[3], 1, 0), + ]); + const teamOnePosition = table.findIndex((row) => row.team.id === 1); + const teamTwoPosition = table.findIndex((row) => row.team.id === 2); + expect(teamOnePosition).toBeLessThan(teamTwoPosition); + }); + + it('usa o número da equipa como último critério determinístico', () => { + const reversed = [team(10, 9), team(11, 2)]; + expect(calculateClassification(reversed, []).map((row) => row.team.number)).toEqual([2, 9]); + }); + + describe('total-points', () => { + it('soma os resultados marcados por cada equipa nos pontos', () => { + const table = calculateClassification(teams.slice(0, 3), [ + match(teams[0], teams[1], 8, 5), + match(teams[0], teams[2], 3, 4), + match(teams[1], teams[2], 7, 6), + ], 'total-points'); + + expect(table.map((row) => ({ id: row.team.id, points: row.points }))).toEqual([ + { id: 2, points: 12 }, + { id: 1, points: 11 }, + { id: 3, points: 10 }, + ]); + expect(table.find((row) => row.team.id === 1)).toMatchObject({ + played: 2, + wins: 1, + draws: 0, + losses: 1, + goalsFor: 11, + goalsAgainst: 9, + goalDifference: 2, + }); + }); + + it('resolve totais empatados pelo confronto direto, não pela diferença global', () => { + const table = calculateClassification(teams, [ + match(teams[0], teams[1], 0, 1), + match(teams[0], teams[2], 5, 0), + match(teams[1], teams[3], 4, 10), + ], 'total-points'); + + expect(table.map((row) => row.team.id)).toEqual([4, 2, 1, 3]); + expect(table.find((row) => row.team.id === 1)).toMatchObject({ points: 5, goalDifference: 4 }); + expect(table.find((row) => row.team.id === 2)).toMatchObject({ points: 5, goalDifference: -5 }); + }); + + it('usa o número da equipa como fallback determinístico', () => { + const reversed = [team(10, 9), team(11, 2), team(12, 5)]; + + expect(calculateClassification(reversed, [], 'total-points').map((row) => row.team.number)) + .toEqual([2, 5, 9]); + }); + + it('ignora jogos incompletos sem acumular resultados ou estatísticas', () => { + const table = calculateClassification(teams.slice(0, 3), [ + match(teams[0], teams[1], 7, 3), + match(teams[0], teams[2], 9, null), + match(teams[1], teams[2], null, 8), + ], 'total-points'); + + expect(table.find((row) => row.team.id === 1)).toMatchObject({ played: 1, points: 7, goalsFor: 7 }); + expect(table.find((row) => row.team.id === 2)).toMatchObject({ played: 1, points: 3, goalsFor: 3 }); + expect(table.find((row) => row.team.id === 3)).toMatchObject({ played: 0, points: 0, goalsFor: 0 }); + }); + }); +}); diff --git a/apps/tournament/src/server/services/classification.ts b/apps/tournament/src/server/services/classification.ts new file mode 100644 index 0000000..1e98603 --- /dev/null +++ b/apps/tournament/src/server/services/classification.ts @@ -0,0 +1,185 @@ +import type { + ClassificationMode, + ClassificationRow, + LeagueMatch, + TeamSummary, +} from '../../shared/types/tournament.js'; + +export type ClassificationMatch = Pick; + +type Statistics = Omit; + +interface HeadToHeadStatistics { + points: number; + goalsFor: number; + goalsAgainst: number; +} + +function goalDifference(statistics: Pick): number { + return statistics.goalsFor - statistics.goalsAgainst; +} + +function comparePrimary(a: Statistics, b: Statistics, mode: ClassificationMode): number { + if (mode === 'total-points') { + return b.points - a.points; + } + + return ( + b.points - a.points || + goalDifference(b) - goalDifference(a) || + b.goalsFor - a.goalsFor + ); +} + +function haveSamePrimaryValues(a: Statistics, b: Statistics, mode: ClassificationMode): boolean { + if (mode === 'total-points') { + return a.points === b.points; + } + + return a.points === b.points && + goalDifference(a) === goalDifference(b) && + a.goalsFor === b.goalsFor; +} + +function calculateHeadToHead( + tiedTeams: readonly Statistics[], + matches: readonly ClassificationMatch[], +): Map { + const ids = new Set(tiedTeams.map((team) => team.team.id)); + const statistics = new Map( + tiedTeams.map((team) => [team.team.id, { points: 0, goalsFor: 0, goalsAgainst: 0 }]), + ); + + for (const match of matches) { + if (match.scoreA === null || match.scoreB === null || !ids.has(match.teamA.id) || !ids.has(match.teamB.id)) { + continue; + } + + const teamA = statistics.get(match.teamA.id)!; + const teamB = statistics.get(match.teamB.id)!; + teamA.goalsFor += match.scoreA; + teamA.goalsAgainst += match.scoreB; + teamB.goalsFor += match.scoreB; + teamB.goalsAgainst += match.scoreA; + if (match.scoreA > match.scoreB) { + teamA.points += 3; + } else if (match.scoreB > match.scoreA) { + teamB.points += 3; + } else { + teamA.points += 1; + teamB.points += 1; + } + } + + return statistics; +} + +/** + * Calculates the league table from completed matches. Standard classifications + * use result points; total-points classifications use the teams' scored totals. + */ +export function calculateClassification( + teams: readonly TeamSummary[], + matches: readonly ClassificationMatch[], + mode: ClassificationMode = 'standard', +): ClassificationRow[] { + const statisticsByTeamId = new Map( + teams.map((team) => [team.id, { + team, + played: 0, + wins: 0, + draws: 0, + losses: 0, + goalsFor: 0, + goalsAgainst: 0, + points: 0, + }]), + ); + + for (const match of matches) { + if (match.scoreA === null || match.scoreB === null) { + continue; + } + const teamA = statisticsByTeamId.get(match.teamA.id); + const teamB = statisticsByTeamId.get(match.teamB.id); + if (!teamA || !teamB) { + continue; + } + + teamA.played += 1; + teamB.played += 1; + teamA.goalsFor += match.scoreA; + teamA.goalsAgainst += match.scoreB; + teamB.goalsFor += match.scoreB; + teamB.goalsAgainst += match.scoreA; + if (mode === 'total-points') { + teamA.points += match.scoreA; + teamB.points += match.scoreB; + } + if (match.scoreA > match.scoreB) { + teamA.wins += 1; + teamB.losses += 1; + if (mode === 'standard') { + teamA.points += 3; + } + } else if (match.scoreB > match.scoreA) { + teamB.wins += 1; + teamA.losses += 1; + if (mode === 'standard') { + teamB.points += 3; + } + } else { + teamA.draws += 1; + teamB.draws += 1; + if (mode === 'standard') { + teamA.points += 1; + teamB.points += 1; + } + } + } + + const primaryOrdered = [...statisticsByTeamId.values()].sort((a, b) => comparePrimary(a, b, mode)); + const ordered: Statistics[] = []; + for (let start = 0; start < primaryOrdered.length;) { + let end = start + 1; + while ( + end < primaryOrdered.length && + haveSamePrimaryValues(primaryOrdered[start], primaryOrdered[end], mode) + ) { + end += 1; + } + + const tiedGroup = primaryOrdered.slice(start, end); + if (tiedGroup.length === 1) { + ordered.push(tiedGroup[0]); + } else { + const headToHead = calculateHeadToHead(tiedGroup, matches); + tiedGroup.sort((a, b) => { + const aHeadToHead = headToHead.get(a.team.id)!; + const bHeadToHead = headToHead.get(b.team.id)!; + return ( + bHeadToHead.points - aHeadToHead.points || + (bHeadToHead.goalsFor - bHeadToHead.goalsAgainst) - + (aHeadToHead.goalsFor - aHeadToHead.goalsAgainst) || + bHeadToHead.goalsFor - aHeadToHead.goalsFor || + a.team.number - b.team.number + ); + }); + ordered.push(...tiedGroup); + } + start = end; + } + + return ordered.map((statistics, index) => ({ + position: index + 1, + team: statistics.team, + played: statistics.played, + wins: statistics.wins, + draws: statistics.draws, + losses: statistics.losses, + goalsFor: statistics.goalsFor, + goalsAgainst: statistics.goalsAgainst, + goalDifference: goalDifference(statistics), + points: statistics.points, + })); +} diff --git a/apps/tournament/src/server/services/state.ts b/apps/tournament/src/server/services/state.ts new file mode 100644 index 0000000..5feda2c --- /dev/null +++ b/apps/tournament/src/server/services/state.ts @@ -0,0 +1,254 @@ +import { + isDisplayPanelType, + isDisplayZoomPercent, + type ClassificationMode, + type DisplaySettings, + type FinalStage, + type Group, + type GroupStanding, + type LeagueMatch, + type PublicTournamentState, + type TeamSummary, + type TournamentSettings, +} from '../../shared/types/tournament.js'; +import type { SqliteDatabase } from '../db/database.js'; +import { calculateBracket, type BracketResult, type BracketSeed } from './bracket.js'; +import { calculateClassification } from './classification.js'; + +interface SettingsRow { + name: string; + classification_mode: ClassificationMode; + final_round_count: number | null; + third_place_enabled: number; +} + +interface DisplaySettingsRow { + active_panel: string; + zoom_percent: number; +} + +interface TeamRow { + id: number; + number: number; + name: string; + group_id: number; + group_name: string; + group_sort_order: number; +} + +const TEAM_SUMMARY_SELECT = ` + SELECT + teams.id, + teams.number, + teams.name, + league_groups.id AS group_id, + league_groups.name AS group_name, + league_groups.sort_order AS group_sort_order + FROM teams + JOIN league_groups ON league_groups.id = teams.group_id +`; + +interface LeagueMatchRow { + id: number; + order_index: number; + round_index: number; + counts_toward_standings: number; + score_a: number | null; + score_b: number | null; + team_a_id: number; + team_a_number: number; + team_a_name: string; + team_a_group_id: number; + team_a_group_name: string; + team_a_group_sort_order: number; + team_b_id: number; + team_b_number: number; + team_b_name: string; + team_b_group_id: number; + team_b_group_name: string; + team_b_group_sort_order: number; +} + +function teamFromRow(row: TeamRow): TeamSummary { + return { + id: row.id, + number: row.number, + name: row.name, + group: { id: row.group_id, name: row.group_name, sortOrder: row.group_sort_order }, + }; +} + +export function getSettings(database: SqliteDatabase): TournamentSettings { + const row = database.prepare( + 'SELECT name, classification_mode, final_round_count, third_place_enabled FROM tournament_settings WHERE id = 1', + ).get() as SettingsRow | undefined; + if (!row) { + throw new Error('As definições do torneio não foram inicializadas.'); + } + return { + name: row.name, + classificationMode: row.classification_mode, + finalRoundCount: row.final_round_count, + thirdPlaceEnabled: Boolean(row.third_place_enabled), + }; +} + +export function getDisplaySettings(database: SqliteDatabase): DisplaySettings { + const row = database.prepare( + 'SELECT active_panel, zoom_percent FROM display_settings WHERE id = 1', + ).get() as DisplaySettingsRow | undefined; + if (!row) { + throw new Error('As definições do ecrã não foram inicializadas.'); + } + return { + activePanel: isDisplayPanelType(row.active_panel) ? row.active_panel : 'latest-results', + zoomPercent: isDisplayZoomPercent(row.zoom_percent) ? row.zoom_percent : 100, + }; +} + +export function getTeamSummaries(database: SqliteDatabase): TeamSummary[] { + const rows = database.prepare(`${TEAM_SUMMARY_SELECT} ORDER BY teams.number ASC`).all() as TeamRow[]; + return rows.map(teamFromRow); +} + +export function getTeamSummary(database: SqliteDatabase, teamId: number): TeamSummary | null { + const row = database.prepare(`${TEAM_SUMMARY_SELECT} WHERE teams.id = ?`).get(teamId) as TeamRow | undefined; + return row ? teamFromRow(row) : null; +} + +export function getGroups(database: SqliteDatabase): Group[] { + return database.prepare(` + SELECT id, name, sort_order AS sortOrder + FROM league_groups + ORDER BY sort_order ASC, id ASC + `).all() as Group[]; +} + +export function getLeagueMatches(database: SqliteDatabase): LeagueMatch[] { + const rows = database.prepare(` + SELECT + league_matches.id, + league_matches.order_index, + league_matches.round_index, + league_rounds.counts_toward_standings, + league_matches.score_a, + league_matches.score_b, + team_a.id AS team_a_id, + team_a.number AS team_a_number, + team_a.name AS team_a_name, + team_a_group.id AS team_a_group_id, + team_a_group.name AS team_a_group_name, + team_a_group.sort_order AS team_a_group_sort_order, + team_b.id AS team_b_id, + team_b.number AS team_b_number, + team_b.name AS team_b_name, + team_b_group.id AS team_b_group_id, + team_b_group.name AS team_b_group_name, + team_b_group.sort_order AS team_b_group_sort_order + FROM league_matches + JOIN league_rounds ON league_rounds.round_index = league_matches.round_index + JOIN teams AS team_a ON team_a.id = league_matches.team_a_id + JOIN league_groups AS team_a_group ON team_a_group.id = team_a.group_id + JOIN teams AS team_b ON team_b.id = league_matches.team_b_id + JOIN league_groups AS team_b_group ON team_b_group.id = team_b.group_id + ORDER BY league_matches.order_index ASC + `).all() as LeagueMatchRow[]; + + return rows.map((row) => { + const teamA = { + id: row.team_a_id, + number: row.team_a_number, + name: row.team_a_name, + group: { + id: row.team_a_group_id, + name: row.team_a_group_name, + sortOrder: row.team_a_group_sort_order, + }, + }; + const teamB = { + id: row.team_b_id, + number: row.team_b_number, + name: row.team_b_name, + group: { + id: row.team_b_group_id, + name: row.team_b_group_name, + sortOrder: row.team_b_group_sort_order, + }, + }; + return { + id: row.id, + gameNumber: row.order_index, + roundNumber: row.round_index, + countsTowardStandings: Boolean(row.counts_toward_standings), + group: teamA.group.id === teamB.group.id ? teamA.group : null, + teamA, + teamB, + scoreA: row.score_a, + scoreB: row.score_b, + }; + }); +} + +export function getFinalStage( + database: SqliteDatabase, + roundCount?: number | null, + thirdPlaceEnabled?: boolean, +): FinalStage { + if (roundCount === undefined || thirdPlaceEnabled === undefined) { + const settings = getSettings(database); + if (roundCount === undefined) roundCount = settings.finalRoundCount; + if (thirdPlaceEnabled === undefined) thirdPlaceEnabled = settings.thirdPlaceEnabled; + } + + const teams = new Map(getTeamSummaries(database).map((team) => [team.id, team])); + const seedRows = database.prepare('SELECT slot_index, team_id FROM final_seeds ORDER BY slot_index ASC').all() as Array<{ + slot_index: number; + team_id: number | null; + }>; + const seeds: BracketSeed[] = seedRows.map((row) => ({ + slotIndex: row.slot_index, + team: row.team_id === null ? null : teams.get(row.team_id) ?? null, + })); + const resultRows = database.prepare(` + SELECT round_index, match_index, score_a, score_b + FROM final_match_results + ORDER BY round_index ASC, match_index ASC + `).all() as Array<{ + round_index: number; + match_index: number; + score_a: number | null; + score_b: number | null; + }>; + const results: BracketResult[] = resultRows.map((row) => ({ + roundIndex: row.round_index, + matchIndex: row.match_index, + scoreA: row.score_a, + scoreB: row.score_b, + })); + return calculateBracket(roundCount, seeds, results, thirdPlaceEnabled); +} + +export function getPublicState(database: SqliteDatabase): PublicTournamentState { + const tournament = getSettings(database); + const groups = getGroups(database); + const teams = getTeamSummaries(database); + const matches = getLeagueMatches(database); + const groupStandings: GroupStanding[] = groups.map((group) => { + const groupTeams = teams.filter((team) => team.group.id === group.id); + const groupMatches = matches.filter( + (match) => match.countsTowardStandings && match.teamA.group.id === group.id && match.teamB.group.id === group.id, + ); + return { + group, + rows: calculateClassification(groupTeams, groupMatches, tournament.classificationMode), + }; + }); + return { + tournament, + display: getDisplaySettings(database), + groups, + matches, + groupStandings, + finalStage: getFinalStage(database, tournament.finalRoundCount, tournament.thirdPlaceEnabled), + }; +} diff --git a/apps/tournament/src/shared/finalSeeding.test.ts b/apps/tournament/src/shared/finalSeeding.test.ts new file mode 100644 index 0000000..106dc39 --- /dev/null +++ b/apps/tournament/src/shared/finalSeeding.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { GroupStanding, TeamSummary } from './types/tournament'; +import { buildGroupQualificationPreview, buildOverallQualificationPreview, standardBracketSeedOrder } from './finalSeeding'; + +function team(id: number, number: number, name: string, groupId: number, groupName: string): TeamSummary { + return { id, number, name, group: { id: groupId, name: groupName, sortOrder: groupId - 1 } }; +} + +function standing(groupId: number, groupName: string, teams: TeamSummary[]): GroupStanding { + return { + group: { id: groupId, name: groupName, sortOrder: groupId - 1 }, + rows: teams.map((entry, index) => ({ + position: index + 1, + team: entry, + played: 1, + wins: index === 0 ? 1 : 0, + draws: 0, + losses: index === 0 ? 0 : 1, + goalsFor: index === 0 ? 3 : 1, + goalsAgainst: index === 0 ? 1 : 3, + goalDifference: index === 0 ? 2 : -2, + points: index === 0 ? 3 : 0, + })), + }; +} + +describe('final seeding', () => { + it('uses standard bracket seed placement', () => { + expect(standardBracketSeedOrder(2)).toEqual([1, 2]); + expect(standardBracketSeedOrder(4)).toEqual([1, 4, 2, 3]); + expect(standardBracketSeedOrder(8)).toEqual([1, 8, 4, 5, 2, 7, 3, 6]); + }); + + it('crosses the top two teams from two groups in the semi-finals', () => { + const a1 = team(1, 1, 'A1', 1, 'Grupo A'); + const a2 = team(2, 2, 'A2', 1, 'Grupo A'); + const b1 = team(3, 3, 'B1', 2, 'Grupo B'); + const b2 = team(4, 4, 'B2', 2, 'Grupo B'); + + const preview = buildGroupQualificationPreview([ + standing(1, 'Grupo A', [a1, a2]), + standing(2, 'Grupo B', [b1, b2]), + ], 2, 4); + + expect(preview).toMatchObject({ mode: 'per-group', qualifiersPerGroup: 2, qualifierCount: 4 }); + expect(preview.qualifiers.map((qualifier) => qualifier.team.id)).toEqual([a1.id, b1.id, a2.id, b2.id]); + expect(preview.seeds).toEqual([ + { slotIndex: 1, teamId: a1.id }, + { slotIndex: 2, teamId: b2.id }, + { slotIndex: 3, teamId: b1.id }, + { slotIndex: 4, teamId: a2.id }, + ]); + }); + + it('leaves lower seeds empty when the bracket has extra slots', () => { + const a1 = team(1, 1, 'A1', 1, 'Grupo A'); + const b1 = team(2, 2, 'B1', 2, 'Grupo B'); + + const preview = buildGroupQualificationPreview([ + standing(1, 'Grupo A', [a1]), + standing(2, 'Grupo B', [b1]), + ], 1, 4); + + expect(preview.seeds).toEqual([ + { slotIndex: 1, teamId: a1.id }, + { slotIndex: 2, teamId: null }, + { slotIndex: 3, teamId: b1.id }, + { slotIndex: 4, teamId: null }, + ]); + }); + + it('can select the best teams across all groups', () => { + const a1 = team(1, 1, 'A1', 1, 'Grupo A'); + const a2 = team(2, 2, 'A2', 1, 'Grupo A'); + const b1 = team(3, 3, 'B1', 2, 'Grupo B'); + const b2 = team(4, 4, 'B2', 2, 'Grupo B'); + const groupA = standing(1, 'Grupo A', [a1, a2]); + const groupB = standing(2, 'Grupo B', [b1, b2]); + groupB.rows[0].points = 6; + groupB.rows[0].goalDifference = 4; + + const preview = buildOverallQualificationPreview([groupA, groupB], 2, 4, 'standard'); + + expect(preview).toMatchObject({ mode: 'overall', qualifiersPerGroup: null, qualifierCount: 2 }); + expect(preview.qualifiers.map((qualifier) => qualifier.team.id)).toEqual([b1.id, a1.id]); + expect(preview.seeds).toEqual([ + { slotIndex: 1, teamId: b1.id }, + { slotIndex: 2, teamId: null }, + { slotIndex: 3, teamId: a1.id }, + { slotIndex: 4, teamId: null }, + ]); + }); + +}); diff --git a/apps/tournament/src/shared/finalSeeding.ts b/apps/tournament/src/shared/finalSeeding.ts new file mode 100644 index 0000000..6e2473c --- /dev/null +++ b/apps/tournament/src/shared/finalSeeding.ts @@ -0,0 +1,134 @@ +import type { + AutoQualifiedTeam, + ClassificationMode, + ClassificationRow, + FinalSeedAssignment, + FinalSeedPreview, + GroupStanding, +} from './types/tournament.js'; + +function isPowerOfTwo(value: number): boolean { + return value > 0 && (value & (value - 1)) === 0; +} + +/** + * Returns the seed number occupying each bracket slot. For four slots the + * order is [1, 4, 2, 3], producing the usual 1-v-4 and 2-v-3 semi-finals. + */ +export function standardBracketSeedOrder(slotCount: number): number[] { + if (!Number.isInteger(slotCount) || !isPowerOfTwo(slotCount)) { + throw new RangeError('O número de lugares da fase final tem de ser uma potência de dois.'); + } + + if (slotCount === 1) return [1]; + + let order = [1, 2]; + for (let size = 4; size <= slotCount; size *= 2) { + order = order.flatMap((seed) => [seed, size + 1 - seed]); + } + return order; +} + +function buildPreview( + qualifiers: AutoQualifiedTeam[], + slotCount: number, + mode: FinalSeedPreview['mode'], + qualifiersPerGroup: number | null, +): FinalSeedPreview { + if (qualifiers.length > slotCount) { + throw new RangeError('Existem mais equipas apuradas do que lugares na fase final.'); + } + + const qualifierBySeed = new Map(qualifiers.map((qualifier) => [qualifier.seedNumber, qualifier])); + const seeds: FinalSeedAssignment[] = standardBracketSeedOrder(slotCount).map((seedNumber, index) => ({ + slotIndex: index + 1, + teamId: qualifierBySeed.get(seedNumber)?.team.id ?? null, + })); + + return { + mode, + qualifiersPerGroup, + qualifierCount: qualifiers.length, + qualifiers, + seeds, + }; +} + +/** + * Builds a preview that takes the top N teams from every non-empty group. + * Teams are ranked by group position, then group order, and placed using + * standard bracket seeding. This naturally crosses groups in a two-group + * bracket (1st A v 2nd B, 1st B v 2nd A). + */ +export function buildGroupQualificationPreview( + groupStandings: readonly GroupStanding[], + qualifiersPerGroup: number, + slotCount: number, +): FinalSeedPreview { + if (!Number.isInteger(qualifiersPerGroup) || qualifiersPerGroup < 1) { + throw new RangeError('O número de apurados por grupo tem de ser positivo.'); + } + + const standings = [...groupStandings] + .filter((standing) => standing.rows.length > 0) + .sort((first, second) => first.group.sortOrder - second.group.sortOrder || first.group.id - second.group.id); + + const qualifiers: AutoQualifiedTeam[] = []; + for (let groupPosition = 1; groupPosition <= qualifiersPerGroup; groupPosition += 1) { + for (const standing of standings) { + const row = standing.rows[groupPosition - 1]; + if (row) { + qualifiers.push({ + seedNumber: qualifiers.length + 1, + groupPosition, + team: row.team, + }); + } + } + } + + return buildPreview(qualifiers, slotCount, 'per-group', qualifiersPerGroup); +} + +function compareAcrossGroups(first: ClassificationRow, second: ClassificationRow, mode: ClassificationMode): number { + if (mode === 'total-points') { + return second.points - first.points || first.team.number - second.team.number; + } + + return second.points - first.points || + second.goalDifference - first.goalDifference || + second.goalsFor - first.goalsFor || + first.team.number - second.team.number; +} + +/** + * Selects the best N teams across all groups. Standard mode compares points, + * goal difference and goals scored; total-points mode compares points only. + * Team number provides a deterministic cross-group fallback. + */ +export function buildOverallQualificationPreview( + groupStandings: readonly GroupStanding[], + qualifierCount: number, + slotCount: number, + classificationMode: ClassificationMode, +): FinalSeedPreview { + if (!Number.isInteger(qualifierCount) || qualifierCount < 1) { + throw new RangeError('O número total de apurados tem de ser positivo.'); + } + + const rankedRows = groupStandings + .flatMap((standing) => standing.rows) + .sort((first, second) => compareAcrossGroups(first, second, classificationMode)); + + if (qualifierCount > rankedRows.length) { + throw new RangeError('Não existem equipas suficientes para esse número de apurados.'); + } + + const qualifiers = rankedRows.slice(0, qualifierCount).map((row, index) => ({ + seedNumber: index + 1, + groupPosition: row.position, + team: row.team, + })); + + return buildPreview(qualifiers, slotCount, 'overall', null); +} diff --git a/apps/tournament/src/shared/types/tournament.ts b/apps/tournament/src/shared/types/tournament.ts new file mode 100644 index 0000000..65e4845 --- /dev/null +++ b/apps/tournament/src/shared/types/tournament.ts @@ -0,0 +1,173 @@ +export interface Group { + id: number; + name: string; + sortOrder: number; +} + +export interface TeamSummary { + id: number; + number: number; + name: string; + group: Group; +} + +export interface Player { + id: number; + teamId: number; + name: string; + sortOrder: number; +} + +export interface Team extends TeamSummary { + players: Player[]; +} + +export const CLASSIFICATION_MODES = ['standard', 'total-points'] as const; +export type ClassificationMode = typeof CLASSIFICATION_MODES[number]; + +export const DISPLAY_PANEL_TYPES = ['next-match', 'latest-results', 'classification'] as const; +export type DisplayPanelType = typeof DISPLAY_PANEL_TYPES[number]; + +export const DISPLAY_ZOOM_MIN_PERCENT = 50; +export const DISPLAY_ZOOM_MAX_PERCENT = 400; +export type DisplayZoomPercent = number; + +export function isDisplayPanelType(value: string): value is DisplayPanelType { + return (DISPLAY_PANEL_TYPES as readonly string[]).includes(value); +} + +export function isDisplayZoomPercent(value: number): value is DisplayZoomPercent { + return Number.isInteger(value) && value >= DISPLAY_ZOOM_MIN_PERCENT && value <= DISPLAY_ZOOM_MAX_PERCENT; +} + +export interface DisplaySettings { + activePanel: DisplayPanelType; + zoomPercent: DisplayZoomPercent; +} + +export interface TournamentSettings { + name: string; + classificationMode: ClassificationMode; + finalRoundCount: number | null; + thirdPlaceEnabled: boolean; +} + +export interface LeagueMatch { + id: number; + gameNumber: number; + roundNumber: number; + /** Whether this round's results contribute to the group standings. */ + countsTowardStandings: boolean; + /** The common group, or null for a match between groups. */ + group: Group | null; + teamA: TeamSummary; + teamB: TeamSummary; + scoreA: number | null; + scoreB: number | null; +} + +export type CalendarLegs = 1 | 2; + +export interface CalendarGeneration { + legs: CalendarLegs; + teamOrder: Array<{ teamId: number; groupId: number }>; +} + +export interface CalendarPreviewMatch { + gameNumber: number; + roundNumber: number; + group: Group; + teamA: TeamSummary; + teamB: TeamSummary; +} + +export interface CalendarResponse { + matches: LeagueMatch[]; + matchCount: number; + roundCount: number; +} + +export interface CalendarGenerationPreview { + generation: CalendarGeneration; + matches: CalendarPreviewMatch[]; + matchCount: number; + roundCount: number; +} + +export interface ClassificationRow { + position: number; + team: TeamSummary; + played: number; + wins: number; + draws: number; + losses: number; + goalsFor: number; + goalsAgainst: number; + goalDifference: number; + points: number; +} + +export interface GroupStanding { + group: Group; + rows: ClassificationRow[]; +} + +export interface FinalSeedAssignment { + slotIndex: number; + teamId: number | null; +} + +export const FINAL_QUALIFICATION_MODES = ['per-group', 'overall'] as const; +export type FinalQualificationMode = typeof FINAL_QUALIFICATION_MODES[number]; + +export interface AutoQualifiedTeam { + seedNumber: number; + groupPosition: number; + team: TeamSummary; +} + +export interface FinalSeedPreview { + mode: FinalQualificationMode; + qualifiersPerGroup: number | null; + qualifierCount: number; + qualifiers: AutoQualifiedTeam[]; + seeds: FinalSeedAssignment[]; +} + +export interface BracketParticipant { + team: TeamSummary | null; + pending: boolean; +} + +export interface BracketMatch { + matchIndex: number; + teamA: BracketParticipant; + teamB: BracketParticipant; + scoreA: number | null; + scoreB: number | null; + winner: TeamSummary | null; + isBye: boolean; +} + +export interface BracketRound { + roundIndex: number; + name: string; + matches: BracketMatch[]; +} + +export interface FinalStage { + roundCount: number | null; + thirdPlaceEnabled: boolean; + rounds: BracketRound[]; + thirdPlaceMatch: BracketMatch | null; + champion: TeamSummary | null; +} + +export interface PublicTournamentState { + tournament: TournamentSettings; + display: DisplaySettings; + groups: Group[]; + matches: LeagueMatch[]; + groupStandings: GroupStanding[]; + finalStage: FinalStage; +} diff --git a/apps/tournament/src/vite-env.d.ts b/apps/tournament/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/tournament/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/tournament/tsconfig.client.json b/apps/tournament/tsconfig.client.json new file mode 100644 index 0000000..7b309ad --- /dev/null +++ b/apps/tournament/tsconfig.client.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/client/**/*.ts", "src/client/**/*.vue", "src/vite-env.d.ts"] +} diff --git a/apps/tournament/tsconfig.server.json b/apps/tournament/tsconfig.server.json new file mode 100644 index 0000000..287afd3 --- /dev/null +++ b/apps/tournament/tsconfig.server.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/server/**/*.ts", "src/shared/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/apps/tournament/vite.config.ts b/apps/tournament/vite.config.ts new file mode 100644 index 0000000..f59535a --- /dev/null +++ b/apps/tournament/vite.config.ts @@ -0,0 +1,44 @@ +import { fileURLToPath, URL } from 'node:url'; +import { defineConfig, loadEnv } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +const brandRoot = fileURLToPath(new URL('../../packages/crr-brand', import.meta.url)); + +function deploymentBase(base: string | undefined): string { + const value = base ?? '/'; + if (!value.startsWith('/')) { + throw new Error('VITE_BASE_PATH tem de começar por "/".'); + } + return value.endsWith('/') ? value : `${value}/`; +} + +export default defineConfig(({ mode }) => { + const base = deploymentBase(loadEnv(mode, process.cwd(), '').VITE_BASE_PATH); + + return { + base, + publicDir: `${brandRoot}/assets`, + plugins: [vue()], + server: { + fs: { + allow: [fileURLToPath(new URL('.', import.meta.url)), brandRoot], + }, + proxy: { + [`${base}api`]: { + target: 'http://localhost:8080', + rewrite: (path) => path.replace(`${base}api`, '/api'), + }, + }, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src/client', import.meta.url)), + '@crr-brand': brandRoot, + }, + }, + build: { + outDir: 'dist/client', + emptyOutDir: false, + }, + }; +}); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..62c35a1 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,37 @@ +services: + tournament: + build: + context: . + dockerfile: apps/tournament/Dockerfile + args: + VITE_BASE_PATH: ${TOURNAMENT_BASE_PATH:-/jogo/} + image: crr-tournament + ports: + - "8080:8080" + env_file: + - path: apps/tournament/.env + required: false + environment: + APP_BASE_PATH: ${TOURNAMENT_BASE_PATH:-/jogo/} + DATABASE_PATH: /data/tournament.sqlite + volumes: + - tournament-data:/data + + shifts: + build: + context: . + dockerfile: apps/shifts/Dockerfile + image: crr-shifts + ports: + - "8000:8000" + env_file: + - path: apps/shifts/backend/.env + required: false + environment: + DATABASE_URL: sqlite:////data/bar_rota.db + volumes: + - shifts-data:/data + +volumes: + tournament-data: + shifts-data: diff --git a/migration/README.md b/migration/README.md new file mode 100644 index 0000000..6021016 --- /dev/null +++ b/migration/README.md @@ -0,0 +1,177 @@ +# Migration progress + +Verified on 2026-10-07. Follow `MONOREPO_MIGRATION_PLAN.md`; its historical source +labels resolve to `originals/tournment-manager-web/` and `originals/crr-shifts/`. + +## Phase 0 — behaviour baseline: complete + +Both untouched source snapshots were copied into disposable `.migration/` +directories before dependency installation, tests and builds. SHA-256 manifests +confirm that source files match the snapshots after the checks and app copies. +No commands installed dependencies or generated outputs inside `originals/`. + +| Baseline check | Result | +| --- | --- | +| Tournament `npm test` | 55 passed, 8 files | +| Tournament `npm run typecheck` | Passed | +| Tournament `npm run build` | Vue and server builds passed | +| Shifts pytest, Python 3.13.13 | 14 passed | +| Tournament SQLite integrity / foreign keys | Passed | +| Desktop/mobile screenshots | 14 captured, including print cards | + +Tournament was checked locally using Node 26.0.0/npm 11.12.1, satisfying the +legacy `>=22` engine constraint. Node 22 remains the CI/container target; its CI +checks have not run yet. The legacy package has no `lint` script. Shifts' legacy +requirements resolve the same versions as the new root lockfile's runtime/test +dependencies. Existing upstream/runtime deprecation warnings remain visible. + +### Preserved evidence + +- `baseline/tournament.json` and `baseline/shifts.json`: source checksums, + executed commands, exit codes and individual passing test names. +- `baseline/artifacts.json`: fixture and screenshot checksums. +- `fixtures/tournament.sqlite`: database created by the actual legacy Node + application, containing all nine existing tables and no Alembic stamp. +- `fixtures/tournament-schema.json` and `tournament-rows.json`: exact legacy + schema/index definitions and persisted contents for adoption checks. +- `fixtures/tournament-state.json`: expected public state, including standings, + final-stage results, champion and third-place match. +- `fixtures/tournament-api.json`: observable requests/statuses/bodies for a + synthetic workflow, authentication failures, duplicate-number conflict and 404. +- `fixtures/tournament-routes.json`: explicit API method/path inventory. This is + an inventory, not a substitute for the legacy integration suite. +- `fixtures/tournament-cookie.json`: cookie attributes with its token removed. +- `screenshots/`: desktop (1440×1000), mobile (390×844), login/results/admin pages + and Tournament match cards in print media, with a capture manifest. + +Fixtures contain synthetic Portuguese names and public test-only credentials; +they contain no production data. Timestamps in the final Tournament database +are fixed; the browser clock is fixed for screenshots. Shifts demo schedules +use the server's capture month, October 2026. Raw execution logs and test-runner +reports remain available locally under ignored `migration/results/`. + +### Reproduce + +```sh +make baseline-test +make baseline-fixtures +make baseline-screenshots +make baseline-preserve +``` + +These commands require Node 22+, Python 3.13, uv, and Playwright's Chromium +download for screenshot capture. `baseline-test` replaces disposable staging +copies. Fixture/screenshot capture intentionally refreshes migration artefacts. +It starts isolated servers on localhost ports 18080 and 18081 and shuts them +down afterwards. Run screenshot commands serially. Repeated captures in a +different month/browser version may need a reviewed visual baseline update. + +This machine's `uv` is installed through the Python 3.14.6 pyenv environment, +while `.python-version` selects 3.13. Commands here used +`PYENV_VERSION=3.14.6 make shifts-test` (and that environment for baseline tooling) +to resolve the uv executable; uv still used Python 3.13.13 for Shifts. Other +machines with uv available independently do not need that override. + +## Phase 1 — monorepo paths: local exit verified; remote CI pending + +- Tournament source/lockfile copied into `apps/tournament/`, retaining its + working `src/{client,server,shared}` layout during this phase. +- Shifts app/templates/static files, tests and unchanged migration history copied + into `apps/shifts/backend/`; domain/architecture reference docs copied too. +- Root uv workspace, Python 3.13 constraint, single lockfile and independent + Shifts project metadata added. Tournament enters the workspace in Phase 3. +- Root namespaced Makefile commands, Python configuration, app instructions and + development documentation added. +- Independent root-context Dockerfiles and Compose services/volumes added. + Optional per-app environment files let either service be configured separately. +- Path-filtered CI tests/builds only affected apps; shared branding/Python/root + tooling changes trigger both as applicable. No production or CI job runs from + `originals/`. + +| Migrated check | Result | +| --- | --- | +| `make tournament-test` | 55 tests and both typechecks passed | +| `make tournament-build` | Vue and server builds passed | +| `make shifts-test` | 14 passed with locked Python 3.13 dependencies | +| Live app rendering from `apps/` | Both applications served successfully | +| `make migration-visual-check` | All 14 captures byte-for-byte match legacy | +| `docker compose config --quiet` | Passed; both independent services listed | +| Docker image builds | Both passed: Tournament Node 22, Shifts Python 3.13 | +| `make images-smoke-test` | Both containers started; JSON/HTML routes passed | +| GitHub Actions | Workflow added; remote execution not verified | + +`tools/migrate_app_sources.py` was used for the one-time source copy. It refuses +existing destinations rather than overwriting application work. Do not rerun it +against the populated apps. Requirements files stay in the migrated Shifts copy +until the lockfile workflow passes CI, as required by the plan. + +Docker was initially stopped; Docker Desktop was started and both independent +image builds then passed. The smoke check used temporary containers, random +localhost ports, synthetic credentials and tmpfs databases; containers were +removed after checking Tournament `/api/public/state` and `/results`, and Shifts +`/health` and `/login`. No production database or persistent volume was used. + +Repeat image checks after building both images: + +```sh +docker build -f apps/tournament/Dockerfile -t crr-tournament . +make shifts-build +make images-smoke-test +``` + +## Phase 2 — shared CRR brand: locally verified complete + +- Created `packages/crr-brand` with one canonical Tournament logo (2324×2051) + and favicon, copied byte-for-byte from the baseline. Shared CSS defines the + red `#ed1c24` palette, neutral/control/error colors, font stack, radii, shadows + and spacing tokens, plus common base, button, panel and error-alert rules. +- Both apps load tokens/base/components before app styles. Existing variable + names now alias shared tokens. Responsive layouts, typography hierarchy, + control padding, specialist badges and print-card dimensions stay app-local. +- Tournament imports the package through Vite's `@crr-brand` alias and public + asset directory; shared files also work in Vite development under `/jogo/`. +- Shifts mounts `/brand`, generates prefixed asset URLs through Jinja `url_for`, + and uses the canonical logo in PDF exports. Its login logo retains the old + display aspect ratio to avoid a subpixel form-layout shift from the source + image's slightly different dimensions. +- Removed the two app-local logo files and Tournament's local favicon after + successful visual verification. Original snapshots remain unchanged. +- Both root-context images consume the package; the existing brand path filter + triggers both application CI jobs. + +| Check | Result | +| --- | --- | +| Tournament tests/typechecks | 55 passed; both typechecks passed | +| Tournament Vue/server build | Passed | +| Shifts tests, including PDF exports and brand resources | 16 passed | +| Shifts brand assets under `/` and `/crr` | CSS, logo and favicon passed | +| Tournament Vite development under `/jogo/` | Page, favicon, entrypoint, shared CSS and logo passed | +| `make branding-visual-check` | All 14 captures preserve measured legacy element rectangles exactly | +| Tournament print cards | Both desktop/mobile captures match Phase 0 byte-for-byte | +| `make migration-visual-check` | All 14 captures match the new Phase 2 reference byte-for-byte | +| Both Docker builds | Passed | +| `make images-smoke-test` | Both containers serve HTML/JSON and referenced CSS/JS/PNG resources | + +Visual review confirmed the intended palette/control-focus/panel-corner changes +and sharper canonical Shifts logo, with no layout regressions in the captured +desktop/mobile pages. `phase2/screenshots/` preserves the 14 updated PNGs, +capture manifest and measured rectangles. `phase2/verification.json` records +the geometry/print results, element counts and screenshot checksums. Phase 0 +screenshots and compatibility fixtures are retained unchanged. + +`make branding-visual-check` compares geometry against new captures of the +isolated legacy copies, allowing the deliberate color/asset changes. Future +`make migration-visual-check` runs use `phase2/screenshots/` as their exact +reference. Both require the Phase 0 staging/Playwright setup described above. +Container smoke checks emulate the existing Tournament proxy's `/jogo` prefix +stripping when fetching its built asset URLs. Remote CI remains unverified. + +## Next work + +1. Verify the new CI jobs on Node 22/Python 3.13 after publishing the changes. +2. Phase 3: Tournament SQLAlchemy/Alembic adoption of the saved SQLite fixture, + then deterministic services, state, auth, routers and SSE compatibility. + +Phases 3–6 are not complete. No shared Python package has been extracted yet. +Independent tag-triggered release workflows and final dependency cleanup belong +to later phases. diff --git a/migration/baseline/artifacts.json b/migration/baseline/artifacts.json new file mode 100644 index 0000000..d78d945 --- /dev/null +++ b/migration/baseline/artifacts.json @@ -0,0 +1,28 @@ +{ + "fixtures": { + "tournament-api.json": "cb6f1745a5c4158359326080d92958af08163d491476cadbee4af567bb404fc8", + "tournament-cookie.json": "7f2797f7330acc4fae4dda40fc6c9d1ec500c0616d8777361b25a9439cc40afe", + "tournament-routes.json": "79b605e2cb3e3845d0e1ec43a48d163b649e878d3c2089730f18c4b1291b94e7", + "tournament-rows.json": "139a91d291571eeff49ffc44f1f793d5a098e6728bc3fbc4a9097662fc1ed574", + "tournament-schema.json": "f870e415dffa31ac0b5d85990a06def5342ebd983d88c3f8da116bfc4796adb5", + "tournament-state.json": "fe4161eea7859c93535b1ec4364eed1dce8fcb57c77a2c675f6675c31333ba11", + "tournament.sqlite": "29a934415b8e158b2d40899d0e060d0bd63fc252edf8692ab7da8da7357f7d3a" + }, + "screenshots": { + "manifest.json": "2c8389c13cdd7576bf79a5b4414ba651b326220600048b1477b7ce920536f90f", + "shifts-dashboard-desktop.png": "3d3509d51141cb8b193db23cbc1ed49f16d900f7e842a72519f13568a957d2a8", + "shifts-dashboard-mobile.png": "469635ca4237cdb927d837bee4efdfb17ad4c7729d5be636d22bfa0c9260c128", + "shifts-login-desktop.png": "0820a46898d63623228d4ff968a9cb1d2e711511b56938aae010f6262fb98173", + "shifts-login-mobile.png": "0b3c5594b3a6c4b135f8f9c083145ceb0d0847cbd1a643bcc50767faac1b3bc2", + "shifts-teams-desktop.png": "f32362be5e3081704450d59baa9eb538419b0a0e3187ae11ef3aa029d8ba45e2", + "shifts-teams-mobile.png": "9b182e57540fa520b090a749e878a7c60f2728a9af014860c90cea9f77886c9f", + "tournament-login-desktop.png": "806d30b3bf6f7d446eb093f4995f3ee524b531d40a9a86ed2db23539c7fafdf0", + "tournament-login-mobile.png": "760efafad09046bd4e64d012fdd79c8b78b6a51f75bda9c2602899a09e2bc934", + "tournament-match-cards-print-desktop.png": "763d9ddbad413d1798e0192e69394a3065ada912498eb2d60de20945b0db9dc1", + "tournament-match-cards-print-mobile.png": "c3d6b94e73cab499516a02b07b2f47046e86a49561679607842c324708ad5ec3", + "tournament-results-desktop.png": "7167ea009925c416cdc07f3fa876c405fb1b85484b61703471284a6220bc69f0", + "tournament-results-mobile.png": "832961595e2d5442fdcde57763e4f817d40e8d06cddf7191d4e541a31fac0243", + "tournament-teams-desktop.png": "e028f3028d02f2d21903a79b9eb17a95c0845fe24ecba545466f42f385d92759", + "tournament-teams-mobile.png": "c92aa6509a3afeb159d87920947a52624ada6d7d83d19286956245db51adc4a4" + } +} diff --git a/migration/baseline/shifts.json b/migration/baseline/shifts.json new file mode 100644 index 0000000..7bdd169 --- /dev/null +++ b/migration/baseline/shifts.json @@ -0,0 +1,152 @@ +{ + "source": "originals/crr-shifts", + "sha256": { + ".dockerignore": "183044744db3de421e173d489d40a312f970864458bcca995bae1d0c323b7ffd", + ".env.example": "fbc8f9a4eebacec897eb400fbbd8889eaaa33328d5b56c2baca1b1e03e36f71a", + ".gitignore": "8864d9afc989075fbd233b0f125f850f4bae2bec11f83c9c15fc3b1f9b633011", + "AGENTS.md": "43f79221f136da3bdf2c3b19f23225e8573badc3fac9e9d7780c85a47c91ad05", + "Dockerfile": "b7cf12e3c31498090ce8b2e6045bf621b61bb1ba3e2af260a90b9d4604e90553", + "Makefile": "a2087bff8d1f563a58216b5618f98ae73963425ca23bf885bf3aba4660f26999", + "README.md": "42f05d8b861c0b9a2530f617776d56c0165cc4310ffe2d6afc53e2903c289810", + "alembic.ini": "66d160352b292dcb9dceac1a04d30893780109cfca3f49166e227b7ab7daf3dc", + "app/__init__.py": "c04c56f3d3c1739ad80d4df3ff11922f118c8d75ca063b6db0f0324d288f9e3e", + "app/bootstrap.py": "9316302dcf6b1cde47af962be5e4dd6ff1fa4617f093dc35ca7eee9a749541a9", + "app/calendar.py": "63a635c2c3b88a1abe86a04690098ecf108435479afe86ed6b7b4a21138e6056", + "app/cli.py": "adb4bd15376c5381301d11441ffb382a8a4c4d574ebde1ff66bfb95c587abf46", + "app/config.py": "5140721b86590d2cd37a5788f7b6215dcbef5299ebcbd9bc2ee6aa076e4b468d", + "app/database.py": "cf6fb8c2432a62963a1ae76c4643bb9e72437016fbc4829fcae5f2b2d6b7f914", + "app/i18n.py": "fb4015163a1a7401e561a975e2d9983ce9c14555c8f12b9a632dd8e590d08e93", + "app/main.py": "5e8bfe76ea17758bbfb83cb5453b495034b6ba241e041ffd3d98885ee5733a9d", + "app/models.py": "f42ee705c8ef5d35c4ad82dc221b2d794f01e1d17d1ea25360b8e4944b85ae15", + "app/notifications.py": "c45edb681bb40652102a9a5d3f18edfb085dcdf5f33bf9bcd7913e8ddd77924d", + "app/pdf.py": "697e15ca05213e2f5de405dc70f2b6e0c9dc3ccf5f36d9fa0b5bf6c6acec2c7a", + "app/scheduling.py": "dc8d5d43805b1ad2b3264fbefb87659b3f4b0875bbb9585115b127f5a54365ef", + "app/security.py": "516a4c335226563a59542105217095ed6cedc94aacee463edbfb45b29bfe86be", + "app/static/app.css": "325d2b4723f7cb46ffe5240f0fdf787c1435f52aff5bf3d922f4e8827ef767f1", + "app/static/logo-crr.png": "d14dde43019621b2aa359b4f6ea7e01dc64c4fbf206ad5f629cbf95c8d9f2df3", + "app/templates/_schedule_export_form.html": "20c607d2626f7d9a737fbd0537ceea698f57b8f233796f91488cb901b8da8251", + "app/templates/account.html": "d9c15a5f37312f53a05c542de9a8ac090c5cd6667e4ac314e0a49918169a3623", + "app/templates/admin_assign.html": "ca9c371da677f21d0ef5ffe640ce64e81827472833200270076af9bfb6f1e355", + "app/templates/admin_notifications.html": "91a017426856d851a7c0dc820c1f8ceb152116d4c60a904a1e23e06aadd4a857", + "app/templates/admin_schedule.html": "ea2563c09bc26163f53e64b340c22baba5b5526b86dad86d3e722a8a44e56d12", + "app/templates/admin_schedules.html": "dd102843c060b3cd23de5369cca157990f68d87d2d9a9e001442360c1ee8b304", + "app/templates/admin_team_delete.html": "69506df5d26d893f1b9015364a069c229d9882749a3e581693080a559be9b130", + "app/templates/admin_teams.html": "2d21b7cde73c0504ede059ffcae2c4dd7b89b1d09718ec6a210324b35a71ff1c", + "app/templates/admin_users.html": "ae86af98b7f7b762e19101398d580e19f9204d965a9d22e30a51c38115ce0534", + "app/templates/base.html": "6343c0415bb54b6b8a90bcba5edf46076ba37a74c14dc0c825605395854a0de3", + "app/templates/calendar.html": "c0dace3297d5a190f088df2c27848e30ff7683b9b4f46860804956c270fed848", + "app/templates/dashboard.html": "fa94e4aaf5f80b34325c4b5d5bbc3736241bec7ade35f8737a36bfd4aec7b3a3", + "app/templates/export_schedule.html": "f0dd4ef8d740ad9c40b5135e65e486fdfeee931c40eba2addae53a25678de697", + "app/templates/login.html": "161bf18d6da2c7010b6520abc4a8989488c4761ca368188cfbb747a8854961a1", + "app/templates/swap_new.html": "4f314af1878145cc89ae3bc1709f43aa1acd7b023dd23fdbdd107f1bc8d938e7", + "app/templates/swaps.html": "8deafac584f2d2cf187a960ef4a96e7932a8967f29857854e24a4936281f7b53", + "app/web.py": "e865cb15a4608335286f0aedc0741160a5dec31d6d17c7ccc1138d2bb0e4b63d", + "compose.yaml": "449bf47bb38bd0ccba1f9d1e105307da1e4ab6034103785c38c6c2e338e50b2f", + "docs/ARCHITECTURE.md": "b3f60767e63b72c1fee3672bc2b94e66f5076d9f85e547620059d5f2d0220ffd", + "docs/DOMAIN.md": "633cc4406230811e89a8c4dd83d72bce7bc943b7d4924dd79f085ddc7e5b648b", + "migrations/README": "31595cf53626af9ed16e15c44fa43183209cc163fbc3ebcb904b22ac436a8884", + "migrations/env.py": "2094c197e04306806e783814065c4a47f98dd7764315ffac6e676130c0ee202e", + "migrations/script.py.mako": "d38920781b4d31ae1c0a71bc09f41c2ade8feab9e5a087e3f30cb4bbf0c1c9d3", + "migrations/versions/0001_initial.py": "6b5f1c37860c1cde017565a39464bf9b2635840c695f1f06a874845e3fa3ff6a", + "pyproject.toml": "e975b2aee7f78584805bc4a77acc9db116496d38f0d2cbe0967a7f462cd864b7", + "pyrightconfig.json": "9fbf4d6ecc40f01a15bf9d3e8262390361012b78652f07a948a65261cf43e3fe", + "requirements-dev.txt": "3996ec073fd8c07fad9ba9cbb2d109b3c2d1711efc1035a752999a42eda56edd", + "requirements.txt": "09683cca66d07d5a3b58f02711e02f65d76ce55339ad6c0296b0679c3b99be69", + "tests/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/conftest.py": "4e0f62bc2d93c343bdd28d33aa1407c787b26c36e05368e94e697311f0d9ab3f", + "tests/test_export_schedule.py": "2947953104e81cb54cf99c028ba94d613928110160aea2aee3bd0fe36c8cb474" + }, + "checks": [ + { + "command": [ + "uv", + "venv", + "--python", + "3.13", + ".migration/shifts/.venv" + ], + "exit_code": 0 + }, + { + "command": [ + "uv", + "pip", + "install", + "--python", + ".migration/shifts/.venv/bin/python", + "-r", + "requirements-dev.txt" + ], + "exit_code": 0 + }, + { + "command": [ + ".migration/shifts/.venv/bin/python", + "-m", + "pytest", + "-p", + "no:cacheprovider", + "--junitxml=migration/results/shifts/tests.xml" + ], + "exit_code": 0 + } + ], + "tests": [ + { + "name": "tests.test_export_schedule.test_form_has_editable_dates_and_download_action", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_old_admin_export_link_redirects_to_integrated_controls", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_member_export_does_not_grant_administration_access", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_both_export_routes_require_login", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_pdf_is_one_a4_page_with_month_lists_and_inclusive_dates", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_other_schedule_assignments_are_excluded", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_full_daily_year_fits_one_page_without_losing_rows", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_invalid_ranges_keep_form_values_and_show_error[2026-12-26-2026-09-19-data final]", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_invalid_ranges_keep_form_values_and_show_error[2026-01-01-2027-01-01-12 meses]", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_invalid_ranges_keep_form_values_and_show_error[1999-12-01-2000-01-01-2000 e 2100]", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_malformed_date_is_rejected", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_single_day_and_empty_range_still_produce_pdf", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_inactive_or_unknown_schedule_is_rejected", + "status": "passed" + }, + { + "name": "tests.test_export_schedule.test_dense_long_names_wrap_and_remain_inside_page", + "status": "passed" + } + ] +} diff --git a/migration/baseline/tournament.json b/migration/baseline/tournament.json new file mode 100644 index 0000000..db3117e --- /dev/null +++ b/migration/baseline/tournament.json @@ -0,0 +1,334 @@ +{ + "source": "originals/tournment-manager-web", + "sha256": { + ".dockerignore": "178aac544855dee0aaf2305f9c2ffa924f81b1c95bae33dc5a9d8a34e8328767", + ".env.example": "6bac1679acbecccdd88ff257fe9db7429d9c5096adc3f58290d22b37a0decad1", + ".gitignore": "9b52e2b799fb85e5afc0b3b4f7d0f559f213b4feb681bfe0e46d28b7550bd842", + "AGENTS.md": "e128eb7b4e77101d48a939a83cd6496eb28d495b9645946858ef19e2777f092d", + "AGENTS.md.bak": "bc10ec3686bedfdad9bcc4d7c308da76548d293ef1eade82c2f3557ad9f5ebb0", + "Dockerfile": "c7ac20016148d49b4c21fb6bb6b1af37937e12ec9262f24b33b5927f1f574794", + "README.md": "ff77a4a53ac161c89330046a19632c9f0d5d9bf2d45271c1f88bb88542fdacf7", + "compose.yaml": "734aa0eb1dc26c33f3fe570fa2bff82e4a222aacea792cf4c07b59f2f3c74ebc", + "index.html": "622d05f56b43ba5dbf1f25a5fd85c633d68b971a760b1eb83c7c4541209e5e4a", + "logo.png": "ff1648cdbb27303e321f6a90a8900b296d8e888d2a171a32809f9358c41173a7", + "package-lock.json": "1c879087ccbe0647436e581dff229a5272c3cf92edeeb028f6e5729c947a644e", + "package.json": "ed3b56bf731bdd6057dc75bf1ca82e896d7ddc31464bf360bc4adeaad47c6df0", + "public/favicon.png": "c1e5bc00c3eb05c33c9cc153e6cec69f6c4ced4d6d9141a8cbf10c9bf03203dc", + "src/client/App.vue": "98e9ad1d4641c07490d8e06733e1276dea69624109e956ebdf1e70ce3627aefb", + "src/client/components/AdminShell.vue": "53ca955d2a27e88c3104866b68d77d26d4e8a657118e032203cda35b5b7f52d4", + "src/client/components/AppLogo.vue": "1f39a89328a72f58080d756549e6eba2d916c81bad021fdf4a70703564e096d0", + "src/client/components/AppModal.vue": "0fc51991c62ead81f13750342d3a112f379b1c8ad6193ae6c9edf47ecea693d4", + "src/client/components/ClassificationTable.vue": "9924070fdf4ff90cabb12d75a24e29fcf96f4616a8dbfa212686eb54ee294fdd", + "src/client/components/DisplayClassificationPanel.vue": "27b889c6c1f33fa002d1812ae9baee07c69187ac1843e9352626fd0b15e7344f", + "src/client/components/DisplayFit.vue": "5b74538367fb33ce262e8746f574b0fbbf35f5e31e28e2786c36c4a5163a9ebb", + "src/client/components/DisplayLatestResultsPanel.vue": "39dd1d0dd3dcc8a339563f47b5658ec05d0f502f4103ff82215f59cc9fc914d1", + "src/client/components/DisplayNextMatchPanel.vue": "7c93993baaf2c8dba6d943621eb00e4ec808f448d8723838644345244a21adc5", + "src/client/components/FinalBracket.vue": "626dfb31dee0348bf375983fcdb94d3ec86e8127216efd575abdd186792188f9", + "src/client/components/MatchResultCard.vue": "81bb29b57bf45fbcb378ad51731d23e3b9682a5461371ebd14892dcf8e58965f", + "src/client/components/match-cards/FourScores.vue": "576bf5aebc4646a4c0e38eada766240314d76a9869b31b52b70a6a4a8b3cbda2", + "src/client/components/match-cards/SingleScore.vue": "710e7b82905e1fbf79a78edc30f737bd1976d338e2458d165257eca85c78f13c", + "src/client/components/match-cards/SixScores.vue": "f69826ca1c83fd688e56487006ba5f4a6e1816fb8558560f7cca3df79df24939", + "src/client/components/matchCards.ts": "180a3525fb799d6cf6aade2031b0fc15564bdcd92c517c4616649dc410e11dbe", + "src/client/displayPanels.ts": "6c328a2d48064f6776aee63c14892ed37d5b1ce1fb7daedfdc1fd111bf693ab0", + "src/client/lib/api.ts": "3cb692eb4acc9ecb89175a8da6b3ce014e9f67c69217e5697b1808f36e098c4b", + "src/client/lib/format.test.ts": "12ce52fb65ee672bc08c42c791da7f5c501a5efc1a2567c3693c880f21f071a5", + "src/client/lib/format.ts": "e7b05cf464b5827439d7eec6ea1e22956c27136ad2d5f82560bfb1507cbe7e63", + "src/client/lib/score.test.ts": "7a7d0b2cce5dd88cb2f795b9d53fffc925d9bf31f31fabeecd4c3bd1bdf16ab3", + "src/client/lib/score.ts": "9999d7e55be56434acba1418f701497cba62d2efb3ec4e77a95e7d9f8946c0d7", + "src/client/lib/usePublicTournamentState.ts": "ce2ddd8ade4842a5f0a49a07d9bfba2fb220c98f614b2ab0f8252345b021c588", + "src/client/main.ts": "69ce4fe8901340615eec82ffe3f7fb8eef26c052f3c6cb8836b1cb8582272eb7", + "src/client/router.ts": "c7798c05c1d209465516311c3cb2cc9cce3dfffa0330fbf7c1ffbf6f8ddb372a", + "src/client/styles.css": "3d95766165d8c301cb8354524780a558bcb9e34349453d59e31267e20e26cdb4", + "src/client/types.ts": "2ac37341bd5ff1a6d34ca7f63a80fa72135130b7b67e4efc8948e661ccdd8b6f", + "src/client/views/CalendarView.vue": "12a71303d4a885016a765c1d9f1026275417304f9eab807b3f5ef0dc28e178c6", + "src/client/views/DashboardView.vue": "45d4544d975bd561748386333b79000fd8c6a387a749d572230344d1df461cb5", + "src/client/views/DisplaySettingsView.vue": "e1473f3f7f19027aeb024f111be7f17026bd29ab539d36857faa2a5e442dbe72", + "src/client/views/DisplayView.vue": "35e5554890506ef04376f42d132d000b274005fddabd5bfd1628c54cbebe949f", + "src/client/views/FinalStageView.vue": "c77781d54daed1e8d5d2fcc52d9b20e181e0e93f83c38fc86b5dedd43308fea9", + "src/client/views/LoginView.vue": "0355286c8ceca82e05bfb0b79f632bae58ce04e4d7a550cc48292acba3a6b241", + "src/client/views/MatchCardsView.vue": "a26c3191f6b20c3d35acd6dbd66d92ba7b367c96f37ba588ddcc0e0d8e500ff1", + "src/client/views/PublicResultsView.vue": "d8ae4d0b51180a948f012e8884b3565d27ece3e20a2ca3b23e292b59dd42dc0e", + "src/client/views/ResultsView.vue": "407f1cab97f5cb330166e7624a0d3fc70ee791da889795e085edcc3b28b25834", + "src/client/views/TeamsView.vue": "aa84e47e61b96ca7733230a9c1faeb75f5ad0b141fe7e7a97f8b0190c3ff7bc2", + "src/server/app.integration.test.ts": "a8d16cedbfbfcacc33f234c4fe60e09078dac117953a3254106d0a701865488c", + "src/server/app.ts": "4f37e9f5a5d90735c610564d5a15cbe148b4df079b10d7177341c090e27c5d0d", + "src/server/auth.ts": "3495c47e1099fdf9d8bab61b4609c371d6dd589f9f4c01291503133068f8b80a", + "src/server/db/database.test.ts": "816e36a37d58e511076019f407afd166fc52053768bfde0c75b179b680892971", + "src/server/db/database.ts": "e7f659a176f7f9edd63e22c46dd3af6f7cf545a6654ed5f66e1799422a9cb290", + "src/server/errors.ts": "2cb51b5ab83797db79bbdda6066cad1f2e8933a6abdc9eb8b94f3b66bbda369a", + "src/server/events.ts": "a0e48fb3abc16248f4ac245204227ef9724dfb9a4e14a10033dad3048fb5c7fa", + "src/server/main.ts": "60b3835fe40e8863aaac358c8acd9728a30dd258b2766a368b920d4107add989", + "src/server/services/bracket.test.ts": "9a930d995b020751609bc70658e5a3514cbc0feb559fb51e0ed6dbd2d9a9dbc6", + "src/server/services/bracket.ts": "07572f946bce4128758c7abe99b94f52ba50e507f9c733b93746803ea6ceced3", + "src/server/services/calendar.test.ts": "3ed80358e56a2447242f0d5e8907ac558cde9983149ed618e63cfc530c189248", + "src/server/services/calendar.ts": "8d6a644d3be6a6f1fc53f5e89490e7be230f294ef6b2e4f5e8e91e1a3d233b08", + "src/server/services/classification.test.ts": "83eddf6ee8b6f1779d0c5488cbf34ed3e6e4f4777172ad37cee42fc060d62e91", + "src/server/services/classification.ts": "b0c8775f9f0abb6d06984ee5ac6b25b016c0628c12e07866d11f1c436582767c", + "src/server/services/state.ts": "412d65f9d1288d6c552ca4c5b56a9d24472a440226eb40b38156a7c83fafbb48", + "src/shared/finalSeeding.test.ts": "5e4a3ea66324d0b488a29109263495c0d5ee5c831a149e659b2e5dfd9b23a99b", + "src/shared/finalSeeding.ts": "76ab7d02b5d8a3577d8a6aa6c7a6f7a4578dc8664c6b12481dddbac085350de9", + "src/shared/types/tournament.ts": "277298053306a1ed97016daa4a5c2416d4f2697b30798f04bd0a08fe187145b9", + "src/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5", + "tsconfig.client.json": "26adbfc7307ebc32594106e5fd7c33cacf953a1c14fd33d48a973924e5fb3d43", + "tsconfig.server.json": "43cf832bf150a0e895913a773e637929023e2ef5ed2b35d7a285d60b109d78c3", + "vite.config.ts": "01d718dc8e11bad093a3734693f02d237f64d5431dce0b5f4c086d5babccdc43" + }, + "checks": [ + { + "command": [ + "npm", + "ci" + ], + "exit_code": 0 + }, + { + "command": [ + "npm", + "test", + "--", + "--reporter=default", + "--reporter=json", + "--outputFile=migration/results/tournament/tests.json" + ], + "exit_code": 0 + }, + { + "command": [ + "npm", + "run", + "typecheck" + ], + "exit_code": 0 + }, + { + "command": [ + "npm", + "run", + "build" + ], + "exit_code": 0 + } + ], + "tests": [ + { + "name": "final seeding uses standard bracket seed placement", + "status": "passed" + }, + { + "name": "final seeding crosses the top two teams from two groups in the semi-finals", + "status": "passed" + }, + { + "name": "final seeding leaves lower seeds empty when the bracket has extra slots", + "status": "passed" + }, + { + "name": "final seeding can select the best teams across all groups", + "status": "passed" + }, + { + "name": "tournament manager HTTP API rejects unauthenticated administration and accepts only the configured login password", + "status": "passed" + }, + { + "name": "tournament manager HTTP API scopes the administration session cookie to the deployment path", + "status": "passed" + }, + { + "name": "tournament manager HTTP API publishes the admin-selected panel and zoom to the public display state", + "status": "passed" + }, + { + "name": "tournament manager HTTP API notifies connected displays when the selected panel changes", + "status": "passed" + }, + { + "name": "tournament manager HTTP API creates teams in Grupo A and rejects globally duplicated team numbers", + "status": "passed" + }, + { + "name": "tournament manager HTTP API assigns the next team number when creating a team without one", + "status": "passed" + }, + { + "name": "tournament manager HTTP API manages groups and prevents deleting nonempty or final groups", + "status": "passed" + }, + { + "name": "tournament manager HTTP API previews and saves the exact one-leg schedule for four teams", + "status": "passed" + }, + { + "name": "tournament manager HTTP API rejects generation when no group can produce a match", + "status": "passed" + }, + { + "name": "tournament manager HTTP API keeps a round out of the standings until an administrator enables it", + "status": "passed" + }, + { + "name": "tournament manager HTTP API ranks total game points and resolves equal totals by the direct match", + "status": "passed" + }, + { + "name": "tournament manager HTTP API generates only intra-group games with persisted rounds and aligned public classifications", + "status": "passed" + }, + { + "name": "tournament manager HTTP API previews a second leg that exactly mirrors the first with reversed venues", + "status": "passed" + }, + { + "name": "tournament manager HTTP API rejects tampered and stale calendar generation descriptors", + "status": "passed" + }, + { + "name": "tournament manager HTTP API requires confirmation to replace a scored calendar and clears scores when confirmed", + "status": "passed" + }, + { + "name": "tournament manager HTTP API randomizes existing team numbers and group assignments after confirmation", + "status": "passed" + }, + { + "name": "tournament manager HTTP API configures and advances a final stage that is exposed through public state", + "status": "passed" + }, + { + "name": "tournament manager HTTP API accepts the next-match display panel", + "status": "passed" + }, + { + "name": "tournament manager HTTP API previews automatic final-stage qualification from group standings", + "status": "passed" + }, + { + "name": "latestResultsRound returns null without a calendar", + "status": "passed" + }, + { + "name": "latestResultsRound shows the first scheduled round before results are entered", + "status": "passed" + }, + { + "name": "latestResultsRound shows every match from the latest round containing a result", + "status": "passed" + }, + { + "name": "unfinishedLeagueMatches returns unfinished matches ordered by game number", + "status": "passed" + }, + { + "name": "parseScoreDraft parses non-negative integer scores", + "status": "passed" + }, + { + "name": "parseScoreDraft rejects invalid score drafts: { scoreA: '', scoreB: '1' }", + "status": "passed" + }, + { + "name": "parseScoreDraft rejects invalid score drafts: { scoreA: '-1', scoreB: '1' }", + "status": "passed" + }, + { + "name": "parseScoreDraft rejects invalid score drafts: { scoreA: '1.5', scoreB: '2' }", + "status": "passed" + }, + { + "name": "parseScoreDraft rejects invalid score drafts: { scoreA: 'a', scoreB: '2' }", + "status": "passed" + }, + { + "name": "database initialization creates the current schema and its default rows", + "status": "passed" + }, + { + "name": "database initialization enforces the current schema constraints", + "status": "passed" + }, + { + "name": "calculateBracket faz avan\u00e7ar vencedores entre v\u00e1rias rondas e determina o campe\u00e3o", + "status": "passed" + }, + { + "name": "calculateBracket n\u00e3o faz avan\u00e7ar um empate que a API deve rejeitar", + "status": "passed" + }, + { + "name": "calculateBracket faz avan\u00e7ar uma equipa automaticamente num bye e deixa jogos vazios por apurar", + "status": "passed" + }, + { + "name": "calculateBracket cria o jogo do 3.\u00ba lugar com os derrotados das meias-finais", + "status": "passed" + }, + { + "name": "calculateBracket identifica todos os resultados a invalidar ap\u00f3s mudar um jogo a montante", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule gera as tr\u00eas jornadas de um grupo par com quatro equipas", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule suporta um grupo \u00edmpar atrav\u00e9s de uma folga por jornada", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule combina grupos ordenados nas mesmas jornadas sem cruzar nem repetir equipas", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule anexa uma segunda volta que espelha exatamente a primeira", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule agenda todos os pares uma vez por volta", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule n\u00e3o altera a lista nem a ordem recebidas", + "status": "passed" + }, + { + "name": "generateRoundRobinSchedule rejeita um n\u00famero de voltas inv\u00e1lido mesmo quando o tipo \u00e9 contornado", + "status": "passed" + }, + { + "name": "calculateClassification mant\u00e9m a classifica\u00e7\u00e3o standard como modo predefinido", + "status": "passed" + }, + { + "name": "calculateClassification calcula vit\u00f3rias, empates, derrotas e ignora resultados incompletos", + "status": "passed" + }, + { + "name": "calculateClassification ordena por pontos, diferen\u00e7a de golos e golos marcados", + "status": "passed" + }, + { + "name": "calculateClassification usa confronto direto para equipas empatadas nos crit\u00e9rios principais", + "status": "passed" + }, + { + "name": "calculateClassification usa o n\u00famero da equipa como \u00faltimo crit\u00e9rio determin\u00edstico", + "status": "passed" + }, + { + "name": "calculateClassification total-points soma os resultados marcados por cada equipa nos pontos", + "status": "passed" + }, + { + "name": "calculateClassification total-points resolve totais empatados pelo confronto direto, n\u00e3o pela diferen\u00e7a global", + "status": "passed" + }, + { + "name": "calculateClassification total-points usa o n\u00famero da equipa como fallback determin\u00edstico", + "status": "passed" + }, + { + "name": "calculateClassification total-points ignora jogos incompletos sem acumular resultados ou estat\u00edsticas", + "status": "passed" + } + ] +} diff --git a/migration/capture_screenshots.py b/migration/capture_screenshots.py new file mode 100644 index 0000000..174c6c9 --- /dev/null +++ b/migration/capture_screenshots.py @@ -0,0 +1,187 @@ +"""Capture desktop/mobile legacy pages using synthetic data and isolated servers.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import time +from urllib.request import urlopen +from urllib.parse import urlsplit + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] +WORK = ROOT / ".migration" + + +def wait_for(url: str, process: subprocess.Popen) -> None: + for _ in range(100): + if process.poll() is not None: + raise RuntimeError(f"Server exited before serving {url}") + try: + with urlopen(url, timeout=1): + return + except OSError: + time.sleep(0.1) + raise RuntimeError(f"Timed out waiting for {url}") + + +def main(migrated: bool = False, branding: bool = False, reference: Path | None = None) -> None: + if branding: + output = ROOT / "migration/phase2/screenshots" if migrated else WORK / "branding-baseline" + else: + output = WORK / "screenshots" if migrated else ROOT / "migration/screenshots" + output.mkdir(parents=True, exist_ok=True) + suffix = "-migrated" if migrated else "" + tournament_source = ROOT / "apps/tournament" if migrated else WORK / "tournament" + shifts_source = ROOT / "apps/shifts/backend" if migrated else WORK / "shifts" + tournament_db = WORK / f"visual-tournament{suffix}.sqlite" + shutil.copyfile(ROOT / "migration/fixtures/tournament.sqlite", tournament_db) + env = os.environ | { + "CAPTURE_DATABASE": str(tournament_db), + "MIGRATION_SOURCE_DIR": str(tournament_source), + "DATABASE_URL": f"sqlite:///{WORK / f'visual-shifts{suffix}.db'}", + "BACKUP_DIR": str(WORK / "visual-backups"), + "SECRET_KEY": "migration-visual-only-secret", + "INITIAL_ADMIN_USERNAME": "migration-admin", + "INITIAL_ADMIN_PASSWORD": "migration-fixture-password", + "SMTP_HOST": "", "ROOT_PATH": "", "SESSION_HTTPS_ONLY": "false", + "PYTHONDONTWRITEBYTECODE": "1", + } + python = str(ROOT / ".venv/bin/python" if migrated else WORK / "shifts/.venv/bin/python") + subprocess.run([python, "-m", "app.cli", "seed-demo"], cwd=shifts_source, env=env, check=True) + processes = [] + logs = [] + try: + for name, command, cwd in [ + ("tournament", ["node", "--import", str(tournament_source / "node_modules/tsx/dist/loader.mjs"), + "migration/capture_tournament.mjs", "--serve"], ROOT), + ("shifts", [python, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", + "--port", "18081"], shifts_source), + ]: + log = (WORK / f"visual-{name}.log").open("w") + logs.append(log) + processes.append(subprocess.Popen(command, cwd=cwd, env=env, stdout=log, stderr=log)) + wait_for("http://127.0.0.1:18080/api/public/state", processes[0]) + wait_for("http://127.0.0.1:18081/health", processes[1]) + captures = [] + layouts = {} + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + try: + for size, width, height in [("desktop", 1440, 1000), ("mobile", 390, 844)]: + context = browser.new_context(viewport={"width": width, "height": height}, + locale="pt-PT", timezone_id="Europe/Lisbon") + page = context.new_page() + page.clock.set_fixed_time(datetime(2026, 10, 7, 12, tzinfo=timezone.utc)) + + def capture(app: str, name: str, url: str, *, print_media: bool = False) -> None: + response = page.goto(url, wait_until="domcontentloaded") + assert response and response.status == 200, url + page.locator("h1").first.wait_for() + page.evaluate("document.fonts.ready") + page.wait_for_timeout(500) + page.emulate_media(media="print" if print_media else "screen") + filename = f"{app}-{name}-{size}.png" + if branding: + assert page.evaluate("Array.from(document.images).every(img => img.complete && img.naturalWidth > 0)"), filename + layouts[filename] = page.locator( + "h1, h2, h3, main, header, form, button, input, select, table, tr, th, td, img, " + ".panel, .auth-card, .day-card, .team-card, .match-result-card, " + ".match-result-card__score-fields span" + ).evaluate_all("""elements => elements.map(element => { + const rect = element.getBoundingClientRect(); + return {tag: element.tagName, class: element.className, + rect: ['x', 'y', 'width', 'height'].map(key => Math.round(rect[key] * 1000) / 1000)}; + })""") + if migrated: + primary = page.evaluate("getComputedStyle(document.documentElement).getPropertyValue('--crr-primary').trim()") + assert primary == "#ed1c24", filename + page.screenshot(path=str(output / filename), full_page=True, animations="disabled") + captures.append({"file": filename, "path": urlsplit(url).path, + "viewport": {"width": width, "height": height}, + "media": "print" if print_media else "screen"}) + page.emulate_media(media="screen") + + tournament = "http://127.0.0.1:18080" + capture("tournament", "login", tournament + "/admin/login") + capture("tournament", "results", tournament + "/results") + login = context.request.post(tournament + "/api/auth/login", + data={"password": "migration-fixture-password"}) + assert login.status == 200 + capture("tournament", "teams", tournament + "/admin/teams") + capture("tournament", "match-cards-print", tournament + "/admin/match-cards", print_media=True) + # Separate contexts: cookies from the two apps share the same hostname. + context.close() + context = browser.new_context(viewport={"width": width, "height": height}, + locale="pt-PT", timezone_id="Europe/Lisbon") + page = context.new_page() + page.clock.set_fixed_time(datetime(2026, 10, 7, 12, tzinfo=timezone.utc)) + shifts = "http://127.0.0.1:18081" + capture("shifts", "login", shifts + "/login") + page.locator('[name="username"]').fill("migration-admin") + page.locator('[name="password"]').fill("migration-fixture-password") + page.locator('button[type="submit"]').click() + page.wait_for_url(shifts + "/") + capture("shifts", "dashboard", shifts + "/") + capture("shifts", "teams", shifts + "/admin/teams") + context.close() + finally: + browser.close() + (output / "manifest.json").write_text(json.dumps(captures, indent=2) + "\n") + if branding: + (output / "layout.json").write_text(json.dumps(layouts, indent=2) + "\n") + if migrated: + baseline_layout = json.loads((WORK / "branding-baseline/layout.json").read_text()) + assert layouts.keys() == baseline_layout.keys() + for filename, layout in layouts.items(): + expected = baseline_layout[filename] + differences = [(index, before, after) for index, (before, after) in enumerate(zip(expected, layout)) + if before != after] + assert len(layout) == len(expected) and not differences, f"{filename}: {differences[:5]}" + if "match-cards-print" in filename: + assert (ROOT / "migration/screenshots" / filename).read_bytes() == (output / filename).read_bytes(), filename + print(f"All {len(captures)} branded captures preserve legacy geometry; both print captures match byte-for-byte.") + report = { + "captures": len(captures), + "legacy_geometry_preserved": True, + "print_captures_byte_identical": 2, + "primary_token": "#ed1c24", + "element_counts": {filename: len(layout) for filename, layout in layouts.items()}, + "screenshot_sha256": {item["file"]: hashlib.sha256((output / item["file"]).read_bytes()).hexdigest() + for item in captures}, + } + (output.parent / "verification.json").write_text(json.dumps(report, indent=2) + "\n") + elif migrated: + baseline = ROOT / reference if reference else ROOT / "migration/screenshots" + for item in captures: + filename = item["file"] + assert (baseline / filename).read_bytes() == (output / filename).read_bytes(), filename + label = str(reference) if reference else "the legacy baseline" + print(f"All {len(captures)} migrated screenshots match {label} byte-for-byte.") + finally: + for process in processes: + process.terminate() + for process in processes: + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + for log in logs: + log.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--migrated", action="store_true") + parser.add_argument("--branding", action="store_true", help="Check layout geometry while permitting shared palette/asset changes") + parser.add_argument("--reference", type=Path, help="Screenshot directory for exact migrated comparisons") + args = parser.parse_args() + main(args.migrated, args.branding, args.reference) diff --git a/migration/capture_tournament.mjs b/migration/capture_tournament.mjs new file mode 100644 index 0000000..e1ae433 --- /dev/null +++ b/migration/capture_tournament.mjs @@ -0,0 +1,109 @@ +// Runs against an isolated legacy checkout; produces synthetic compatibility fixtures. +import assert from 'node:assert/strict'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const source = resolve(process.env.MIGRATION_SOURCE_DIR ?? '.migration/tournament'); +const output = resolve('migration/fixtures'); +const { createApp } = await import(pathToFileURL(`${source}/src/server/app.ts`)); +const { openDatabase } = await import(pathToFileURL(`${source}/src/server/db/database.ts`)); +const authentication = { + adminPassword: 'migration-fixture-password', + sessionSecret: 'migration-fixture-secret-not-for-production', +}; + +if (process.argv.includes('--serve')) { + const app = await createApp({ + databasePath: process.env.CAPTURE_DATABASE, + staticRoot: `${source}/dist/client`, basePath: '/', authentication, + }); + await app.listen({ host: '127.0.0.1', port: 18080 }); +} else { + mkdirSync(output, { recursive: true }); + mkdirSync(resolve('.migration'), { recursive: true }); + const temporary = mkdtempSync(resolve('.migration/capture-')); + const databasePath = `${temporary}/tournament.sqlite`; + const database = openDatabase({ path: databasePath }); + assert.equal(database.prepare('SELECT count(*) AS count FROM teams').get().count, 0); + const app = await createApp({ database, authentication, basePath: '/jogo/' }); + const observations = []; + let cookie; + const save = (name, value) => writeFileSync(`${output}/${name}`, JSON.stringify(value, null, 2) + '\n'); + async function request(method, url, payload, status = 200, authorized = true) { + const response = await app.inject({ method, url, payload, + headers: authorized && cookie ? { cookie } : {} }); + assert.equal(response.statusCode, status, `${method} ${url}: ${response.body}`); + const body = response.json(); + observations.push({ request: { method, url, ...(payload ? { payload } : {}), authorized }, + response: { status, body } }); + return { body, response }; + } + try { + await request('GET', '/api/auth/session', undefined, 200, false); + await request('GET', '/api/admin/teams', undefined, 401, false); + await request('POST', '/api/auth/login', { password: 'wrong' }, 401, false); + const login = await request('POST', '/api/auth/login', + { password: authentication.adminPassword }, 200, false); + const session = login.response.cookies.find(item => item.name === 'tournament_admin_session'); + assert.ok(session); + cookie = `${session.name}=${session.value}`; + // Preserve cookie attributes, excluding the generated token. + save('tournament-cookie.json', login.response.headers['set-cookie'].replace(session.value, '')); + await request('PUT', '/api/admin/settings', { + name: 'Torneio de referência CRR', classificationMode: 'standard', + }); + const teams = []; + for (let number = 1; number <= 4; number++) { + const team = (await request('POST', '/api/admin/teams', { + number, name: `Equipa ${number}`, groupId: 1, + }, 201)).body; + teams.push(team); + await request('POST', `/api/admin/teams/${team.id}/players`, { name: `Jogador ${number}` }, 201); + } + await request('POST', '/api/admin/teams', { number: 1, name: 'Duplicada', groupId: 1 }, 409); + const calendar = (await request('POST', '/api/admin/calendar/generate', { + generation: { legs: 1, teamOrder: teams.map(team => ({ teamId: team.id, groupId: 1 })) }, + })).body; + assert.ok(calendar.matches.length > 0); + await request('PUT', `/api/admin/matches/${calendar.matches[0].id}/result`, { scoreA: 3, scoreB: 1 }); + await request('PUT', '/api/admin/rounds/1/standings', { countsTowardStandings: true }); + await request('PUT', '/api/admin/final-stage/config', { roundCount: 2, thirdPlaceEnabled: true }); + await request('PUT', '/api/admin/final-stage/seeds', { + seeds: teams.map((team, index) => ({ slotIndex: index + 1, teamId: team.id })), + }); + await request('PUT', '/api/admin/final-stage/matches/1/1/result', { scoreA: 2, scoreB: 0 }); + await request('PUT', '/api/admin/final-stage/matches/1/2/result', { scoreA: 1, scoreB: 4 }); + await request('PUT', '/api/admin/final-stage/matches/2/1/result', { scoreA: 3, scoreB: 2 }); + await request('PUT', '/api/admin/final-stage/matches/2/2/result', { scoreA: 1, scoreB: 0 }); + await request('PUT', '/api/admin/display', { activePanel: 'classification', zoomPercent: 110 }); + // Freeze persisted timestamps so the final state and DB are stable across captures. + for (const table of ['tournament_settings', 'display_settings', 'league_groups', 'teams']) { + database.prepare(`UPDATE ${table} SET created_at = ?, updated_at = ?`) + .run('2026-01-01 00:00:00', '2026-01-01 00:00:00'); + } + save('tournament-state.json', (await request('GET', '/api/public/state')).body); + await request('GET', '/api/admin/missing', undefined, 404); + save('tournament-api.json', observations); + const schema = database.prepare( + "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name", + ).all(); + save('tournament-schema.json', schema); + const tables = schema.filter(item => item.type === 'table').map(item => item.name); + save('tournament-rows.json', Object.fromEntries(tables.map(table => [table, + database.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all()]))); + const routes = [...readFileSync(`${source}/src/server/app.ts`, 'utf8') + .matchAll(/app\.(get|post|put|delete)\('(\/api\/[^']+)'/g)] + .map(match => ({ method: match[1].toUpperCase(), path: match[2] })); + save('tournament-routes.json', routes); + assert.deepEqual(database.pragma('foreign_key_check'), []); + assert.equal(database.pragma('integrity_check', { simple: true }), 'ok'); + } finally { + await app.close(); + database.pragma('wal_checkpoint(TRUNCATE)'); + database.close(); + } + copyFileSync(databasePath, `${output}/tournament.sqlite`); + rmSync(temporary, { recursive: true }); + console.log(`Tournament baseline captured in ${output}`); +} diff --git a/migration/fixtures/tournament-api.json b/migration/fixtures/tournament-api.json new file mode 100644 index 0000000..7feb6b9 --- /dev/null +++ b/migration/fixtures/tournament-api.json @@ -0,0 +1,2118 @@ +[ + { + "request": { + "method": "GET", + "url": "/api/auth/session", + "authorized": false + }, + "response": { + "status": 200, + "body": { + "authenticated": false + } + } + }, + { + "request": { + "method": "GET", + "url": "/api/admin/teams", + "authorized": false + }, + "response": { + "status": 401, + "body": { + "error": "É necessário iniciar sessão para aceder à administração." + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/auth/login", + "payload": { + "password": "wrong" + }, + "authorized": false + }, + "response": { + "status": 401, + "body": { + "error": "A palavra-passe está incorreta." + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/auth/login", + "payload": { + "password": "migration-fixture-password" + }, + "authorized": false + }, + "response": { + "status": 200, + "body": { + "authenticated": true + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/settings", + "payload": { + "name": "Torneio de referência CRR", + "classificationMode": "standard" + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "name": "Torneio de referência CRR", + "classificationMode": "standard", + "finalRoundCount": null, + "thirdPlaceEnabled": false + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams", + "payload": { + "number": 1, + "name": "Equipa 1", + "groupId": 1 + }, + "authorized": true + }, + "response": { + "status": 201, + "body": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "players": [] + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams/1/players", + "payload": { + "name": "Jogador 1" + }, + "authorized": true + }, + "response": { + "status": 201, + "body": { + "id": 1, + "teamId": 1, + "name": "Jogador 1", + "sortOrder": 0 + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams", + "payload": { + "number": 2, + "name": "Equipa 2", + "groupId": 1 + }, + "authorized": true + }, + "response": { + "status": 201, + "body": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "players": [] + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams/2/players", + "payload": { + "name": "Jogador 2" + }, + "authorized": true + }, + "response": { + "status": 201, + "body": { + "id": 2, + "teamId": 2, + "name": "Jogador 2", + "sortOrder": 0 + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams", + "payload": { + "number": 3, + "name": "Equipa 3", + "groupId": 1 + }, + "authorized": true + }, + "response": { + "status": 201, + "body": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "players": [] + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams/3/players", + "payload": { + "name": "Jogador 3" + }, + "authorized": true + }, + "response": { + "status": 201, + "body": { + "id": 3, + "teamId": 3, + "name": "Jogador 3", + "sortOrder": 0 + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams", + "payload": { + "number": 4, + "name": "Equipa 4", + "groupId": 1 + }, + "authorized": true + }, + "response": { + "status": 201, + "body": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "players": [] + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams/4/players", + "payload": { + "name": "Jogador 4" + }, + "authorized": true + }, + "response": { + "status": 201, + "body": { + "id": 4, + "teamId": 4, + "name": "Jogador 4", + "sortOrder": 0 + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/teams", + "payload": { + "number": 1, + "name": "Duplicada", + "groupId": 1 + }, + "authorized": true + }, + "response": { + "status": 409, + "body": { + "error": "Já existe uma equipa com esse número." + } + } + }, + { + "request": { + "method": "POST", + "url": "/api/admin/calendar/generate", + "payload": { + "generation": { + "legs": 1, + "teamOrder": [ + { + "teamId": 1, + "groupId": 1 + }, + { + "teamId": 2, + "groupId": 1 + }, + { + "teamId": 3, + "groupId": 1 + }, + { + "teamId": 4, + "groupId": 1 + } + ] + } + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "matches": [ + { + "id": 1, + "gameNumber": 1, + "roundNumber": 1, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 2, + "gameNumber": 2, + "roundNumber": 1, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 3, + "gameNumber": 3, + "roundNumber": 2, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 4, + "gameNumber": 4, + "roundNumber": 2, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 5, + "gameNumber": 5, + "roundNumber": 3, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 6, + "gameNumber": 6, + "roundNumber": 3, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + } + ], + "matchCount": 6, + "roundCount": 3 + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/matches/1/result", + "payload": { + "scoreA": 3, + "scoreB": 1 + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "id": 1, + "gameNumber": 1, + "roundNumber": 1, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": 3, + "scoreB": 1 + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/rounds/1/standings", + "payload": { + "countsTowardStandings": true + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "roundNumber": 1, + "countsTowardStandings": true + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/final-stage/config", + "payload": { + "roundCount": 2, + "thirdPlaceEnabled": true + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "roundCount": 2, + "thirdPlaceEnabled": true, + "rounds": [ + { + "roundIndex": 1, + "name": "Meias-finais", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": null, + "pending": false + }, + "teamB": { + "team": null, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + }, + { + "matchIndex": 2, + "teamA": { + "team": null, + "pending": false + }, + "teamB": { + "team": null, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + } + ] + }, + { + "roundIndex": 2, + "name": "Final", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": null, + "pending": false + }, + "teamB": { + "team": null, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + } + ] + } + ], + "thirdPlaceMatch": { + "matchIndex": 2, + "teamA": { + "team": null, + "pending": false + }, + "teamB": { + "team": null, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + }, + "champion": null + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/final-stage/seeds", + "payload": { + "seeds": [ + { + "slotIndex": 1, + "teamId": 1 + }, + { + "slotIndex": 2, + "teamId": 2 + }, + { + "slotIndex": 3, + "teamId": 3 + }, + { + "slotIndex": 4, + "teamId": 4 + } + ] + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "roundCount": 2, + "thirdPlaceEnabled": true, + "rounds": [ + { + "roundIndex": 1, + "name": "Meias-finais", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + }, + { + "matchIndex": 2, + "teamA": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + } + ] + }, + { + "roundIndex": 2, + "name": "Final", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": null, + "pending": true + }, + "teamB": { + "team": null, + "pending": true + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + } + ] + } + ], + "thirdPlaceMatch": { + "matchIndex": 2, + "teamA": { + "team": null, + "pending": true + }, + "teamB": { + "team": null, + "pending": true + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + }, + "champion": null + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/final-stage/matches/1/1/result", + "payload": { + "scoreA": 2, + "scoreB": 0 + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "roundCount": 2, + "thirdPlaceEnabled": true, + "rounds": [ + { + "roundIndex": 1, + "name": "Meias-finais", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 2, + "scoreB": 0, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + { + "matchIndex": 2, + "teamA": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + } + ] + }, + { + "roundIndex": 2, + "name": "Final", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": null, + "pending": true + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + } + ] + } + ], + "thirdPlaceMatch": { + "matchIndex": 2, + "teamA": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": null, + "pending": true + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + }, + "champion": null + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/final-stage/matches/1/2/result", + "payload": { + "scoreA": 1, + "scoreB": 4 + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "roundCount": 2, + "thirdPlaceEnabled": true, + "rounds": [ + { + "roundIndex": 1, + "name": "Meias-finais", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 2, + "scoreB": 0, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + { + "matchIndex": 2, + "teamA": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 1, + "scoreB": 4, + "winner": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + }, + { + "roundIndex": 2, + "name": "Final", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + } + ] + } + ], + "thirdPlaceMatch": { + "matchIndex": 2, + "teamA": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + }, + "champion": null + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/final-stage/matches/2/1/result", + "payload": { + "scoreA": 3, + "scoreB": 2 + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "roundCount": 2, + "thirdPlaceEnabled": true, + "rounds": [ + { + "roundIndex": 1, + "name": "Meias-finais", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 2, + "scoreB": 0, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + { + "matchIndex": 2, + "teamA": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 1, + "scoreB": 4, + "winner": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + }, + { + "roundIndex": 2, + "name": "Final", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 3, + "scoreB": 2, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + } + ], + "thirdPlaceMatch": { + "matchIndex": 2, + "teamA": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": null, + "scoreB": null, + "winner": null, + "isBye": false + }, + "champion": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + } + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/final-stage/matches/2/2/result", + "payload": { + "scoreA": 1, + "scoreB": 0 + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "roundCount": 2, + "thirdPlaceEnabled": true, + "rounds": [ + { + "roundIndex": 1, + "name": "Meias-finais", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 2, + "scoreB": 0, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + { + "matchIndex": 2, + "teamA": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 1, + "scoreB": 4, + "winner": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + }, + { + "roundIndex": 2, + "name": "Final", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 3, + "scoreB": 2, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + } + ], + "thirdPlaceMatch": { + "matchIndex": 2, + "teamA": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 1, + "scoreB": 0, + "winner": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + "champion": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + } + } + } + }, + { + "request": { + "method": "PUT", + "url": "/api/admin/display", + "payload": { + "activePanel": "classification", + "zoomPercent": 110 + }, + "authorized": true + }, + "response": { + "status": 200, + "body": { + "activePanel": "classification", + "zoomPercent": 110 + } + } + }, + { + "request": { + "method": "GET", + "url": "/api/public/state", + "authorized": true + }, + "response": { + "status": 200, + "body": { + "tournament": { + "name": "Torneio de referência CRR", + "classificationMode": "standard", + "finalRoundCount": 2, + "thirdPlaceEnabled": true + }, + "display": { + "activePanel": "classification", + "zoomPercent": 110 + }, + "groups": [ + { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + ], + "matches": [ + { + "id": 1, + "gameNumber": 1, + "roundNumber": 1, + "countsTowardStandings": true, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": 3, + "scoreB": 1 + }, + { + "id": 2, + "gameNumber": 2, + "roundNumber": 1, + "countsTowardStandings": true, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 3, + "gameNumber": 3, + "roundNumber": 2, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 4, + "gameNumber": 4, + "roundNumber": 2, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 5, + "gameNumber": 5, + "roundNumber": 3, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 6, + "gameNumber": 6, + "roundNumber": 3, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + } + ], + "groupStandings": [ + { + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "rows": [ + { + "position": 1, + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "played": 1, + "wins": 1, + "draws": 0, + "losses": 0, + "goalsFor": 3, + "goalsAgainst": 1, + "goalDifference": 2, + "points": 3 + }, + { + "position": 2, + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "played": 0, + "wins": 0, + "draws": 0, + "losses": 0, + "goalsFor": 0, + "goalsAgainst": 0, + "goalDifference": 0, + "points": 0 + }, + { + "position": 3, + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "played": 0, + "wins": 0, + "draws": 0, + "losses": 0, + "goalsFor": 0, + "goalsAgainst": 0, + "goalDifference": 0, + "points": 0 + }, + { + "position": 4, + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "played": 1, + "wins": 0, + "draws": 0, + "losses": 1, + "goalsFor": 1, + "goalsAgainst": 3, + "goalDifference": -2, + "points": 0 + } + ] + } + ], + "finalStage": { + "roundCount": 2, + "thirdPlaceEnabled": true, + "rounds": [ + { + "roundIndex": 1, + "name": "Meias-finais", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 2, + "scoreB": 0, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + { + "matchIndex": 2, + "teamA": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 1, + "scoreB": 4, + "winner": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + }, + { + "roundIndex": 2, + "name": "Final", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 3, + "scoreB": 2, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + } + ], + "thirdPlaceMatch": { + "matchIndex": 2, + "teamA": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 1, + "scoreB": 0, + "winner": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + "champion": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + } + } + } + } + }, + { + "request": { + "method": "GET", + "url": "/api/admin/missing", + "authorized": true + }, + "response": { + "status": 404, + "body": { + "error": "O recurso pedido não existe." + } + } + } +] diff --git a/migration/fixtures/tournament-cookie.json b/migration/fixtures/tournament-cookie.json new file mode 100644 index 0000000..d5be06c --- /dev/null +++ b/migration/fixtures/tournament-cookie.json @@ -0,0 +1 @@ +"tournament_admin_session=; Max-Age=604800; Path=/jogo/; HttpOnly; SameSite=Strict" diff --git a/migration/fixtures/tournament-routes.json b/migration/fixtures/tournament-routes.json new file mode 100644 index 0000000..3b17ea3 --- /dev/null +++ b/migration/fixtures/tournament-routes.json @@ -0,0 +1,142 @@ +[ + { + "method": "POST", + "path": "/api/auth/login" + }, + { + "method": "POST", + "path": "/api/auth/logout" + }, + { + "method": "GET", + "path": "/api/auth/session" + }, + { + "method": "GET", + "path": "/api/admin/settings" + }, + { + "method": "PUT", + "path": "/api/admin/settings" + }, + { + "method": "GET", + "path": "/api/admin/display" + }, + { + "method": "PUT", + "path": "/api/admin/display" + }, + { + "method": "GET", + "path": "/api/admin/groups" + }, + { + "method": "POST", + "path": "/api/admin/groups" + }, + { + "method": "PUT", + "path": "/api/admin/groups/:id" + }, + { + "method": "DELETE", + "path": "/api/admin/groups/:id" + }, + { + "method": "GET", + "path": "/api/admin/teams" + }, + { + "method": "POST", + "path": "/api/admin/teams/randomize" + }, + { + "method": "POST", + "path": "/api/admin/teams" + }, + { + "method": "PUT", + "path": "/api/admin/teams/:id" + }, + { + "method": "DELETE", + "path": "/api/admin/teams/:id" + }, + { + "method": "POST", + "path": "/api/admin/teams/:teamId/players" + }, + { + "method": "PUT", + "path": "/api/admin/players/:id" + }, + { + "method": "DELETE", + "path": "/api/admin/players/:id" + }, + { + "method": "GET", + "path": "/api/admin/calendar" + }, + { + "method": "POST", + "path": "/api/admin/calendar/generate-preview" + }, + { + "method": "POST", + "path": "/api/admin/calendar/generate" + }, + { + "method": "DELETE", + "path": "/api/admin/calendar" + }, + { + "method": "PUT", + "path": "/api/admin/matches/:id/result" + }, + { + "method": "DELETE", + "path": "/api/admin/matches/:id/result" + }, + { + "method": "PUT", + "path": "/api/admin/rounds/:round/standings" + }, + { + "method": "GET", + "path": "/api/admin/final-stage" + }, + { + "method": "PUT", + "path": "/api/admin/final-stage/config" + }, + { + "method": "DELETE", + "path": "/api/admin/final-stage/config" + }, + { + "method": "POST", + "path": "/api/admin/final-stage/auto-seed-preview" + }, + { + "method": "PUT", + "path": "/api/admin/final-stage/seeds" + }, + { + "method": "PUT", + "path": "/api/admin/final-stage/matches/:round/:match/result" + }, + { + "method": "DELETE", + "path": "/api/admin/final-stage/matches/:round/:match/result" + }, + { + "method": "GET", + "path": "/api/public/state" + }, + { + "method": "GET", + "path": "/api/public/events" + } +] diff --git a/migration/fixtures/tournament-rows.json b/migration/fixtures/tournament-rows.json new file mode 100644 index 0000000..b87075d --- /dev/null +++ b/migration/fixtures/tournament-rows.json @@ -0,0 +1,205 @@ +{ + "display_settings": [ + { + "id": 1, + "active_panel": "classification", + "zoom_percent": 110, + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00" + } + ], + "final_match_results": [ + { + "round_index": 1, + "match_index": 1, + "score_a": 2, + "score_b": 0 + }, + { + "round_index": 1, + "match_index": 2, + "score_a": 1, + "score_b": 4 + }, + { + "round_index": 2, + "match_index": 1, + "score_a": 3, + "score_b": 2 + }, + { + "round_index": 2, + "match_index": 2, + "score_a": 1, + "score_b": 0 + } + ], + "final_seeds": [ + { + "slot_index": 1, + "team_id": 1 + }, + { + "slot_index": 2, + "team_id": 2 + }, + { + "slot_index": 3, + "team_id": 3 + }, + { + "slot_index": 4, + "team_id": 4 + } + ], + "league_groups": [ + { + "id": 1, + "name": "Grupo A", + "sort_order": 0, + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00" + } + ], + "league_matches": [ + { + "id": 1, + "order_index": 1, + "round_index": 1, + "team_a_id": 1, + "team_b_id": 4, + "score_a": 3, + "score_b": 1 + }, + { + "id": 2, + "order_index": 2, + "round_index": 1, + "team_a_id": 2, + "team_b_id": 3, + "score_a": null, + "score_b": null + }, + { + "id": 3, + "order_index": 3, + "round_index": 2, + "team_a_id": 3, + "team_b_id": 1, + "score_a": null, + "score_b": null + }, + { + "id": 4, + "order_index": 4, + "round_index": 2, + "team_a_id": 4, + "team_b_id": 2, + "score_a": null, + "score_b": null + }, + { + "id": 5, + "order_index": 5, + "round_index": 3, + "team_a_id": 1, + "team_b_id": 2, + "score_a": null, + "score_b": null + }, + { + "id": 6, + "order_index": 6, + "round_index": 3, + "team_a_id": 3, + "team_b_id": 4, + "score_a": null, + "score_b": null + } + ], + "league_rounds": [ + { + "round_index": 1, + "counts_toward_standings": 1 + }, + { + "round_index": 2, + "counts_toward_standings": 0 + }, + { + "round_index": 3, + "counts_toward_standings": 0 + } + ], + "players": [ + { + "id": 1, + "team_id": 1, + "name": "Jogador 1", + "sort_order": 0 + }, + { + "id": 2, + "team_id": 2, + "name": "Jogador 2", + "sort_order": 0 + }, + { + "id": 3, + "team_id": 3, + "name": "Jogador 3", + "sort_order": 0 + }, + { + "id": 4, + "team_id": 4, + "name": "Jogador 4", + "sort_order": 0 + } + ], + "teams": [ + { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group_id": 1, + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00" + }, + { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group_id": 1, + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00" + }, + { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group_id": 1, + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00" + }, + { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group_id": 1, + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00" + } + ], + "tournament_settings": [ + { + "id": 1, + "name": "Torneio de referência CRR", + "final_round_count": 2, + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00", + "third_place_enabled": 1, + "classification_mode": "standard" + } + ] +} diff --git a/migration/fixtures/tournament-schema.json b/migration/fixtures/tournament-schema.json new file mode 100644 index 0000000..388c6ce --- /dev/null +++ b/migration/fixtures/tournament-schema.json @@ -0,0 +1,80 @@ +[ + { + "type": "index", + "name": "idx_final_seeds_unique_team", + "tbl_name": "final_seeds", + "sql": "CREATE UNIQUE INDEX idx_final_seeds_unique_team\n ON final_seeds(team_id)\n WHERE team_id IS NOT NULL" + }, + { + "type": "index", + "name": "idx_league_matches_order", + "tbl_name": "league_matches", + "sql": "CREATE INDEX idx_league_matches_order ON league_matches(order_index)" + }, + { + "type": "index", + "name": "idx_players_team_order", + "tbl_name": "players", + "sql": "CREATE INDEX idx_players_team_order ON players(team_id, sort_order)" + }, + { + "type": "index", + "name": "idx_teams_group", + "tbl_name": "teams", + "sql": "CREATE INDEX idx_teams_group ON teams(group_id)" + }, + { + "type": "table", + "name": "display_settings", + "tbl_name": "display_settings", + "sql": "CREATE TABLE display_settings (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n active_panel TEXT NOT NULL CHECK (length(trim(active_panel)) > 0),\n zoom_percent INTEGER NOT NULL DEFAULT 100 CHECK (zoom_percent BETWEEN 50 AND 400),\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )" + }, + { + "type": "table", + "name": "final_match_results", + "tbl_name": "final_match_results", + "sql": "CREATE TABLE final_match_results (\n round_index INTEGER NOT NULL CHECK (round_index >= 1),\n match_index INTEGER NOT NULL CHECK (match_index >= 1),\n score_a INTEGER,\n score_b INTEGER,\n PRIMARY KEY (round_index, match_index),\n CHECK (\n (score_a IS NULL AND score_b IS NULL) OR\n (score_a IS NOT NULL AND score_b IS NOT NULL AND score_a >= 0 AND score_b >= 0)\n )\n )" + }, + { + "type": "table", + "name": "final_seeds", + "tbl_name": "final_seeds", + "sql": "CREATE TABLE final_seeds (\n slot_index INTEGER PRIMARY KEY CHECK (slot_index >= 1),\n team_id INTEGER,\n FOREIGN KEY (team_id) REFERENCES teams(id)\n )" + }, + { + "type": "table", + "name": "league_groups", + "tbl_name": "league_groups", + "sql": "CREATE TABLE league_groups (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK (length(trim(name)) > 0),\n sort_order INTEGER NOT NULL UNIQUE CHECK (sort_order >= 0),\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )" + }, + { + "type": "table", + "name": "league_matches", + "tbl_name": "league_matches", + "sql": "CREATE TABLE league_matches (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n order_index INTEGER NOT NULL UNIQUE CHECK (order_index >= 1),\n round_index INTEGER NOT NULL CHECK (round_index >= 1),\n team_a_id INTEGER NOT NULL,\n team_b_id INTEGER NOT NULL,\n score_a INTEGER,\n score_b INTEGER,\n CHECK (team_a_id <> team_b_id),\n CHECK (\n (score_a IS NULL AND score_b IS NULL) OR\n (score_a IS NOT NULL AND score_b IS NOT NULL AND score_a >= 0 AND score_b >= 0)\n ),\n FOREIGN KEY (team_a_id) REFERENCES teams(id),\n FOREIGN KEY (team_b_id) REFERENCES teams(id)\n )" + }, + { + "type": "table", + "name": "league_rounds", + "tbl_name": "league_rounds", + "sql": "CREATE TABLE league_rounds (\n round_index INTEGER PRIMARY KEY CHECK (round_index >= 1),\n counts_toward_standings INTEGER NOT NULL DEFAULT 0 CHECK (counts_toward_standings IN (0, 1))\n )" + }, + { + "type": "table", + "name": "players", + "tbl_name": "players", + "sql": "CREATE TABLE players (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n team_id INTEGER NOT NULL,\n name TEXT NOT NULL CHECK (length(trim(name)) > 0),\n sort_order INTEGER NOT NULL CHECK (sort_order >= 0),\n FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE\n )" + }, + { + "type": "table", + "name": "teams", + "tbl_name": "teams", + "sql": "CREATE TABLE teams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n number INTEGER NOT NULL UNIQUE CHECK (number > 0),\n name TEXT NOT NULL CHECK (length(trim(name)) > 0),\n group_id INTEGER NOT NULL REFERENCES league_groups(id),\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )" + }, + { + "type": "table", + "name": "tournament_settings", + "tbl_name": "tournament_settings", + "sql": "CREATE TABLE tournament_settings (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n name TEXT NOT NULL CHECK (length(trim(name)) > 0),\n final_round_count INTEGER CHECK (final_round_count IS NULL OR (final_round_count BETWEEN 1 AND 8)),\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n third_place_enabled INTEGER NOT NULL DEFAULT 0 CHECK (third_place_enabled IN (0, 1)),\n classification_mode TEXT NOT NULL DEFAULT 'standard' CHECK (classification_mode IN ('standard', 'total-points'))\n )" + } +] diff --git a/migration/fixtures/tournament-state.json b/migration/fixtures/tournament-state.json new file mode 100644 index 0000000..dcc3601 --- /dev/null +++ b/migration/fixtures/tournament-state.json @@ -0,0 +1,510 @@ +{ + "tournament": { + "name": "Torneio de referência CRR", + "classificationMode": "standard", + "finalRoundCount": 2, + "thirdPlaceEnabled": true + }, + "display": { + "activePanel": "classification", + "zoomPercent": 110 + }, + "groups": [ + { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + ], + "matches": [ + { + "id": 1, + "gameNumber": 1, + "roundNumber": 1, + "countsTowardStandings": true, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": 3, + "scoreB": 1 + }, + { + "id": 2, + "gameNumber": 2, + "roundNumber": 1, + "countsTowardStandings": true, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 3, + "gameNumber": 3, + "roundNumber": 2, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 4, + "gameNumber": 4, + "roundNumber": 2, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 5, + "gameNumber": 5, + "roundNumber": 3, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + }, + { + "id": 6, + "gameNumber": 6, + "roundNumber": 3, + "countsTowardStandings": false, + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "teamA": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "teamB": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "scoreA": null, + "scoreB": null + } + ], + "groupStandings": [ + { + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + }, + "rows": [ + { + "position": 1, + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "played": 1, + "wins": 1, + "draws": 0, + "losses": 0, + "goalsFor": 3, + "goalsAgainst": 1, + "goalDifference": 2, + "points": 3 + }, + { + "position": 2, + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "played": 0, + "wins": 0, + "draws": 0, + "losses": 0, + "goalsFor": 0, + "goalsAgainst": 0, + "goalDifference": 0, + "points": 0 + }, + { + "position": 3, + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "played": 0, + "wins": 0, + "draws": 0, + "losses": 0, + "goalsFor": 0, + "goalsAgainst": 0, + "goalDifference": 0, + "points": 0 + }, + { + "position": 4, + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "played": 1, + "wins": 0, + "draws": 0, + "losses": 1, + "goalsFor": 1, + "goalsAgainst": 3, + "goalDifference": -2, + "points": 0 + } + ] + } + ], + "finalStage": { + "roundCount": 2, + "thirdPlaceEnabled": true, + "rounds": [ + { + "roundIndex": 1, + "name": "Meias-finais", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 2, + "scoreB": 0, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + { + "matchIndex": 2, + "teamA": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 1, + "scoreB": 4, + "winner": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + }, + { + "roundIndex": 2, + "name": "Final", + "matches": [ + { + "matchIndex": 1, + "teamA": { + "team": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 4, + "number": 4, + "name": "Equipa 4", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 3, + "scoreB": 2, + "winner": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + } + ] + } + ], + "thirdPlaceMatch": { + "matchIndex": 2, + "teamA": { + "team": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "teamB": { + "team": { + "id": 3, + "number": 3, + "name": "Equipa 3", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "pending": false + }, + "scoreA": 1, + "scoreB": 0, + "winner": { + "id": 2, + "number": 2, + "name": "Equipa 2", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + }, + "isBye": false + }, + "champion": { + "id": 1, + "number": 1, + "name": "Equipa 1", + "group": { + "id": 1, + "name": "Grupo A", + "sortOrder": 0 + } + } + } +} diff --git a/migration/fixtures/tournament.sqlite b/migration/fixtures/tournament.sqlite new file mode 100644 index 0000000..8b64545 Binary files /dev/null and b/migration/fixtures/tournament.sqlite differ diff --git a/migration/phase2/screenshots/layout.json b/migration/phase2/screenshots/layout.json new file mode 100644 index 0000000..f8380f9 --- /dev/null +++ b/migration/phase2/screenshots/layout.json @@ -0,0 +1,5970 @@ +{ + "tournament-login-desktop.png": [ + { + "tag": "MAIN", + "class": "login-page", + "rect": [ + 0, + 0, + 1440, + 1000 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 530.594, + 313.172, + 80, + 80 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 530.594, + 437.359, + 378.813, + 52 + ] + }, + { + "tag": "FORM", + "class": "stack-form", + "rect": [ + 530.594, + 521.359, + 378.813, + 124.453 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 530.594, + 543.438, + 378.813, + 43.188 + ] + }, + { + "tag": "BUTTON", + "class": "button button--wide", + "rect": [ + 530.594, + 602.625, + 378.813, + 43.188 + ] + } + ], + "tournament-results-desktop.png": [ + { + "tag": "MAIN", + "class": "public-page", + "rect": [ + 0, + 0, + 1440, + 2593.703 + ] + }, + { + "tag": "HEADER", + "class": "public-hero", + "rect": [ + 0, + 0, + 1440, + 368 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 48, + 144, + 80, + 80 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 185.594, + 150.891, + 1042.109, + 86.406 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 65, + 514.859, + 352, + 43.188 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 48, + 666.625, + 1344, + 51.594 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 49, + 754.406, + 433.203, + 48.375 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 65, + 839.281, + 159, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 307.188, + 839.281, + 159.016, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 65, + 915.875, + 159, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 307.188, + 915.875, + 159.016, + 21 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 503.391, + 754.406, + 433.203, + 48.375 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 519.391, + 839.281, + 159, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 761.578, + 839.281, + 159.016, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 519.391, + 915.875, + 159, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 761.578, + 915.875, + 159.016, + 21 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 957.781, + 754.406, + 433.219, + 48.375 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 973.781, + 839.281, + 159.016, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 1215.984, + 839.281, + 159.016, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 973.781, + 915.875, + 159.016, + 21 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 1215.984, + 915.875, + 159.016, + 21 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 48, + 1121.156, + 1344, + 51.594 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 49, + 1208.938, + 1342, + 46.781 + ] + }, + { + "tag": "TABLE", + "class": "classification-table", + "rect": [ + 49, + 1255.719, + 1342, + 200.188 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 49, + 1255.719, + 1342, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 49, + 1255.719, + 123.469, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 172.469, + 1255.719, + 570.734, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 743.203, + 1255.719, + 66.703, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 809.906, + 1255.719, + 66.703, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 876.609, + 1255.719, + 66.703, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 943.313, + 1255.719, + 66.703, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 1010.016, + 1255.719, + 94.094, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 1104.109, + 1255.719, + 87.109, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 1191.219, + 1255.719, + 92.766, + 33.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 1283.984, + 1255.719, + 107.016, + 33.188 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 49, + 1288.906, + 1342, + 42.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__position", + "rect": [ + 49, + 1288.906, + 123.469, + 42.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__team", + "rect": [ + 172.469, + 1288.906, + 570.734, + 42.25 + ] + }, + { + "tag": "BUTTON", + "class": "classification-table__team-button", + "rect": [ + 179.656, + 1300.781, + 65.859, + 19 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 743.203, + 1288.906, + 66.703, + 42.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 809.906, + 1288.906, + 66.703, + 42.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 876.609, + 1288.906, + 66.703, + 42.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 943.313, + 1288.906, + 66.703, + 42.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1010.016, + 1288.906, + 94.094, + 42.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1104.109, + 1288.906, + 87.109, + 42.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1191.219, + 1288.906, + 92.766, + 42.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__points", + "rect": [ + 1283.984, + 1288.906, + 107.016, + 42.25 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 49, + 1331.156, + 1342, + 41.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__position", + "rect": [ + 49, + 1331.156, + 123.469, + 41.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__team", + "rect": [ + 172.469, + 1331.156, + 570.734, + 41.75 + ] + }, + { + "tag": "BUTTON", + "class": "classification-table__team-button", + "rect": [ + 179.656, + 1342.531, + 67.984, + 19 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 743.203, + 1331.156, + 66.703, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 809.906, + 1331.156, + 66.703, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 876.609, + 1331.156, + 66.703, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 943.313, + 1331.156, + 66.703, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1010.016, + 1331.156, + 94.094, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1104.109, + 1331.156, + 87.109, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1191.219, + 1331.156, + 92.766, + 41.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__points", + "rect": [ + 1283.984, + 1331.156, + 107.016, + 41.75 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 49, + 1372.906, + 1342, + 41.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__position", + "rect": [ + 49, + 1372.906, + 123.469, + 41.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__team", + "rect": [ + 172.469, + 1372.906, + 570.734, + 41.75 + ] + }, + { + "tag": "BUTTON", + "class": "classification-table__team-button", + "rect": [ + 179.656, + 1384.281, + 68.406, + 19 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 743.203, + 1372.906, + 66.703, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 809.906, + 1372.906, + 66.703, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 876.609, + 1372.906, + 66.703, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 943.313, + 1372.906, + 66.703, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1010.016, + 1372.906, + 94.094, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1104.109, + 1372.906, + 87.109, + 41.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1191.219, + 1372.906, + 92.766, + 41.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__points", + "rect": [ + 1283.984, + 1372.906, + 107.016, + 41.75 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 49, + 1414.656, + 1342, + 41.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__position", + "rect": [ + 49, + 1414.656, + 123.469, + 41.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__team", + "rect": [ + 172.469, + 1414.656, + 570.734, + 41.25 + ] + }, + { + "tag": "BUTTON", + "class": "classification-table__team-button", + "rect": [ + 179.656, + 1426.031, + 68.703, + 19 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 743.203, + 1414.656, + 66.703, + 41.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 809.906, + 1414.656, + 66.703, + 41.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 876.609, + 1414.656, + 66.703, + 41.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 943.313, + 1414.656, + 66.703, + 41.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1010.016, + 1414.656, + 94.094, + 41.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1104.109, + 1414.656, + 87.109, + 41.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 1191.219, + 1414.656, + 92.766, + 41.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__points", + "rect": [ + 1283.984, + 1414.656, + 107.016, + 41.25 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 48, + 1622.094, + 1344, + 51.594 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 81, + 1741.875, + 320, + 23 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 473, + 1741.875, + 320, + 23 + ] + }, + { + "tag": "H3", + "class": "third-place-match__title", + "rect": [ + 561.797, + 2077.719, + 316.406, + 16 + ] + } + ], + "tournament-teams-desktop.png": [ + { + "tag": "HEADER", + "class": "admin-header", + "rect": [ + 0, + 0, + 1440, + 73.594 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 57.594, + 18.703, + 35.188, + 35.188 + ] + }, + { + "tag": "BUTTON", + "class": "logout-button", + "rect": [ + 1272.813, + 20.297, + 109.594, + 32 + ] + }, + { + "tag": "MAIN", + "class": "admin-main", + "rect": [ + 112, + 73.594, + 1216, + 1307.328 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 112, + 151.375, + 584.234, + 52 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet", + "rect": [ + 1183.188, + 192.188, + 144.813, + 43.188 + ] + }, + { + "tag": "SECTION", + "class": "panel groups-panel", + "rect": [ + 112, + 259.375, + 1216, + 268.563 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 139.391, + 286.766, + 1161.219, + 24.172 + ] + }, + { + "tag": "FORM", + "class": "inline-form", + "rect": [ + 139.391, + 373.328, + 1161.219, + 65.266 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 139.391, + 395.406, + 1003.344, + 43.188 + ] + }, + { + "tag": "BUTTON", + "class": "button", + "rect": [ + 1155.531, + 395.406, + 145.078, + 43.188 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 221.047, + 466.781, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 272.672, + 466.781, + 60.297, + 22.375 + ] + }, + { + "tag": "SECTION", + "class": "panel team-create-panel", + "rect": [ + 112, + 551.938, + 1216, + 206.609 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 139.391, + 579.328, + 1161.219, + 24.172 + ] + }, + { + "tag": "FORM", + "class": "inline-form", + "rect": [ + 139.391, + 665.891, + 1161.219, + 65.266 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 139.391, + 687.969, + 856.531, + 43.188 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 1008.719, + 687.969, + 128, + 43.188 + ] + }, + { + "tag": "BUTTON", + "class": "button", + "rect": [ + 1149.516, + 687.969, + 151.094, + 43.188 + ] + }, + { + "tag": "ARTICLE", + "class": "team-card", + "rect": [ + 112, + 782.547, + 394.656, + 259.188 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 374.547, + 802.734, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 426.172, + 802.734, + 60.297, + 22.375 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 132.188, + 825.109, + 354.281, + 25.984 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 132.188, + 906.469, + 354.281, + 21.594 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 374.547, + 940.516, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 424.578, + 940.516, + 60.297, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet button--small", + "rect": [ + 132.188, + 980.953, + 135.906, + 34.391 + ] + }, + { + "tag": "ARTICLE", + "class": "team-card", + "rect": [ + 522.656, + 782.547, + 394.672, + 259.188 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 785.219, + 802.734, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 836.844, + 802.734, + 60.297, + 22.375 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 542.844, + 825.109, + 354.297, + 25.984 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 542.844, + 906.469, + 354.297, + 21.594 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 785.219, + 940.516, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 835.25, + 940.516, + 60.297, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet button--small", + "rect": [ + 542.844, + 980.953, + 135.906, + 34.391 + ] + }, + { + "tag": "ARTICLE", + "class": "team-card", + "rect": [ + 933.328, + 782.547, + 394.656, + 259.188 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 1195.875, + 802.734, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 1247.5, + 802.734, + 60.297, + 22.375 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 953.516, + 825.109, + 354.281, + 25.984 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 953.516, + 906.469, + 354.281, + 21.594 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 1195.875, + 940.516, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 1245.906, + 940.516, + 60.297, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet button--small", + "rect": [ + 953.516, + 980.953, + 135.906, + 34.391 + ] + }, + { + "tag": "ARTICLE", + "class": "team-card", + "rect": [ + 112, + 1057.734, + 394.656, + 259.188 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 374.547, + 1077.922, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 426.172, + 1077.922, + 60.297, + 22.375 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 132.188, + 1100.297, + 354.281, + 25.984 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 132.188, + 1181.656, + 354.281, + 21.594 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 374.547, + 1215.703, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 424.578, + 1215.703, + 60.297, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet button--small", + "rect": [ + 132.188, + 1256.141, + 135.906, + 34.391 + ] + } + ], + "tournament-match-cards-print-desktop.png": [ + { + "tag": "HEADER", + "class": "admin-header", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "logout-button", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "MAIN", + "class": "admin-main", + "rect": [ + 0, + 0, + 1440, + 1043.094 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "SECTION", + "class": "panel match-card-controls", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "match-card-template-option match-card-template-option--active", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "match-card-template-option", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "match-card-template-option", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "button", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "SECTION", + "class": "panel match-card-preview", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 0, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 8.188, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 0, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 8.188, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 211.641, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 219.828, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 211.641, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 219.828, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 423.281, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 431.469, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 423.281, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 431.469, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 634.922, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 643.109, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 634.922, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 643.109, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 846.563, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 854.75, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 846.563, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 854.75, + 325.484, + 35 + ] + } + ], + "shifts-login-desktop.png": [ + { + "tag": "HEADER", + "class": "topbar", + "rect": [ + 0, + 0, + 1440, + 73 + ] + }, + { + "tag": "IMG", + "class": "brand-logo", + "rect": [ + 170, + 17.5, + 46, + 41 + ] + }, + { + "tag": "MAIN", + "class": "container", + "rect": [ + 170, + 129, + 1100, + 502.781 + ] + }, + { + "tag": "SECTION", + "class": "auth-card", + "rect": [ + 505, + 129, + 430, + 502.781 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 657, + 156, + 126, + 111.172 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 534, + 285.172, + 372, + 34.547 + ] + }, + { + "tag": "FORM", + "class": "stack", + "rect": [ + 534, + 398.094, + 372, + 204.688 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 534, + 423.969, + 372, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 534, + 504.719, + 372, + 40.875 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 534, + 559.594, + 372, + 43.188 + ] + } + ], + "shifts-dashboard-desktop.png": [ + { + "tag": "HEADER", + "class": "topbar", + "rect": [ + 0, + 0, + 1440, + 73 + ] + }, + { + "tag": "IMG", + "class": "brand-logo", + "rect": [ + 170, + 17.5, + 46, + 41 + ] + }, + { + "tag": "FORM", + "class": "", + "rect": [ + 1244.563, + 27.563, + 25.438, + 20.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "link-button", + "rect": [ + 1244.563, + 27.563, + 25.438, + 20.875 + ] + }, + { + "tag": "MAIN", + "class": "container", + "rect": [ + 170, + 101, + 1100, + 3518.219 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 170, + 103.641, + 191.25, + 34.547 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 164.188, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 261.328, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 358.469, + 1100, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 501.906, + 1100, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 645.344, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 742.484, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card today", + "rect": [ + 170, + 839.625, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 936.766, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1033.906, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1131.047, + 1100, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1274.484, + 1100, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1417.922, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1515.063, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1612.203, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1709.344, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1806.484, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 1903.625, + 1100, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2047.063, + 1100, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2190.5, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2287.641, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2384.781, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2481.922, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2579.063, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2676.203, + 1100, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2819.641, + 1100, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 2963.078, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 3060.219, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 3157.359, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 3254.5, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 3351.641, + 1100, + 89.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 170, + 3448.781, + 1100, + 135.438 + ] + } + ], + "shifts-teams-desktop.png": [ + { + "tag": "HEADER", + "class": "topbar", + "rect": [ + 0, + 0, + 1440, + 73 + ] + }, + { + "tag": "IMG", + "class": "brand-logo", + "rect": [ + 170, + 17.5, + 46, + 41 + ] + }, + { + "tag": "FORM", + "class": "", + "rect": [ + 1244.563, + 27.563, + 25.438, + 20.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "link-button", + "rect": [ + 1244.563, + 27.563, + 25.438, + 20.875 + ] + }, + { + "tag": "MAIN", + "class": "container", + "rect": [ + 170, + 101, + 1100, + 857.172 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 170, + 101, + 1100, + 34.547 + ] + }, + { + "tag": "SECTION", + "class": "panel", + "rect": [ + 170, + 233.922, + 1100, + 344.313 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 189, + 259.469, + 147.406, + 22.078 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 1097.906, + 252.922, + 153.094, + 43.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid", + "rect": [ + 189, + 296.109, + 1062, + 263.125 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 321.984, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 402.734, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 483.484, + 524, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 742, + 354.859, + 494, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 193, + 542.297, + 13, + 13 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 170, + 596.234, + 1100, + 61.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 189, + 656.422, + 1062, + 320.313 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 682.297, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 763.047, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 843.797, + 524, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 742, + 715.172, + 494, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 193, + 902.609, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 731, + 902.609, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 189, + 933.547, + 87.547, + 43.188 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 170, + 671.422, + 1100, + 61.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 189, + 731.609, + 1062, + 320.313 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 757.484, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 838.234, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 918.984, + 524, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 742, + 790.359, + 494, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 193, + 977.797, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 731, + 977.797, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 189, + 1008.734, + 87.547, + 43.188 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 170, + 746.609, + 1100, + 61.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 189, + 806.797, + 1062, + 320.313 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 832.672, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 913.422, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 994.172, + 524, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 742, + 865.547, + 494, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 193, + 1052.984, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 731, + 1052.984, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 189, + 1083.922, + 87.547, + 43.188 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 170, + 821.797, + 1100, + 61.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 189, + 881.984, + 1062, + 320.313 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 907.859, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 988.609, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 1069.359, + 524, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 742, + 940.734, + 494, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 193, + 1128.172, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 731, + 1128.172, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 189, + 1159.109, + 87.547, + 43.188 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 170, + 896.984, + 1100, + 61.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 189, + 957.172, + 1062, + 320.313 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 983.047, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 1063.797, + 524, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 189, + 1144.547, + 524, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 742, + 1015.922, + 494, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 193, + 1203.359, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 731, + 1203.359, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 189, + 1234.297, + 87.547, + 43.188 + ] + } + ], + "tournament-login-mobile.png": [ + { + "tag": "MAIN", + "class": "login-page", + "rect": [ + 0, + 0, + 390, + 844 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 45, + 245.172, + 80, + 80 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 45, + 369.359, + 300, + 32 + ] + }, + { + "tag": "FORM", + "class": "stack-form", + "rect": [ + 45, + 433.359, + 300, + 124.453 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 45, + 455.438, + 300, + 43.188 + ] + }, + { + "tag": "BUTTON", + "class": "button button--wide", + "rect": [ + 45, + 514.625, + 300, + 43.188 + ] + } + ], + "tournament-results-mobile.png": [ + { + "tag": "MAIN", + "class": "public-page", + "rect": [ + 0, + 0, + 390, + 2271.266 + ] + }, + { + "tag": "HEADER", + "class": "public-hero", + "rect": [ + 0, + 0, + 390, + 272 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 20, + 60.406, + 60.797, + 60.797 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 96.797, + 65.688, + 273.203, + 70.406 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 27, + 364.469, + 336, + 43.188 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 10, + 476.234, + 370, + 32.25 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 11, + 528.672, + 368, + 48.375 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 23, + 605.953, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 232.594, + 605.953, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 23, + 667.359, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 232.594, + 667.359, + 134.406, + 17 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 11, + 720.047, + 368, + 48.375 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 23, + 797.328, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 232.594, + 797.328, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 23, + 858.734, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 232.594, + 858.734, + 134.406, + 17 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 11, + 911.422, + 368, + 48.375 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 23, + 988.703, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 232.594, + 988.703, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-team-link", + "rect": [ + 23, + 1050.109, + 134.406, + 17 + ] + }, + { + "tag": "BUTTON", + "class": "public-match__team public-match__team--away public-team-link", + "rect": [ + 232.594, + 1050.109, + 134.406, + 17 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 10, + 1167.797, + 370, + 32.25 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 11, + 1220.234, + 368, + 46.781 + ] + }, + { + "tag": "TABLE", + "class": "classification-table", + "rect": [ + 11, + 1267.016, + 672, + 186.188 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 11, + 1267.016, + 672, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 11, + 1267.016, + 58.375, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 69.375, + 1267.016, + 299.031, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 368.406, + 1267.016, + 32.953, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 401.359, + 1267.016, + 32.953, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 434.313, + 1267.016, + 32.953, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 467.266, + 1267.016, + 32.953, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 500.219, + 1267.016, + 45.094, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 545.313, + 1267.016, + 42.016, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 587.328, + 1267.016, + 44.703, + 31.188 + ] + }, + { + "tag": "TH", + "class": "", + "rect": [ + 632.031, + 1267.016, + 50.969, + 31.188 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 11, + 1298.203, + 672, + 39.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__position", + "rect": [ + 11, + 1298.203, + 58.375, + 39.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__team", + "rect": [ + 69.375, + 1298.203, + 299.031, + 39.25 + ] + }, + { + "tag": "BUTTON", + "class": "classification-table__team-button", + "rect": [ + 76.563, + 1310.078, + 56.828, + 16 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 368.406, + 1298.203, + 32.953, + 39.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 401.359, + 1298.203, + 32.953, + 39.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 434.313, + 1298.203, + 32.953, + 39.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 467.266, + 1298.203, + 32.953, + 39.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 500.219, + 1298.203, + 45.094, + 39.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 545.313, + 1298.203, + 42.016, + 39.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 587.328, + 1298.203, + 44.703, + 39.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__points", + "rect": [ + 632.031, + 1298.203, + 50.969, + 39.25 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 11, + 1337.453, + 672, + 38.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__position", + "rect": [ + 11, + 1337.453, + 58.375, + 38.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__team", + "rect": [ + 69.375, + 1337.453, + 299.031, + 38.75 + ] + }, + { + "tag": "BUTTON", + "class": "classification-table__team-button", + "rect": [ + 76.563, + 1348.828, + 58.625, + 16 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 368.406, + 1337.453, + 32.953, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 401.359, + 1337.453, + 32.953, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 434.313, + 1337.453, + 32.953, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 467.266, + 1337.453, + 32.953, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 500.219, + 1337.453, + 45.094, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 545.313, + 1337.453, + 42.016, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 587.328, + 1337.453, + 44.703, + 38.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__points", + "rect": [ + 632.031, + 1337.453, + 50.969, + 38.75 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 11, + 1376.203, + 672, + 38.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__position", + "rect": [ + 11, + 1376.203, + 58.375, + 38.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__team", + "rect": [ + 69.375, + 1376.203, + 299.031, + 38.75 + ] + }, + { + "tag": "BUTTON", + "class": "classification-table__team-button", + "rect": [ + 76.563, + 1387.578, + 58.984, + 16 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 368.406, + 1376.203, + 32.953, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 401.359, + 1376.203, + 32.953, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 434.313, + 1376.203, + 32.953, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 467.266, + 1376.203, + 32.953, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 500.219, + 1376.203, + 45.094, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 545.313, + 1376.203, + 42.016, + 38.75 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 587.328, + 1376.203, + 44.703, + 38.75 + ] + }, + { + "tag": "TD", + "class": "classification-table__points", + "rect": [ + 632.031, + 1376.203, + 50.969, + 38.75 + ] + }, + { + "tag": "TR", + "class": "", + "rect": [ + 11, + 1414.953, + 672, + 38.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__position", + "rect": [ + 11, + 1414.953, + 58.375, + 38.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__team", + "rect": [ + 69.375, + 1414.953, + 299.031, + 38.25 + ] + }, + { + "tag": "BUTTON", + "class": "classification-table__team-button", + "rect": [ + 76.563, + 1426.328, + 59.234, + 16 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 368.406, + 1414.953, + 32.953, + 38.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 401.359, + 1414.953, + 32.953, + 38.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 434.313, + 1414.953, + 32.953, + 38.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 467.266, + 1414.953, + 32.953, + 38.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 500.219, + 1414.953, + 45.094, + 38.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 545.313, + 1414.953, + 42.016, + 38.25 + ] + }, + { + "tag": "TD", + "class": "", + "rect": [ + 587.328, + 1414.953, + 44.703, + 38.25 + ] + }, + { + "tag": "TD", + "class": "classification-table__points", + "rect": [ + 632.031, + 1414.953, + 50.969, + 38.25 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 10, + 1539.391, + 370, + 32.25 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 27, + 1607.828, + 272, + 23 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 350.188, + 1607.828, + 272, + 23 + ] + }, + { + "tag": "H3", + "class": "third-place-match__title", + "rect": [ + 36.797, + 1927.672, + 316.406, + 16 + ] + } + ], + "tournament-teams-mobile.png": [ + { + "tag": "HEADER", + "class": "admin-header", + "rect": [ + 0, + 0, + 390, + 73.594 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 16, + 18.703, + 35.188, + 35.188 + ] + }, + { + "tag": "BUTTON", + "class": "logout-button", + "rect": [ + 347.672, + 20.297, + 26.328, + 32 + ] + }, + { + "tag": "MAIN", + "class": "admin-main", + "rect": [ + 10, + 73.594, + 370, + 2179.828 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 10, + 120.969, + 266.609, + 32 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet", + "rect": [ + 292.609, + 179.375, + 87.391, + 53.594 + ] + }, + { + "tag": "SECTION", + "class": "panel groups-panel", + "rect": [ + 10, + 256.969, + 370, + 378.953 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 28.594, + 275.563, + 332.813, + 24.172 + ] + }, + { + "tag": "FORM", + "class": "inline-form", + "rect": [ + 28.594, + 434.125, + 332.813, + 121.25 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 28.594, + 456.203, + 332.813, + 43.188 + ] + }, + { + "tag": "BUTTON", + "class": "button", + "rect": [ + 28.594, + 512.188, + 332.813, + 43.188 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 110.25, + 583.563, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 161.875, + 583.563, + 60.297, + 22.375 + ] + }, + { + "tag": "SECTION", + "class": "panel team-create-panel", + "rect": [ + 10, + 659.922, + 370, + 371.063 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 28.594, + 678.516, + 332.813, + 24.172 + ] + }, + { + "tag": "FORM", + "class": "inline-form", + "rect": [ + 28.594, + 813.078, + 332.813, + 199.313 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 28.594, + 835.156, + 332.813, + 43.188 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 28.594, + 913.219, + 332.813, + 43.188 + ] + }, + { + "tag": "BUTTON", + "class": "button", + "rect": [ + 28.594, + 969.203, + 332.813, + 43.188 + ] + }, + { + "tag": "ARTICLE", + "class": "team-card", + "rect": [ + 10, + 1054.984, + 370, + 271.609 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 251.078, + 1071.984, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 302.703, + 1071.984, + 60.297, + 22.375 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 27, + 1094.359, + 336, + 25.984 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 27, + 1175.719, + 336, + 21.594 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 25.406, + 1234.766, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 75.438, + 1234.766, + 60.297, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet button--small", + "rect": [ + 27, + 1275.203, + 135.906, + 34.391 + ] + }, + { + "tag": "ARTICLE", + "class": "team-card", + "rect": [ + 10, + 1342.594, + 370, + 271.609 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 251.078, + 1359.594, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 302.703, + 1359.594, + 60.297, + 22.375 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 27, + 1381.969, + 336, + 25.984 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 27, + 1463.328, + 336, + 21.594 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 25.406, + 1522.375, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 75.438, + 1522.375, + 60.297, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet button--small", + "rect": [ + 27, + 1562.813, + 135.906, + 34.391 + ] + }, + { + "tag": "ARTICLE", + "class": "team-card", + "rect": [ + 10, + 1630.203, + 370, + 271.609 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 251.078, + 1647.203, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 302.703, + 1647.203, + 60.297, + 22.375 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 27, + 1669.578, + 336, + 25.984 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 27, + 1750.938, + 336, + 21.594 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 25.406, + 1809.984, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 75.438, + 1809.984, + 60.297, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet button--small", + "rect": [ + 27, + 1850.422, + 135.906, + 34.391 + ] + }, + { + "tag": "ARTICLE", + "class": "team-card", + "rect": [ + 10, + 1917.813, + 370, + 271.609 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 251.078, + 1934.813, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 302.703, + 1934.813, + 60.297, + 22.375 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 27, + 1957.188, + 336, + 25.984 + ] + }, + { + "tag": "H3", + "class": "", + "rect": [ + 27, + 2038.547, + 336, + 21.594 + ] + }, + { + "tag": "BUTTON", + "class": "text-button", + "rect": [ + 25.406, + 2097.594, + 46.031, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "text-button text-button--danger", + "rect": [ + 75.438, + 2097.594, + 60.297, + 22.375 + ] + }, + { + "tag": "BUTTON", + "class": "button button--quiet button--small", + "rect": [ + 27, + 2138.031, + 135.906, + 34.391 + ] + } + ], + "tournament-match-cards-print-mobile.png": [ + { + "tag": "HEADER", + "class": "admin-header", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "logout-button", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "MAIN", + "class": "admin-main", + "rect": [ + 0, + 0, + 370, + 1043.094 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "SECTION", + "class": "panel match-card-controls", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "match-card-template-option match-card-template-option--active", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "match-card-template-option", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "match-card-template-option", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "button", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "SECTION", + "class": "panel match-card-preview", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 0, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 8.188, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 0, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 8.188, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 211.641, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 219.828, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 211.641, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 219.828, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 423.281, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 431.469, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 423.281, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 431.469, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 634.922, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 643.109, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 634.922, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 643.109, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 0, + 846.563, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 13, + 854.75, + 325.484, + 35 + ] + }, + { + "tag": "ARTICLE", + "class": "match-result-card", + "rect": [ + 366.594, + 846.563, + 351.484, + 196.531 + ] + }, + { + "tag": "HEADER", + "class": "match-result-card__header", + "rect": [ + 379.594, + 854.75, + 325.484, + 35 + ] + } + ], + "shifts-login-mobile.png": [ + { + "tag": "HEADER", + "class": "topbar", + "rect": [ + 0, + 0, + 390, + 73 + ] + }, + { + "tag": "IMG", + "class": "brand-logo", + "rect": [ + 14, + 20, + 40, + 36 + ] + }, + { + "tag": "MAIN", + "class": "container", + "rect": [ + 14, + 109, + 362, + 490.781 + ] + }, + { + "tag": "SECTION", + "class": "auth-card", + "rect": [ + 14, + 109, + 362, + 490.781 + ] + }, + { + "tag": "IMG", + "class": "", + "rect": [ + 132, + 130, + 126, + 111.172 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 37, + 259.172, + 316, + 34.547 + ] + }, + { + "tag": "FORM", + "class": "stack", + "rect": [ + 37, + 372.094, + 316, + 204.688 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 37, + 397.969, + 316, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 37, + 478.719, + 316, + 40.875 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 37, + 533.594, + 316, + 43.188 + ] + } + ], + "shifts-dashboard-mobile.png": [ + { + "tag": "HEADER", + "class": "topbar", + "rect": [ + 0, + 0, + 390, + 141.375 + ] + }, + { + "tag": "IMG", + "class": "brand-logo", + "rect": [ + 14, + 12, + 40, + 36 + ] + }, + { + "tag": "FORM", + "class": "", + "rect": [ + 350.563, + 19.563, + 25.438, + 20.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "link-button", + "rect": [ + 350.563, + 19.563, + 25.438, + 20.875 + ] + }, + { + "tag": "MAIN", + "class": "container", + "rect": [ + 14, + 169.375, + 362, + 3453.953 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 14, + 169.375, + 191.25, + 34.547 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 344.297, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 433.438, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 522.578, + 362, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 666.016, + 362, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 809.453, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 898.594, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card today", + "rect": [ + 14, + 987.734, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1076.875, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1166.016, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1255.156, + 362, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1398.594, + 362, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1542.031, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1631.172, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1720.313, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1809.453, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1898.594, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 1987.734, + 362, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 2131.172, + 362, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 2274.609, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 2363.75, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 2452.891, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 2542.031, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 2631.172, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 2720.313, + 362, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 2863.75, + 362, + 135.438 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 3007.188, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 3096.328, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 3185.469, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 3274.609, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 3363.75, + 362, + 81.141 + ] + }, + { + "tag": "SECTION", + "class": "day-card ", + "rect": [ + 14, + 3452.891, + 362, + 135.438 + ] + } + ], + "shifts-teams-mobile.png": [ + { + "tag": "HEADER", + "class": "topbar", + "rect": [ + 0, + 0, + 390, + 141.375 + ] + }, + { + "tag": "IMG", + "class": "brand-logo", + "rect": [ + 14, + 12, + 40, + 36 + ] + }, + { + "tag": "FORM", + "class": "", + "rect": [ + 350.563, + 19.563, + 25.438, + 20.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "BUTTON", + "class": "link-button", + "rect": [ + 350.563, + 19.563, + 25.438, + 20.875 + ] + }, + { + "tag": "MAIN", + "class": "container", + "rect": [ + 14, + 169.375, + 362, + 1005.484 + ] + }, + { + "tag": "H1", + "class": "", + "rect": [ + 14, + 169.375, + 362, + 34.547 + ] + }, + { + "tag": "SECTION", + "class": "panel", + "rect": [ + 14, + 371.859, + 362, + 463.063 + ] + }, + { + "tag": "H2", + "class": "", + "rect": [ + 29, + 393.406, + 147.406, + 22.078 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 207.906, + 386.859, + 153.094, + 43.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid", + "rect": [ + 29, + 430.047, + 332, + 389.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 455.922, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 536.672, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 617.422, + 332, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 44, + 731.047, + 302, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 802.984, + 13, + 13 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 14, + 852.922, + 362, + 53.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 29, + 909.109, + 332, + 597.5 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 934.984, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1015.734, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1096.484, + 332, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 44, + 1210.109, + 302, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1397.609, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1432.484, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 29, + 1463.422, + 87.547, + 43.188 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 14, + 920.109, + 362, + 53.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 29, + 976.297, + 332, + 597.5 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1002.172, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1082.922, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1163.672, + 332, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 44, + 1277.297, + 302, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1464.797, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1499.672, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 29, + 1530.609, + 87.547, + 43.188 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 14, + 987.297, + 362, + 53.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 29, + 1043.484, + 332, + 597.5 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1069.359, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1150.109, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1230.859, + 332, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 44, + 1344.484, + 302, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1531.984, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1566.859, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 29, + 1597.797, + 87.547, + 43.188 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 14, + 1054.484, + 362, + 53.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 29, + 1110.672, + 332, + 597.5 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1136.547, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1217.297, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1298.047, + 332, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 44, + 1411.672, + 302, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1599.172, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1634.047, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 29, + 1664.984, + 87.547, + 43.188 + ] + }, + { + "tag": "DETAILS", + "class": "panel", + "rect": [ + 14, + 1121.672, + 362, + 53.188 + ] + }, + { + "tag": "FORM", + "class": "form-grid top-space", + "rect": [ + 29, + 1177.859, + 332, + 597.5 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 0, + 0, + 0, + 0 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1203.734, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1284.484, + 332, + 40.875 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 29, + 1365.234, + 332, + 40.875 + ] + }, + { + "tag": "SELECT", + "class": "", + "rect": [ + 44, + 1478.859, + 302, + 39 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1666.359, + 13, + 13 + ] + }, + { + "tag": "INPUT", + "class": "", + "rect": [ + 33, + 1701.234, + 13, + 13 + ] + }, + { + "tag": "BUTTON", + "class": "primary", + "rect": [ + 29, + 1732.172, + 87.547, + 43.188 + ] + } + ] +} diff --git a/migration/phase2/screenshots/manifest.json b/migration/phase2/screenshots/manifest.json new file mode 100644 index 0000000..7069bd4 --- /dev/null +++ b/migration/phase2/screenshots/manifest.json @@ -0,0 +1,128 @@ +[ + { + "file": "tournament-login-desktop.png", + "path": "/admin/login", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "tournament-results-desktop.png", + "path": "/results", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "tournament-teams-desktop.png", + "path": "/admin/teams", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "tournament-match-cards-print-desktop.png", + "path": "/admin/match-cards", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "print" + }, + { + "file": "shifts-login-desktop.png", + "path": "/login", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "shifts-dashboard-desktop.png", + "path": "/", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "shifts-teams-desktop.png", + "path": "/admin/teams", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "tournament-login-mobile.png", + "path": "/admin/login", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "tournament-results-mobile.png", + "path": "/results", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "tournament-teams-mobile.png", + "path": "/admin/teams", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "tournament-match-cards-print-mobile.png", + "path": "/admin/match-cards", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "print" + }, + { + "file": "shifts-login-mobile.png", + "path": "/login", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "shifts-dashboard-mobile.png", + "path": "/", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "shifts-teams-mobile.png", + "path": "/admin/teams", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + } +] diff --git a/migration/phase2/screenshots/shifts-dashboard-desktop.png b/migration/phase2/screenshots/shifts-dashboard-desktop.png new file mode 100644 index 0000000..cec6872 Binary files /dev/null and b/migration/phase2/screenshots/shifts-dashboard-desktop.png differ diff --git a/migration/phase2/screenshots/shifts-dashboard-mobile.png b/migration/phase2/screenshots/shifts-dashboard-mobile.png new file mode 100644 index 0000000..2ff9e0f Binary files /dev/null and b/migration/phase2/screenshots/shifts-dashboard-mobile.png differ diff --git a/migration/phase2/screenshots/shifts-login-desktop.png b/migration/phase2/screenshots/shifts-login-desktop.png new file mode 100644 index 0000000..7b26c1d Binary files /dev/null and b/migration/phase2/screenshots/shifts-login-desktop.png differ diff --git a/migration/phase2/screenshots/shifts-login-mobile.png b/migration/phase2/screenshots/shifts-login-mobile.png new file mode 100644 index 0000000..7b4ec26 Binary files /dev/null and b/migration/phase2/screenshots/shifts-login-mobile.png differ diff --git a/migration/phase2/screenshots/shifts-teams-desktop.png b/migration/phase2/screenshots/shifts-teams-desktop.png new file mode 100644 index 0000000..7c5729c Binary files /dev/null and b/migration/phase2/screenshots/shifts-teams-desktop.png differ diff --git a/migration/phase2/screenshots/shifts-teams-mobile.png b/migration/phase2/screenshots/shifts-teams-mobile.png new file mode 100644 index 0000000..6d40511 Binary files /dev/null and b/migration/phase2/screenshots/shifts-teams-mobile.png differ diff --git a/migration/phase2/screenshots/tournament-login-desktop.png b/migration/phase2/screenshots/tournament-login-desktop.png new file mode 100644 index 0000000..de4b4fb Binary files /dev/null and b/migration/phase2/screenshots/tournament-login-desktop.png differ diff --git a/migration/phase2/screenshots/tournament-login-mobile.png b/migration/phase2/screenshots/tournament-login-mobile.png new file mode 100644 index 0000000..1f007a5 Binary files /dev/null and b/migration/phase2/screenshots/tournament-login-mobile.png differ diff --git a/migration/phase2/screenshots/tournament-match-cards-print-desktop.png b/migration/phase2/screenshots/tournament-match-cards-print-desktop.png new file mode 100644 index 0000000..8fda7d0 Binary files /dev/null and b/migration/phase2/screenshots/tournament-match-cards-print-desktop.png differ diff --git a/migration/phase2/screenshots/tournament-match-cards-print-mobile.png b/migration/phase2/screenshots/tournament-match-cards-print-mobile.png new file mode 100644 index 0000000..b50accf Binary files /dev/null and b/migration/phase2/screenshots/tournament-match-cards-print-mobile.png differ diff --git a/migration/phase2/screenshots/tournament-results-desktop.png b/migration/phase2/screenshots/tournament-results-desktop.png new file mode 100644 index 0000000..c5abc0f Binary files /dev/null and b/migration/phase2/screenshots/tournament-results-desktop.png differ diff --git a/migration/phase2/screenshots/tournament-results-mobile.png b/migration/phase2/screenshots/tournament-results-mobile.png new file mode 100644 index 0000000..412f1c9 Binary files /dev/null and b/migration/phase2/screenshots/tournament-results-mobile.png differ diff --git a/migration/phase2/screenshots/tournament-teams-desktop.png b/migration/phase2/screenshots/tournament-teams-desktop.png new file mode 100644 index 0000000..be67683 Binary files /dev/null and b/migration/phase2/screenshots/tournament-teams-desktop.png differ diff --git a/migration/phase2/screenshots/tournament-teams-mobile.png b/migration/phase2/screenshots/tournament-teams-mobile.png new file mode 100644 index 0000000..8131aa2 Binary files /dev/null and b/migration/phase2/screenshots/tournament-teams-mobile.png differ diff --git a/migration/phase2/verification.json b/migration/phase2/verification.json new file mode 100644 index 0000000..5920eff --- /dev/null +++ b/migration/phase2/verification.json @@ -0,0 +1,38 @@ +{ + "captures": 14, + "legacy_geometry_preserved": true, + "print_captures_byte_identical": 2, + "primary_token": "#ed1c24", + "element_counts": { + "tournament-login-desktop.png": 6, + "tournament-results-desktop.png": 87, + "tournament-teams-desktop.png": 51, + "tournament-match-cards-print-desktop.png": 37, + "shifts-login-desktop.png": 11, + "shifts-dashboard-desktop.png": 38, + "shifts-teams-desktop.png": 67, + "tournament-login-mobile.png": 6, + "tournament-results-mobile.png": 87, + "tournament-teams-mobile.png": 51, + "tournament-match-cards-print-mobile.png": 37, + "shifts-login-mobile.png": 11, + "shifts-dashboard-mobile.png": 38, + "shifts-teams-mobile.png": 67 + }, + "screenshot_sha256": { + "tournament-login-desktop.png": "806d30b3bf6f7d446eb093f4995f3ee524b531d40a9a86ed2db23539c7fafdf0", + "tournament-results-desktop.png": "7167ea009925c416cdc07f3fa876c405fb1b85484b61703471284a6220bc69f0", + "tournament-teams-desktop.png": "c6ace213e8a478df3d2f99e4789abe6c743df4659d9e0d3606fb77ae9ad40d04", + "tournament-match-cards-print-desktop.png": "763d9ddbad413d1798e0192e69394a3065ada912498eb2d60de20945b0db9dc1", + "shifts-login-desktop.png": "05eded55169cc27b425373fe1d3d08ce9d3d33d75a3698f494fdce2b556f9472", + "shifts-dashboard-desktop.png": "40fb5d149347d743fedc60c87df83ecc85f5f840e3bc69d0431f8588c3c20688", + "shifts-teams-desktop.png": "e3c5bb09c7eadefde2c29f8c195db66036df2c8f5ec4e63a8dabb62dba99e9c1", + "tournament-login-mobile.png": "760efafad09046bd4e64d012fdd79c8b78b6a51f75bda9c2602899a09e2bc934", + "tournament-results-mobile.png": "832961595e2d5442fdcde57763e4f817d40e8d06cddf7191d4e541a31fac0243", + "tournament-teams-mobile.png": "fea2d900eb41da2de2d3f71496f5cb18d4673db9adefc28a28ca8c8f01493cd7", + "tournament-match-cards-print-mobile.png": "c3d6b94e73cab499516a02b07b2f47046e86a49561679607842c324708ad5ec3", + "shifts-login-mobile.png": "663fc53f3e05344c6a7933ecb94a98725034eab2e966e08fec30d5177d2e10f1", + "shifts-dashboard-mobile.png": "7b5bc19fd34b85d1ff16df94e85c7693718c78677092b3bb802188740cf5a622", + "shifts-teams-mobile.png": "4ef9893f4369ff7665c239cdf163d65e21b9e889ddea0876cae7a4d8f741a0dd" + } +} diff --git a/migration/screenshots/manifest.json b/migration/screenshots/manifest.json new file mode 100644 index 0000000..7069bd4 --- /dev/null +++ b/migration/screenshots/manifest.json @@ -0,0 +1,128 @@ +[ + { + "file": "tournament-login-desktop.png", + "path": "/admin/login", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "tournament-results-desktop.png", + "path": "/results", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "tournament-teams-desktop.png", + "path": "/admin/teams", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "tournament-match-cards-print-desktop.png", + "path": "/admin/match-cards", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "print" + }, + { + "file": "shifts-login-desktop.png", + "path": "/login", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "shifts-dashboard-desktop.png", + "path": "/", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "shifts-teams-desktop.png", + "path": "/admin/teams", + "viewport": { + "width": 1440, + "height": 1000 + }, + "media": "screen" + }, + { + "file": "tournament-login-mobile.png", + "path": "/admin/login", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "tournament-results-mobile.png", + "path": "/results", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "tournament-teams-mobile.png", + "path": "/admin/teams", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "tournament-match-cards-print-mobile.png", + "path": "/admin/match-cards", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "print" + }, + { + "file": "shifts-login-mobile.png", + "path": "/login", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "shifts-dashboard-mobile.png", + "path": "/", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + }, + { + "file": "shifts-teams-mobile.png", + "path": "/admin/teams", + "viewport": { + "width": 390, + "height": 844 + }, + "media": "screen" + } +] diff --git a/migration/screenshots/shifts-dashboard-desktop.png b/migration/screenshots/shifts-dashboard-desktop.png new file mode 100644 index 0000000..0a548c8 Binary files /dev/null and b/migration/screenshots/shifts-dashboard-desktop.png differ diff --git a/migration/screenshots/shifts-dashboard-mobile.png b/migration/screenshots/shifts-dashboard-mobile.png new file mode 100644 index 0000000..01fe83a Binary files /dev/null and b/migration/screenshots/shifts-dashboard-mobile.png differ diff --git a/migration/screenshots/shifts-login-desktop.png b/migration/screenshots/shifts-login-desktop.png new file mode 100644 index 0000000..50fa547 Binary files /dev/null and b/migration/screenshots/shifts-login-desktop.png differ diff --git a/migration/screenshots/shifts-login-mobile.png b/migration/screenshots/shifts-login-mobile.png new file mode 100644 index 0000000..f9f4a5c Binary files /dev/null and b/migration/screenshots/shifts-login-mobile.png differ diff --git a/migration/screenshots/shifts-teams-desktop.png b/migration/screenshots/shifts-teams-desktop.png new file mode 100644 index 0000000..8bafe1d Binary files /dev/null and b/migration/screenshots/shifts-teams-desktop.png differ diff --git a/migration/screenshots/shifts-teams-mobile.png b/migration/screenshots/shifts-teams-mobile.png new file mode 100644 index 0000000..2d01b54 Binary files /dev/null and b/migration/screenshots/shifts-teams-mobile.png differ diff --git a/migration/screenshots/tournament-login-desktop.png b/migration/screenshots/tournament-login-desktop.png new file mode 100644 index 0000000..de4b4fb Binary files /dev/null and b/migration/screenshots/tournament-login-desktop.png differ diff --git a/migration/screenshots/tournament-login-mobile.png b/migration/screenshots/tournament-login-mobile.png new file mode 100644 index 0000000..1f007a5 Binary files /dev/null and b/migration/screenshots/tournament-login-mobile.png differ diff --git a/migration/screenshots/tournament-match-cards-print-desktop.png b/migration/screenshots/tournament-match-cards-print-desktop.png new file mode 100644 index 0000000..8fda7d0 Binary files /dev/null and b/migration/screenshots/tournament-match-cards-print-desktop.png differ diff --git a/migration/screenshots/tournament-match-cards-print-mobile.png b/migration/screenshots/tournament-match-cards-print-mobile.png new file mode 100644 index 0000000..b50accf Binary files /dev/null and b/migration/screenshots/tournament-match-cards-print-mobile.png differ diff --git a/migration/screenshots/tournament-results-desktop.png b/migration/screenshots/tournament-results-desktop.png new file mode 100644 index 0000000..c5abc0f Binary files /dev/null and b/migration/screenshots/tournament-results-desktop.png differ diff --git a/migration/screenshots/tournament-results-mobile.png b/migration/screenshots/tournament-results-mobile.png new file mode 100644 index 0000000..412f1c9 Binary files /dev/null and b/migration/screenshots/tournament-results-mobile.png differ diff --git a/migration/screenshots/tournament-teams-desktop.png b/migration/screenshots/tournament-teams-desktop.png new file mode 100644 index 0000000..c9ba34d Binary files /dev/null and b/migration/screenshots/tournament-teams-desktop.png differ diff --git a/migration/screenshots/tournament-teams-mobile.png b/migration/screenshots/tournament-teams-mobile.png new file mode 100644 index 0000000..905380b Binary files /dev/null and b/migration/screenshots/tournament-teams-mobile.png differ diff --git a/packages/crr-brand/README.md b/packages/crr-brand/README.md new file mode 100644 index 0000000..6df94bf --- /dev/null +++ b/packages/crr-brand/README.md @@ -0,0 +1,32 @@ +# CRR brand + +Canonical CRR visual assets and framework-independent CSS, consumed by both apps. + +- `assets/logo.png`: the original Tournament logo, copied without conversion. +- `assets/favicon.png`: the original Tournament favicon. +- `styles/tokens.css`: shared palette around `#ed1c24`, font stack, radii, + shadows, spacing and focus-ring values. +- `styles/base.css`: box sizing, body defaults, links, controls, focus and tables. +- `styles/components.css`: the existing primary/quiet button, panel and error + alert patterns used by both Vue and Jinja. Existing class names are supported + directly so templates retain their own structure. + +Load tokens, base and components **before** the application's own stylesheet. +Application CSS owns sizes, typography hierarchy, responsive layout, control +spacing, specialist badges and print-card dimensions. Local variable names are +aliases of `--crr-*` tokens, keeping existing styles/components compatible. + +Tournament resolves `@crr-brand` through Vite and uses `assets/` as its public +asset directory. Shifts mounts this directory at `/brand` using FastAPI +`StaticFiles`; Jinja uses `url_for('brand', ...)` so `ROOT_PATH` is respected. +Its PDF exporter reads this same logo. Both Docker builds copy the package from +the repository-root context, preserving its relative path to each application. + +Brand-only changes trigger both application CI jobs. This package contains no +domain code or UI framework components and has no independent public version. + +After changing branding, run both app checks/builds and `make branding-visual-check` +to compare desktop/mobile geometry and Tournament print cards against the legacy +baseline. `make migration-visual-check` compares rendered pages to the approved +Phase 2 screenshots. Baseline staging and Playwright prerequisites are documented +in `migration/README.md`. diff --git a/packages/crr-brand/assets/favicon.png b/packages/crr-brand/assets/favicon.png new file mode 100644 index 0000000..1dee4cf Binary files /dev/null and b/packages/crr-brand/assets/favicon.png differ diff --git a/packages/crr-brand/assets/logo.png b/packages/crr-brand/assets/logo.png new file mode 100644 index 0000000..3058d52 Binary files /dev/null and b/packages/crr-brand/assets/logo.png differ diff --git a/packages/crr-brand/styles/base.css b/packages/crr-brand/styles/base.css new file mode 100644 index 0000000..88baf78 --- /dev/null +++ b/packages/crr-brand/styles/base.css @@ -0,0 +1,35 @@ +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--crr-background); + color: var(--crr-text); + font-family: var(--crr-font-family); +} + +a { color: inherit; } + +button, input, select { font: inherit; } +button { cursor: pointer; } + +input, select { + width: 100%; + border: 1px solid var(--crr-control-border); + border-radius: var(--crr-radius-control); + background: var(--crr-surface); + color: var(--crr-text); +} + +input:focus, select:focus { + outline: none; + border-color: var(--crr-primary); + box-shadow: var(--crr-focus-shadow); +} + +button:focus-visible, a:focus-visible, summary:focus-visible { + outline: var(--crr-focus-outline); + outline-offset: 2px; +} + +table { width: 100%; border-collapse: collapse; } +th { text-transform: uppercase; } diff --git a/packages/crr-brand/styles/components.css b/packages/crr-brand/styles/components.css new file mode 100644 index 0000000..17353c7 --- /dev/null +++ b/packages/crr-brand/styles/components.css @@ -0,0 +1,37 @@ +.button { + border: 1px solid var(--crr-primary); + border-radius: var(--crr-radius-control); + background: var(--crr-primary); + color: #fff; + text-decoration: none; +} + +.primary { background: var(--crr-primary); color: #fff; } +.button:where(:not(.danger)):hover:not(:disabled), .primary:hover:not(:disabled) { + border-color: var(--crr-primary-hover); + background: var(--crr-primary-hover); +} + +.button--quiet, .secondary { + border-color: var(--crr-control-border); + background: transparent; + color: var(--crr-quiet-text); +} + +.button--quiet:hover:not(:disabled), .secondary:hover:not(:disabled) { + border-color: var(--crr-quiet-hover-border); + background: var(--crr-quiet-hover); + color: var(--crr-text); +} + +.panel { + border: 1px solid var(--crr-border); + border-radius: var(--crr-radius-panel); + background: var(--crr-surface); +} + +.notice--error, .flash.error { + border-color: var(--crr-danger-border); + background: var(--crr-danger-soft); + color: var(--crr-danger-dark); +} diff --git a/packages/crr-brand/styles/tokens.css b/packages/crr-brand/styles/tokens.css new file mode 100644 index 0000000..cb55942 --- /dev/null +++ b/packages/crr-brand/styles/tokens.css @@ -0,0 +1,34 @@ +:root { + --crr-primary: #ed1c24; + --crr-primary-hover: #c81018; + --crr-primary-dark: #b80f17; + --crr-brand-soft: #fff0f1; + --crr-background: #f4f4f1; + --crr-surface: #fff; + --crr-text: #18191b; + --crr-muted: #62676f; + --crr-border: #dedfe2; + --crr-control-border: #c9cbd0; + --crr-quiet-text: #363a40; + --crr-quiet-hover: #f4f4f5; + --crr-quiet-hover-border: #93989f; + --crr-danger: #b42318; + --crr-danger-dark: #8f1d14; + --crr-danger-soft: #fff4f4; + --crr-danger-border: #f1b8b8; + --crr-warning: #8a6100; + --crr-radius-control: .5rem; + --crr-radius-panel: .9rem; + --crr-radius-pill: 999px; + --crr-shadow-soft: 0 12px 36px rgb(25 25 25 / 8%); + --crr-shadow-panel: 0 5px 18px rgb(25 25 25 / 3%); + --crr-space-1: .25rem; + --crr-space-2: .5rem; + --crr-space-3: .75rem; + --crr-space-4: 1rem; + --crr-space-6: 1.5rem; + --crr-space-8: 2rem; + --crr-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --crr-focus-shadow: 0 0 0 3px rgb(237 28 36 / 14%); + --crr-focus-outline: 3px solid rgb(237 28 36 / 24%); +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..916e6eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.uv.workspace] +members = ["apps/shifts/backend"] + +# Tournament joins the Python workspace when its FastAPI backend is created. +# Shared Python infrastructure is introduced only after proven duplication. diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..674a8db --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,7 @@ +{ + "include": ["apps/shifts/backend/app", "apps/shifts/backend/tests"], + "exclude": ["originals", ".migration", "**/node_modules", "**/__pycache__"], + "venvPath": ".", + "venv": ".venv", + "pythonVersion": "3.13" +} diff --git a/tools/check_images.py b/tools/check_images.py new file mode 100644 index 0000000..5ed81ce --- /dev/null +++ b/tools/check_images.py @@ -0,0 +1,81 @@ +"""Smoke-test independently built images using ephemeral synthetic databases.""" + +import json +from html.parser import HTMLParser +import subprocess +import time +from urllib.request import urlopen +from urllib.parse import urlsplit +from uuid import uuid4 + + +class PageResources(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.urls: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + if tag in {"img", "script"} and attributes.get("src"): + self.urls.append(attributes["src"] or "") + elif tag == "link" and attributes.get("rel") in {"stylesheet", "icon"}: + self.urls.append(attributes.get("href") or "") + + +def check(image: str, port: int, environment: dict[str, str], routes: list[str], proxy_prefix: str = "") -> None: + name = f"crr-migration-{uuid4().hex[:12]}" + command = ["docker", "run", "--rm", "--detach", "--name", name, + "--tmpfs", "/data:mode=1777", "--publish", f"127.0.0.1::{port}"] + for key, value in environment.items(): + command.extend(["--env", f"{key}={value}"]) + command.append(image) + subprocess.run(command, check=True, capture_output=True, text=True) + try: + binding = subprocess.check_output(["docker", "port", name, str(port)], text=True).strip() + base = f"http://{binding}" + for _ in range(100): + try: + with urlopen(base + routes[0], timeout=1) as response: + assert response.status == 200 + break + except OSError: + time.sleep(0.1) + else: + logs = subprocess.check_output(["docker", "logs", name], text=True, stderr=subprocess.STDOUT) + raise RuntimeError(f"{image} did not become ready:\n{logs}") + for route in routes: + with urlopen(base + route, timeout=5) as response: + assert response.status == 200, route + body = response.read() + if route == "/api/public/state": + assert json.loads(body)["tournament"]["name"] == "Torneio" + elif route in {"/results", "/login"}: + assert b" None: + copies = [] + tournament = ROOT / "apps/tournament" + shifts = ROOT / "apps/shifts" + for name in ["src", "public", "index.html", "logo.png", "package.json", "package-lock.json", + "tsconfig.client.json", "tsconfig.server.json", "vite.config.ts", ".env.example"]: + copies.append((SOURCES["tournament"] / name, tournament / name)) + for name in ["app", "migrations", "tests", "alembic.ini", "requirements.txt", "requirements-dev.txt"]: + copies.append((SOURCES["shifts"] / name, shifts / "backend" / name)) + for name in ["docs", ".env.example"]: + copies.append((SOURCES["shifts"] / name, shifts / name)) + for _, destination in copies: + if destination.exists(): + raise FileExistsError(f"Refusing to overwrite {destination}") + before = {app: hashes(source) for app, source in SOURCES.items()} + for source, destination in copies: + destination.parent.mkdir(parents=True, exist_ok=True) + if source.is_dir(): + shutil.copytree(source, destination) + else: + shutil.copy2(source, destination) + assert before == {app: hashes(source) for app, source in SOURCES.items()} + print("Copied both applications into apps/; legacy snapshots are unchanged.") + + +if __name__ == "__main__": + main() diff --git a/tools/migration_baseline.py b/tools/migration_baseline.py new file mode 100644 index 0000000..e9adf3f --- /dev/null +++ b/tools/migration_baseline.py @@ -0,0 +1,84 @@ +"""Run legacy checks in isolated copies without writing into source snapshots.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[1] +SOURCES = { + "tournament": ROOT / "originals/tournment-manager-web", + "shifts": ROOT / "originals/crr-shifts", +} +IGNORED = {".git", "node_modules", ".venv", "__pycache__", ".pytest_cache", "dist"} + + +def hashes(source: Path) -> dict[str, str]: + return { + str(path.relative_to(source)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(source.rglob("*")) + if path.is_file() and not (set(path.relative_to(source).parts) & IGNORED) + } + + +def run(app: str) -> bool: + source = SOURCES[app] + before = hashes(source) + work = ROOT / ".migration" / app + results = ROOT / "migration/results" / app + results.mkdir(parents=True, exist_ok=True) + if work.exists(): + shutil.rmtree(work) + shutil.copytree(source, work, ignore=shutil.ignore_patterns(*IGNORED)) + env = os.environ.copy() + env["PYTHONDONTWRITEBYTECODE"] = "1" + commands: list[list[str]] + if app == "tournament": + commands = [ + ["npm", "ci"], + ["npm", "test", "--", "--reporter=default", "--reporter=json", + f"--outputFile={results / 'tests.json'}"], + ["npm", "run", "typecheck"], + ["npm", "run", "build"], + ] + else: + python = str(work / ".venv/bin/python") + commands = [ + ["uv", "venv", "--python", "3.13", str(work / ".venv")], + ["uv", "pip", "install", "--python", python, "-r", "requirements-dev.txt"], + [python, "-m", "pytest", "-p", "no:cacheprovider", + f"--junitxml={results / 'tests.xml'}"], + ] + checks = [] + try: + for index, command in enumerate(commands): + print(f"{app}: {' '.join(command)}", flush=True) + result = subprocess.run(command, cwd=work, env=env, capture_output=True, text=True) + (results / f"{index + 1}.log").write_text(result.stdout + result.stderr) + print(result.stdout + result.stderr, end="", flush=True) + checks.append({"command": command, "exit_code": result.returncode}) + if result.returncode and index < (1 if app == "tournament" else 2): + break + finally: + if hashes(source) != before: + raise RuntimeError(f"Legacy snapshot changed: {source}") + manifest = {"source": str(source.relative_to(ROOT)), "sha256": before, + "checks": checks} + (results / "baseline.json").write_text(json.dumps(manifest, indent=2) + "\n") + return bool(checks) and len(checks) == len(commands) and all( + check["exit_code"] == 0 for check in checks + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("app", choices=[*SOURCES, "all"], default="all", nargs="?") + args = parser.parse_args() + apps = SOURCES if args.app == "all" else [args.app] + outcomes = [run(app) for app in apps] + raise SystemExit(0 if all(outcomes) else 1) diff --git a/tools/preserve_baseline.py b/tools/preserve_baseline.py new file mode 100644 index 0000000..01843bb --- /dev/null +++ b/tools/preserve_baseline.py @@ -0,0 +1,53 @@ +"""Preserve portable baseline evidence and verify captured SQLite contents.""" + +import json +from pathlib import Path +import sqlite3 +import xml.etree.ElementTree as ET + +from migration_baseline import ROOT, SOURCES, hashes + + +def main() -> None: + output = ROOT / "migration/baseline" + output.mkdir(parents=True, exist_ok=True) + for app, source in SOURCES.items(): + results = ROOT / "migration/results" / app + manifest = json.loads((results / "baseline.json").read_text()) + assert hashes(source) == manifest["sha256"], f"Snapshot changed: {app}" + assert all(check["exit_code"] == 0 for check in manifest["checks"]) + for check in manifest["checks"]: + check["command"] = [arg.replace(str(ROOT) + "/", "") for arg in check["command"]] + if app == "tournament": + report = json.loads((results / "tests.json").read_text()) + manifest["tests"] = [ + {"name": test["fullName"], "status": test["status"]} + for suite in report["testResults"] for test in suite["assertionResults"] + ] + else: + report = ET.parse(results / "tests.xml") + manifest["tests"] = [ + {"name": f"{test.attrib['classname']}.{test.attrib['name']}", "status": "passed"} + for test in report.findall(".//testcase") + if not any(child.tag in {"failure", "error", "skipped"} for child in test) + ] + (output / f"{app}.json").write_text(json.dumps(manifest, indent=2) + "\n") + fixtures = ROOT / "migration/fixtures" + with sqlite3.connect(f"file:{fixtures / 'tournament.sqlite'}?mode=ro&immutable=1", uri=True) as db: + db.row_factory = sqlite3.Row + assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + assert db.execute("PRAGMA foreign_key_check").fetchall() == [] + expected = json.loads((fixtures / "tournament-rows.json").read_text()) + assert len(expected) == 9 + for table, rows in expected.items(): + assert table.isidentifier() + actual = [dict(row) for row in db.execute(f'SELECT * FROM "{table}" ORDER BY rowid')] + assert actual == rows, table + assert not db.execute("SELECT name FROM sqlite_master WHERE name='alembic_version'").fetchall() + artifacts = {"fixtures": hashes(fixtures), "screenshots": hashes(ROOT / "migration/screenshots")} + (output / "artifacts.json").write_text(json.dumps(artifacts, indent=2) + "\n") + print("Preserved both green baseline reports; original sources and all nine SQLite tables verified.") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9465bb2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,675 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[manifest] +members = [ + "crr-shifts", +] + +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "crr-shifts" +version = "0.1.0" +source = { virtual = "apps/shifts/backend" } +dependencies = [ + { name = "alembic" }, + { name = "email-validator" }, + { name = "fastapi" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "reportlab" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pypdf" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13,<2" }, + { name = "email-validator", specifier = ">=2.2,<3" }, + { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "itsdangerous", specifier = ">=2.2,<3" }, + { name = "jinja2", specifier = ">=3.1,<4" }, + { name = "pydantic-settings", specifier = ">=2.5,<3" }, + { name = "python-multipart", specifier = ">=0.0.12,<1" }, + { name = "reportlab", specifier = ">=4.2,<5" }, + { name = "sqlalchemy", specifier = ">=2.0,<3" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27,<1" }, + { name = "pypdf", specifier = ">=5,<7" }, + { name = "pytest", specifier = ">=8,<9" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.142.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/4f/f7c30a73127e0a8bbffe788369b8359e530b01ae06e2757936fa35bc5e6d/fastapi-0.142.2.tar.gz", hash = "sha256:06366626f2e70576367714d9ab2fe8472e6c8456dba69b399f9f797ab5e92570", size = 466721, upload-time = "2026-09-30T09:51:11.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pypdf" +version = "6.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ac/63d71aaedb59acbcdef491e6ca6469165e3771c9c74358204818fd9bc5a6/pypdf-6.19.0.tar.gz", hash = "sha256:bbc43aca292369ccc6cbc8a921991ecf2538a3587ab5a116eff06c321d647155", size = 7033266, upload-time = "2026-09-16T09:32:05.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl", hash = "sha256:7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14", size = 395480, upload-time = "2026-09-16T09:32:04.087Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "reportlab" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/3f/b3861b7e40c9d66f4a04e018958d681d16b948bfd1963c962d43a8c23f66/reportlab-4.5.1.tar.gz", hash = "sha256:9fdf68f4de9171ec66acb4a5feed8f8ca2af43479e707a6fbb0daa75d88e5494", size = 3939748, upload-time = "2026-05-12T10:14:13.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/45/ea7fad10122440de6e845568d106bffdc456ca0e8a1d8ae10b46016087e4/reportlab-4.5.1-py3-none-any.whl", hash = "sha256:06fce8cb56c83307cfa4909cdf4e6a2ddbb44e5d6ef4d2edca896d7e9769f091", size = 1957812, upload-time = "2026-05-12T10:14:10.622Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/4b/81d972a46c9f1d978af1795e2abc988855c711453552cb45d75f6e4abbf5/sqlalchemy-2.1.3.tar.gz", hash = "sha256:ade5281df06038c6394f532590d592d3381ee5d11b9e0006a2570aef41145118", size = 10525778, upload-time = "2026-10-02T22:57:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/71/68c29931fb2c5178241f7d0c63dbdd800ad65685eac70d7f2117145b5e3c/sqlalchemy-2.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:260bdd8baa1dc631d76d14b26ad009ebbb665b4469666afef8dcaa1f964016cb", size = 2457479, upload-time = "2026-10-03T02:39:19.741Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/5dbd91816b2cdb367bca3195f300ffeca916762e088d6924fc33a18f50ad/sqlalchemy-2.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fc01a8047d6a4f4e997441994f07af79fdd470fc80669751e32f91153d6b491", size = 4582199, upload-time = "2026-10-02T23:37:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/79962ce06d6cd34516def113f5170184fa9401ebddc59fd6cb18f6e919f3/sqlalchemy-2.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b1b3ff10eba0f9290142375e667351c99d24ee7fcff6e0f85bd8be9d39b50a17", size = 4632923, upload-time = "2026-10-02T23:35:55.318Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/03e98031dc9a1738c580c3bf01beeafe695f247eb647f4e8dda3fab2454f/sqlalchemy-2.1.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:efb1ebf695e80d9a69f5bbff9d812d2491fd168b27d6b42ad4c8a4d3d6faaef1", size = 4300224, upload-time = "2026-10-02T23:46:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1f/388c87607eb6c7abee4866355eeebb6221b3bcb413a64ce4a5cfc3edd22f/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a95754928d47b37ea5c4b5180101db2afabef1da0dd5cd0c573b547d5d036ff0", size = 4507170, upload-time = "2026-10-02T23:38:29.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/73/5515a3f555d31748564fbeb0b9e0b6ab7a647bf2ea0cdb6d233f20f710e3/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7606b413730ac6002930faa96f52195ed6969040b78071a3336370661f5e9e09", size = 4301439, upload-time = "2026-10-02T23:46:47.845Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a0/0eae9b96f6f8a09f3e5d388f6fc5d6fe8d5ff2aced2a5c75bea7333d445e/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:76c3d5c7ada0925ff12e8c2395c547f88fcfccc13368ff8e8dd55ab5e28dcdab", size = 4594441, upload-time = "2026-10-03T02:46:16.928Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/b6d681015414ce9048c07ef3ac49b693c1b8373de4716ee4bdeff4ec19a0/sqlalchemy-2.1.3-cp313-cp313-win32.whl", hash = "sha256:cb6fc5c856e8f1c0228bf74b636c0a03a6c12dd502ced7aa82ced21dcdc7f547", size = 2371547, upload-time = "2026-10-03T02:49:23.549Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/35aa4d737016640c6da7573a4bcd9d91b59a87359bc6df075627963a4781/sqlalchemy-2.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:82d4c63d338737d593f1d29c4dae0a124577a9a76186d90b5c0f9ec9cbeeab35", size = 2421558, upload-time = "2026-10-03T02:49:25.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/42/0dcc7cd92a3f4773376a5488b6ce0d6d75d2d029ad7e7f9e0ecc5fb48fcd/sqlalchemy-2.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:0ab6f42761cc5c48d2f3e8a70810d10630d8222a51a7111cf9b3a48f22c3f148", size = 2382507, upload-time = "2026-10-03T02:42:42.812Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/23d8fb7ed90e5b674006ace415ede1c60cdfc38ace50968b59ef42c22754/sqlalchemy-2.1.3-py3-none-any.whl", hash = "sha256:6f0cf4debd86cb5623a50310784bd005c215761ca7f618d0d4f46193d033675a", size = 2052603, upload-time = "2026-10-03T02:34:19.115Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, +] + +[[package]] +name = "websockets" +version = "17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" }, + { url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" }, + { url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" }, + { url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" }, + { url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" }, +]