Compare commits

42 Commits

Author SHA1 Message Date
1513bb3658 Split e2e tests into manual workflow_dispatch workflow
All checks were successful
CI / frontend-tests (push) Successful in 27s
CI / backend-tests (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:54:25 +01:00
3b63285bd1 Fix FK violations when pruning stale routes
Some checks failed
CI / backend-tests (push) Successful in 26s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Has been cancelled
Bulk delete bypasses ORM-level cascades, so manually delete
route_encounters, nullify boss_battle.after_route_id, and skip
routes referenced by user encounters before deleting stale routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:50:54 +01:00
4f0f881736 Update remaining FireRed boss sprites
All checks were successful
CI / backend-tests (push) Successful in 25s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Successful in 5m29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:39:14 +01:00
dde20c932b Update Brock and Misty boss sprites
Some checks failed
CI / backend-tests (push) Successful in 26s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:37:29 +01:00
efa0b5f855 Add --prune flag to seed command to remove stale data
Without --prune, seeds continue to only upsert (add/update).
With --prune, routes, encounters, and bosses not present in the
seed JSON files are deleted from the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:33:54 +01:00
d535433583 Archive 23 completed beans
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:59:54 +01:00
bf4302cdd4 Use host IP for backend test database URL in CI
All checks were successful
CI / backend-tests (push) Successful in 25s
CI / frontend-tests (push) Successful in 26s
CI / e2e-tests (push) Successful in 4m58s
The Postgres service container is not reachable via localhost from
inside the act runner container. Use the Docker host IP instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:49:04 +01:00
9a8a4f75f9 Use uv run for backend tests instead of system pip install
Some checks failed
CI / backend-tests (push) Failing after 1m13s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Has been cancelled
The uv-managed Python is externally managed and rejects --system pip
installs. Use uv run --extra dev to handle venv creation, dependency
installation, and test execution in a single step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:43:35 +01:00
00734ee233 Use host IP for e2e test API in CI
Some checks failed
CI / backend-tests (push) Failing after 26s
CI / frontend-tests (push) Successful in 29s
CI / e2e-tests (push) Successful in 5m37s
The act runner executes steps inside a container where localhost does
not reach the Docker host. Use E2E_API_URL env var (set to the host IP
192.168.1.10:8100 in CI) so both the global setup and Vite proxy can
reach the test API container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:38:29 +01:00
b50e9160ba Add uv to PATH after install in CI
The uv installer places the binary in ~/.local/bin which isn't on
PATH by default in the act runner. Source the env file for the current
step and append to GITHUB_PATH for subsequent steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:32:51 +01:00
f6bcb1fbe5 Fix CI failures for backend and e2e test jobs
Some checks failed
CI / backend-tests (push) Failing after 9s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Failing after 2m6s
Replace astral-sh/setup-uv action with direct curl install to avoid
Node.js 18 incompatibility (setup-uv v6+ requires Node 20+). Change
e2e test API host port from 8000 to 8100 to avoid conflict with
existing service on the CI runner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:29:04 +01:00
bf3a3d3329 Replace CI lint jobs with backend, frontend, and e2e test jobs
Some checks failed
CI / backend-tests (push) Failing after 37s
CI / frontend-tests (push) Successful in 28s
CI / e2e-tests (push) Failing after 1m42s
Lint, formatting, and type checks are already enforced by prek pre-commit
hooks, so CI now focuses on running the actual test suites instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:13:34 +01:00
9aaa95a1c7 Add component tests for EndRunModal, GameGrid, RulesConfiguration, Layout
33 tests covering rendering, user interactions (userEvent clicks), prop
callbacks, filter state, and conditional description text. Adds a
matchMedia stub to the vitest setup file so components importing
useTheme don't throw in jsdom. Also adds actionlint and zizmor
pre-commit hooks for GitHub Actions linting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:57:12 +01:00
0d2f419c6a Add unit tests for frontend utilities and hooks
82 tests covering download.ts and all React Query hooks. API modules are
mocked with vi.mock; mutation tests spy on queryClient.invalidateQueries
to verify cache invalidation. Conditional queries (null id) are verified
to stay idle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:47:55 +01:00
c80d7d0802 Set up frontend test infrastructure
Install @testing-library/react, @testing-library/jest-dom,
@testing-library/user-event, and jsdom. Configure Vitest with globals,
jsdom environment, and a setup file importing jest-dom matchers. Add a
custom render helper wrapping components with QueryClientProvider and
MemoryRouter. Exclude e2e/ from vitest. Smoke test covers
formatEvolutionMethod.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:35:15 +01:00
ee5bf03f19 Add integration tests for Genlockes & Bosses API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:21:32 +01:00
34835abe0c Add integration tests for Pokemon & Evolutions API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:15:00 +01:00
ca736e0f39 Add unit tests for services layer
36 tests covering build_families (linear chains, branching, disjoint,
Shedinja case), resolve_base_form, to_roman (parametrized), and
strip_roman_suffix including round-trip verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:05:24 +01:00
d6a0b60585 Add integration tests for Runs & Encounters API
28 tests covering run CRUD, rules JSONB storage, encounter creation,
route-lock enforcement, shinyClause and giftClause bypasses, status
transitions (complete/fail), and encounter update/delete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:58:28 +01:00
79eabf4f9f Add integration tests for Games & Routes API
25 tests covering game CRUD (create/list/get/update/delete), slug
uniqueness enforcement, by-region grouping, and route operations
(create/update/delete/reorder). Verifies that list_game_routes
excludes routes with no Pokemon encounters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:51:37 +01:00
4aae12cd72 Add unit tests for Pydantic schemas
46 tests across 12 schema classes covering CamelModel alias generation,
required field validation, optional field defaults, camelCase input/output,
nested model coercion, and from_attributes support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:41:22 +01:00
b0ac3714a9 Set up backend test infrastructure
Add pytest fixtures (engine, db_session, client) with session-scoped
event loop to avoid asyncpg loop mismatch errors. Smoke tests verify
all three main API endpoints return empty results on a clean DB.
Test DB provided by docker-compose.test.yml on port 5433.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:35:22 +01:00
16f9e68821 Mark Overhaul Nuzlocke Rules System epic as completed
All child features are done.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:23:37 +01:00
993ad09d9c Add type restriction rule (monolocke)
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 22s
Adds allowedTypes: string[] to NuzlockeRules. When set, the encounter
selector hides non-matching Pokemon and the routes endpoint filters out
routes with no matching encounters, so only eligible locations appear.

Type picker UI in RulesConfiguration; active restriction shown in
RuleBadges. Backend accepts allowed_types query param and joins through
RouteEncounter.pokemon to filter by type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:22:05 +01:00
85fef68dae Add static clause rule for encounter selector filtering
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 23s
When disabled, static encounters (legendaries, scripted Pokémon) are
grayed out and unselectable in the encounter selector. Enabled by default.
Adds 'static' to METHOD_CONFIG/METHOD_ORDER with a teal badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:04:39 +01:00
aea5d1d84d Update bean 2026-02-20 22:03:52 +01:00
347c25e8ed Add boss team match playstyle rule
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 16s
CI / frontend-lint (push) Successful in 21s
When enabled, the sticky boss banner shows the next boss's team size
as a hint for players who voluntarily match the boss's party count.
Handles variant boss teams by using the auto-detected starter variant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:03:11 +01:00
6968d35a33 Fix boss banner sticking behind nav header on scroll
The sticky level cap banner had z-10 and top-0, placing it behind the
nav (z-40) and overlapping it. Use top-14 to clear the nav height and
z-30 to layer correctly below the nav but above page content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:59:46 +01:00
18cc116348 Add gift clause rule for free gift encounters
When enabled, in-game gift Pokemon (starters, trades, fossils) do not
count against a location's encounter limit. Both a gift encounter and
a regular encounter can coexist on the same route, in any order.

Persists encounter origin on the Encounter model so the backend can
exclude gift encounters from route-lock checks bidirectionally, and the
frontend can split them into a separate display layer that doesn't lock
the route for regular encounters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:55:16 +01:00
ed1f7ad3d0 Increase encounter method badge sizes for readability
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 21s
Bump xs from 8px to 10px and sm from 9px to 12px so the route-list
badges (Grass, Surfing, Gift, etc.) are legible at a glance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:35:54 +01:00
2298c32691 Add egglocke, wonderlocke, and randomizer variant rules
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 21s
When any variant rule is enabled, the encounter modal switches from
the game's regional dex to an all-Pokemon search (same debounced
API pattern as EggEncounterModal). A new "Run Variant" section in
rules configuration groups these rules, and badges render in amber.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:33:01 +01:00
e25d1cf24c Remove unused nuzlocke rules, reorganize into core and playstyle
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 21s
Remove firstEncounterOnly, permadeath, nicknameRequired, and
postGameCompletion from the rules system — they are either implicit
(it's a nuzlocke tracker) or not enforced. Move levelCaps to core
(it's displayed in the sticky bar). Create a new "playstyle" category
for hardcoreMode and setModeOnly — informational rules useful for
stats but not enforced by the tracker. Remove the completion category
entirely. Add sub-task beans for the rules overhaul epic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:20:23 +01:00
4fbfcf9b29 Fix WCAG AA color contrast violations across all pages
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 21s
Replace incorrect perceived-brightness formula in Stats progress bars
with proper WCAG relative luminance calculation, and convert type bar
colors to hex values for reliable contrast detection. Add light: variant
classes to status badges, yellow/purple text, and admin nav links across
17 files. Darken light-mode status-active token and text-tertiary/muted
tokens. Add aria-labels to admin filter selects and flex-wrap for mobile
overflow on AdminEvolutions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:48:16 +01:00
a12478f24b Fix e2e tests for ESM and podman-compose compatibility
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 21s
Replace __dirname with import.meta.url (required by "type": "module").
Replace --wait flag with manual health polling (unsupported by
podman-compose). Use explicit -p project name to isolate test
containers from dev environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:19:17 +01:00
a7ec49fcad Add Playwright accessibility and mobile layout e2e tests
All checks were successful
CI / backend-lint (push) Successful in 49s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 1m2s
Set up end-to-end test infrastructure with Docker Compose test
environment, Playwright config, and automated global setup/teardown
that seeds a test database and creates fixtures via the API.

Tests cover 11 pages across both dark/light themes for WCAG 2.0 AA
accessibility (axe-core), and across 3 viewports (mobile, tablet,
desktop) for horizontal overflow and touch target validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:08:17 +01:00
a381633413 Add dark/light mode toggle with adaptive badge colors
Implement theme switching via sun/moon toggle in nav bar. Dark
remains the default; light mode overrides surface, text, border,
accent, and status color tokens. Preference persists in localStorage
and falls back to prefers-color-scheme. An inline script in
index.html prevents flash of wrong theme on load.

Define a Tailwind v4 @custom-variant for light mode and update all
badge components (encounter method, rule, condition) to use
light:bg-{color}-100 / light:text-{color}-700 for readable contrast
on light surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:45:12 +01:00
cb35bf161e Update bean 2026-02-20 19:27:46 +01:00
Julian Tabel
7ec43431e5 Add epic: Overhaul Nuzlocke Rules System
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:28:50 +01:00
Julian Tabel
4d097158bd Add new epic 2026-02-19 08:44:05 +01:00
92dad22981 Simplify modal, badge, and component styles to dark-first (#29)
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 16s
CI / frontend-lint (push) Successful in 20s
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-02-17 21:08:53 +01:00
42b66ee9a2 Implement dark-first design system with Geist typography (#28)
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 16s
CI / frontend-lint (push) Successful in 21s
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-02-17 20:48:42 +01:00
e3b3dc5317 Rebrand to Another Nuzlocke Tracker (ANT) (#27)
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 21s
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-02-17 20:17:07 +01:00
149 changed files with 8313 additions and 1802 deletions

View File

@@ -1,30 +0,0 @@
---
# nuzlocke-tracker-0arz
title: Integration tests for Runs & Encounters API
status: draft
type: task
created_at: 2026-02-10T09:33:21Z
updated_at: 2026-02-10T09:33:21Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the core run tracking and encounter API endpoints. This is the heart of the application.
## Checklist
- [ ] Test run CRUD operations (create, list, get, update, delete)
- [ ] Test run creation with rules configuration (JSONB field)
- [ ] Test encounter logging on a run (create encounter on a route)
- [ ] Test encounter status changes (alive → dead, alive → retired, etc.)
- [ ] Test duplicate encounter prevention (dupes clause logic)
- [ ] Test shiny encounter handling
- [ ] Test egg encounter handling
- [ ] Test ending a run (completion/failure)
- [ ] Test error cases (encounter on invalid route, duplicate route encounters, etc.)
## Notes
- Run endpoints: `backend/src/app/api/runs.py`
- Encounter endpoints: `backend/src/app/api/encounters.py`
- This is the most critical area — Nuzlocke rules enforcement should be thoroughly tested
- Tests need game + pokemon + route fixtures as prerequisites

View File

@@ -1,30 +0,0 @@
---
# nuzlocke-tracker-1guz
title: Component tests for key frontend components
status: draft
type: task
created_at: 2026-02-10T09:33:45Z
updated_at: 2026-02-10T09:33:45Z
parent: nuzlocke-tracker-yzpb
---
Write component tests for the most important frontend React components, focusing on user interactions and rendering correctness.
## Checklist
- [ ] Test `EncounterModal` — form submission, validation, Pokemon selection
- [ ] Test `StatusChangeModal` — status transitions, confirmation flow
- [ ] Test `EndRunModal` — run completion/failure flow
- [ ] Test `GameGrid` — game selection rendering, click handling
- [ ] Test `RulesConfiguration` — rules toggle interactions, state management
- [ ] Test `Layout` — navigation rendering, responsive behavior
- [ ] Test admin form modals (GameFormModal, RouteFormModal, PokemonFormModal) — CRUD form flows
- [ ] Test `AdminTable` — sorting, filtering, action buttons
## Notes
- Focus on user-facing behavior, not implementation details
- Use @testing-library/user-event for simulating clicks, typing, etc.
- Mock API responses for components that fetch data
- Don't aim for 100% coverage — prioritise the most complex/interactive components
- Page components (RunEncounters, RunDashboard, etc.) are large and complex — consider testing their sub-components instead

View File

@@ -1,30 +0,0 @@
---
# nuzlocke-tracker-9c66
title: Integration tests for Genlockes & Bosses API
status: draft
type: task
created_at: 2026-02-10T09:33:26Z
updated_at: 2026-02-10T09:33:26Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the genlocke challenge and boss battle API endpoints.
## Checklist
- [ ] Test genlocke CRUD operations (create, list, get, update, delete)
- [ ] Test leg management (add/remove legs to a genlocke)
- [ ] Test Pokemon transfers between genlocke legs
- [ ] Test boss battle CRUD (create, list, update, delete per game)
- [ ] Test boss battle results per run (record win/loss)
- [ ] Test stats endpoint for run statistics
- [ ] Test export endpoint
- [ ] Test error cases (invalid transfers, boss results for wrong game, etc.)
## Notes
- Genlocke endpoints: `backend/src/app/api/genlockes.py`
- Boss endpoints: `backend/src/app/api/bosses.py`
- Stats endpoints: `backend/src/app/api/stats.py`
- Export endpoints: `backend/src/app/api/export.py`
- Genlocke tests require multiple runs as fixtures

View File

@@ -1,25 +0,0 @@
---
# nuzlocke-tracker-9c8d
title: Rebrand to Another Nuzlocke Tracker (ANT)
status: todo
type: task
priority: normal
created_at: 2026-02-10T14:46:09Z
updated_at: 2026-02-10T14:46:56Z
---
Adopt the new branding: **Another Nuzlocke Tracker**, abbreviated **ANT**.
## Context
- No existing Nuzlocke tracker uses this name or acronym.
- The name is self-deprecating/playful ("yet another...") and the acronym opens up mascot/logo possibilities (ant character).
- **Durant** (Steel/Bug, Gen V) is the mascot Pokémon — an actual ant Pokémon that ties the ANT acronym directly into the Pokémon universe.
## Checklist
- [ ] Update project name in package.json / config files
- [ ] Update page titles, meta tags, and any visible app name references
- [ ] Update README and any documentation with the new name
- [ ] Design or source a Durant-themed logo/icon
- [ ] Update favicon and app icons

View File

@@ -1,26 +0,0 @@
---
# nuzlocke-tracker-ch77
title: Integration tests for Games & Routes API
status: draft
type: task
created_at: 2026-02-10T09:33:13Z
updated_at: 2026-02-10T09:33:13Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the games and routes API endpoints in `backend/src/app/api/games.py`.
## Checklist
- [ ] Test CRUD operations for games (create, list, get, update, delete)
- [ ] Test route management within a game (create, list, reorder, update, delete)
- [ ] Test route encounter management (add/remove Pokemon to routes)
- [ ] Test bulk import functionality
- [ ] Test region grouping/filtering
- [ ] Test error cases (404 for missing games, validation errors, duplicate handling)
## Notes
- Use the httpx AsyncClient fixture from the test infrastructure task
- Each test should be independent — use fixtures to set up required data
- Test both success and error response codes and bodies

View File

@@ -1,33 +0,0 @@
---
# nuzlocke-tracker-d8cp
title: Set up frontend test infrastructure
status: draft
type: task
priority: normal
created_at: 2026-02-10T09:33:33Z
updated_at: 2026-02-10T09:34:00Z
parent: nuzlocke-tracker-yzpb
blocking:
- nuzlocke-tracker-ee9s
- nuzlocke-tracker-1guz
---
Set up the test infrastructure for the React/TypeScript frontend. No testing tooling currently exists.
## Checklist
- [ ] Install Vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom
- [ ] Configure Vitest in `vite.config.ts` or a dedicated `vitest.config.ts`
- [ ] Set up jsdom as the test environment
- [ ] Create a test setup file (e.g. `src/test/setup.ts`) that imports @testing-library/jest-dom matchers
- [ ] Create test utility helpers (e.g. render wrapper with providers — QueryClientProvider, BrowserRouter)
- [ ] Add a \`test\` script to package.json
- [ ] Verify the setup by writing a simple smoke test
- [ ] Set up MSW (Mock Service Worker) or a similar API mocking strategy for hook/component tests
## Notes
- Vitest integrates natively with Vite, which the project already uses
- React Testing Library is the standard for testing React components
- The app uses React Query (TanStack Query) and React Router — the test wrapper needs to provide these contexts
- MSW is recommended for mocking API calls in hook and component tests, but simpler approaches (vi.mock) may suffice initially

View File

@@ -0,0 +1,19 @@
---
# nuzlocke-tracker-ecn3
title: Prune stale seed data during seeding
status: completed
type: bug
priority: normal
created_at: 2026-02-21T16:28:37Z
updated_at: 2026-02-21T16:29:43Z
---
Seeds only upsert (add/update), they never remove routes, encounters, or bosses that no longer exist in the seed JSON. When routes are renamed, old route names persist in production.
## Fix
After upserting each entity type, delete rows not present in the seed data:
1. **Routes**: After upserting all routes for a version group, delete routes whose names are not in the seed set. FK cascades handle child routes and encounters.
2. **Encounters**: After upserting encounters for a route+game, delete encounters not in the seed data for that route+game pair.
3. **Bosses**: After upserting bosses for a version group, delete bosses with order values beyond what the seed provides.

View File

@@ -1,31 +0,0 @@
---
# nuzlocke-tracker-ee9s
title: Unit tests for frontend utilities and hooks
status: draft
type: task
created_at: 2026-02-10T09:33:38Z
updated_at: 2026-02-10T09:33:38Z
parent: nuzlocke-tracker-yzpb
---
Write unit tests for the frontend utility functions and custom React hooks.
## Checklist
- [ ] Test `utils/formatEvolution.ts` — evolution chain formatting logic
- [ ] Test `utils/download.ts` — file download utility
- [ ] Test `hooks/useRuns.ts` — run CRUD hook with mocked API
- [ ] Test `hooks/useGames.ts` — game fetching hook
- [ ] Test `hooks/useEncounters.ts` — encounter operations hook
- [ ] Test `hooks/usePokemon.ts` — pokemon data hook
- [ ] Test `hooks/useGenlockes.ts` — genlocke operations hook
- [ ] Test `hooks/useBosses.ts` — boss operations hook
- [ ] Test `hooks/useStats.ts` — stats fetching hook
- [ ] Test `hooks/useAdmin.ts` — admin operations hook
## Notes
- Utility functions are pure functions — straightforward to test
- Hooks wrap React Query — test that they call the right API endpoints, handle loading/error states, and invalidate queries correctly
- Use `@testing-library/react`'s `renderHook` for hook testing
- Mock the API client (from `src/api/`) rather than individual fetch calls

View File

@@ -1,28 +0,0 @@
---
# nuzlocke-tracker-hjkk
title: Unit tests for Pydantic schemas and model validation
status: draft
type: task
created_at: 2026-02-10T09:33:03Z
updated_at: 2026-02-10T09:33:03Z
parent: nuzlocke-tracker-yzpb
---
Write unit tests for the Pydantic schemas in `backend/src/app/schemas/`. These are pure validation logic and can be tested without a database.
## Checklist
- [ ] Test `CamelModel` base class (snake_case → camelCase alias generation)
- [ ] Test run schemas — creation validation, required fields, optional fields, serialization
- [ ] Test game schemas — validation rules, field constraints
- [ ] Test encounter schemas — status enum validation, field dependencies
- [ ] Test boss schemas — nested model validation
- [ ] Test genlocke schemas — complex nested structures
- [ ] Test stats schemas — response model structure
- [ ] Test evolution schemas — validation of evolution chain data
## Notes
- Focus on: valid input acceptance, invalid input rejection, serialization output format
- The `CamelModel` base class does alias generation — verify both input (camelCase) and output (camelCase) work
- Test edge cases like empty strings, negative numbers, missing required fields

View File

@@ -1,24 +0,0 @@
---
# nuzlocke-tracker-iam7
title: Unit tests for services layer
status: draft
type: task
created_at: 2026-02-10T09:33:08Z
updated_at: 2026-02-10T09:33:08Z
parent: nuzlocke-tracker-yzpb
---
Write unit tests for the business logic in `backend/src/app/services/`. Currently this is the `families.py` service which handles Pokemon evolution family resolution.
## Checklist
- [ ] Test family resolution with simple linear evolution chains (e.g. A → B → C)
- [ ] Test family resolution with branching evolutions (e.g. Eevee)
- [ ] Test family resolution with region-specific evolutions
- [ ] Test edge cases: single-stage Pokemon, circular references (if possible), missing data
## Notes
- `services/families.py` contains the core logic for resolving Pokemon evolution families
- These tests may need mock database sessions or in-memory data depending on how the service queries data
- If the service methods take a DB session, mock it; if they operate on data objects, pass test data directly

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-m8ki
title: Split e2e tests into manual workflow
status: completed
type: task
priority: normal
created_at: 2026-02-21T16:53:37Z
updated_at: 2026-02-21T16:54:04Z
---
Remove e2e-tests job from ci.yml and create a new e2e.yml workflow with workflow_dispatch trigger only.

View File

@@ -0,0 +1,32 @@
---
# nuzlocke-tracker-mz16
title: Session Journal / Blog Posts
status: draft
type: epic
created_at: 2026-02-19T07:43:05Z
updated_at: 2026-02-19T07:43:05Z
---
Let users tell the story of their nuzlocke run through session journal entries (blog posts).
Nuzlockes are inherently story-driven — encounters you first think are weak become the star of the show, a needed sacrifice lets the run survive, one crit in a boss battle means defeat. Users should be able to capture those moments.
## Concept
For each play session, users can write a short post to document what happened. Posts can:
- Include rich text / markdown content
- Embed screenshots and images
- Automatically link to their current team (or a subset of it)
- Reference deaths, new encounters, and other run events
- Be tied to a specific run
The journal becomes a chronological narrative of the nuzlocke run, with game data woven in automatically.
## Open Questions
- [ ] What editor experience? (Markdown, rich text, block editor?)
- [ ] How are images stored? (Local uploads, external links, cloud storage?)
- [ ] What run events can be linked/embedded? (Team snapshots, deaths, catches, badge progress?)
- [ ] Should posts be publishable/shareable, or private by default?
- [ ] How does the journal UI look? Timeline view? Blog-style list?

View File

@@ -1,36 +0,0 @@
---
# nuzlocke-tracker-rrcf
title: Set up backend test infrastructure
status: draft
type: task
priority: normal
created_at: 2026-02-10T09:32:57Z
updated_at: 2026-02-10T09:33:59Z
parent: nuzlocke-tracker-yzpb
blocking:
- nuzlocke-tracker-hjkk
- nuzlocke-tracker-iam7
- nuzlocke-tracker-ch77
- nuzlocke-tracker-ugb7
- nuzlocke-tracker-0arz
- nuzlocke-tracker-9c66
---
Set up the foundational test infrastructure for the FastAPI backend so that all subsequent test tasks can build on it.
## Checklist
- [ ] Create `backend/tests/conftest.py` with shared fixtures
- [ ] Set up a test database strategy (use a separate test PostgreSQL database or SQLite for speed — evaluate trade-offs)
- [ ] Create an async test client fixture using `httpx.AsyncClient` with the FastAPI `app`
- [ ] Create a database session fixture that creates/drops tables per test session or uses transactions for isolation
- [ ] Add factory fixtures or helpers for creating common test entities (games, pokemon, runs, etc.)
- [ ] Verify the setup works by writing a simple smoke test (e.g. health endpoint returns 200)
- [ ] Document how to run tests (e.g. `pytest` from backend dir, any env vars needed)
## Notes
- pytest, pytest-asyncio, and httpx are already in pyproject.toml dev dependencies
- AsyncIO mode is set to "auto" in pyproject.toml
- The app uses SQLAlchemy async with asyncpg — test fixtures need to handle async session management
- Consider using `SAVEPOINT`-based transaction rollback for test isolation (faster than recreating tables)

View File

@@ -1,25 +0,0 @@
---
# nuzlocke-tracker-ugb7
title: Integration tests for Pokemon & Evolutions API
status: draft
type: task
created_at: 2026-02-10T09:33:16Z
updated_at: 2026-02-10T09:33:16Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the Pokemon and evolutions API endpoints.
## Checklist
- [ ] Test Pokemon CRUD operations (create, list, search, update, delete)
- [ ] Test Pokemon filtering and search
- [ ] Test evolution chain CRUD (create, list, get, update, delete)
- [ ] Test evolution family resolution endpoint
- [ ] Test error cases (invalid Pokemon references, circular evolutions, etc.)
## Notes
- Pokemon endpoints are in `backend/src/app/api/pokemon.py`
- Evolution endpoints are in `backend/src/app/api/evolutions.py`
- Evolution tests should cover multi-stage and branching chains

View File

@@ -1,28 +0,0 @@
---
# nuzlocke-tracker-yzpb
title: Implement Unit & Integration Tests
status: draft
type: epic
priority: high
created_at: 2026-02-10T09:32:47Z
updated_at: 2026-02-10T12:05:43Z
---
Add comprehensive unit and integration test coverage to both the backend (FastAPI/Python) and frontend (React/TypeScript). The project currently has zero tests — pytest is configured in pyproject.toml with pytest-asyncio and httpx, but no actual test files exist. The frontend has no test tooling at all.
**Scope:**
- Unit tests for isolated logic (schemas, services, utilities)
- Integration tests for API endpoints (using httpx AsyncClient against a test database)
- Frontend unit/component tests (using Vitest + React Testing Library)
**Explicitly out of scope:**
- End-to-end / browser tests (e.g. Selenium, Playwright) — requires specialised infrastructure
## Success Criteria
- [ ] Backend test infrastructure is set up (conftest, fixtures, test DB)
- [ ] Backend schemas and services have unit test coverage
- [ ] Backend API endpoints have integration test coverage
- [ ] Frontend test infrastructure is set up (Vitest, RTL)
- [ ] Frontend utilities and hooks have unit test coverage
- [ ] Frontend components have basic render/interaction tests

View File

@@ -22,40 +22,39 @@ permissions:
contents: read contents: read
jobs: jobs:
backend-lint: backend-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
ports:
- 5433:5432
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: nuzlocke_test
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install uv and Python
with:
python-version: "3.14"
- run: pip install ruff ty
- name: Check linting
run: ruff check backend/
- name: Check formatting
run: ruff format --check backend/
- name: Type check
run: ty check backend/src/
continue-on-error: true
actions-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Install actionlint
run: | run: |
bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) curl -LsSf https://astral.sh/uv/install.sh | sh
sudo mv actionlint /usr/local/bin/ source "$HOME/.local/bin/env"
- name: Lint GitHub Actions echo "$HOME/.local/bin" >> "$GITHUB_PATH"
run: actionlint uv python install 3.14
- name: Audit GitHub Actions security - name: Run tests
run: pipx run zizmor .github/workflows/ run: uv run --python 3.14 --extra dev pytest -q
working-directory: backend
env:
TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@192.168.1.10:5433/nuzlocke_test
frontend-lint: frontend-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -67,12 +66,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
working-directory: frontend working-directory: frontend
- name: Lint - name: Run tests
run: npm run lint run: npm test
working-directory: frontend
- name: Check formatting
run: npx oxfmt --check "src/"
working-directory: frontend
- name: Type check
run: npx tsc -b
working-directory: frontend working-directory: frontend

35
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: E2E Tests
on:
workflow_dispatch:
permissions:
contents: read
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: frontend
- name: Run e2e tests
run: npm run test:e2e
working-directory: frontend
env:
E2E_API_URL: http://192.168.1.10:8100
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report
path: frontend/playwright-report/

View File

@@ -1,6 +1,6 @@
# Deployment # Deployment
This document describes the deployment architecture and workflows for the nuzlocke-tracker. This document describes the deployment architecture and workflows for ANT (Another Nuzlocke Tracker).
## Architecture Overview ## Architecture Overview

View File

@@ -1,4 +1,4 @@
# nuzlocke-tracker # ANT - Another Nuzlocke Tracker
A full-stack Nuzlocke run tracker for Pokemon games. A full-stack Nuzlocke run tracker for Pokemon games.

View File

@@ -1,5 +1,5 @@
# Application settings # Application settings
APP_NAME="Nuzlocke Tracker API" APP_NAME="Another Nuzlocke Tracker API"
DEBUG=true DEBUG=true
# API settings # API settings

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "nuzlocke-tracker-api" name = "another-nuzlocke-tracker-api"
version = "0.1.0" version = "0.1.0"
description = "Backend API for Nuzlocke Tracker" description = "Backend API for Another Nuzlocke Tracker"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
@@ -66,4 +66,6 @@ root = "src"
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
testpaths = ["tests"] testpaths = ["tests"]

View File

@@ -0,0 +1,29 @@
"""add origin to encounters
Revision ID: i0d1e2f3a4b5
Revises: h9c0d1e2f3a4
Create Date: 2026-02-20 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "i0d1e2f3a4b5"
down_revision: str | Sequence[str] | None = "h9c0d1e2f3a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"encounters",
sa.Column("origin", sa.String(20), nullable=True),
)
def downgrade() -> None:
op.drop_column("encounters", "origin")

View File

@@ -58,12 +58,13 @@ async def create_encounter(
detail="Cannot create encounter on a parent route. Use a child route instead.", detail="Cannot create encounter on a parent route. Use a child route instead.",
) )
# Shiny clause: shiny encounters bypass the route-lock check # Shiny/gift clause: certain encounters bypass the route-lock check
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in ( gift_clause_on = run.rules.get("giftClause", False) if run.rules else False
"shed_evolution", skip_route_lock = (
"egg", (data.is_shiny and shiny_clause_on)
"transfer", or (data.origin == "gift" and gift_clause_on)
or data.origin in ("shed_evolution", "egg", "transfer")
) )
# If this route has a parent, check if sibling already has an encounter # If this route has a parent, check if sibling already has an encounter
@@ -93,13 +94,17 @@ async def create_encounter(
# Check if any relevant sibling already has an encounter in this run # Check if any relevant sibling already has an encounter in this run
# Exclude transfer-target encounters so they don't block the starter # Exclude transfer-target encounters so they don't block the starter
transfer_target_ids = select(GenlockeTransfer.target_encounter_id) transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
existing_encounter = await session.execute( lock_query = select(Encounter).where(
select(Encounter).where( Encounter.run_id == run_id,
Encounter.run_id == run_id, Encounter.route_id.in_(sibling_ids),
Encounter.route_id.in_(sibling_ids), ~Encounter.id.in_(transfer_target_ids),
~Encounter.id.in_(transfer_target_ids),
)
) )
# Gift-origin encounters don't count toward route lock
if gift_clause_on:
lock_query = lock_query.where(
Encounter.origin.is_(None) | (Encounter.origin != "gift")
)
existing_encounter = await session.execute(lock_query)
if existing_encounter.scalar_one_or_none() is not None: if existing_encounter.scalar_one_or_none() is not None:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
@@ -119,6 +124,7 @@ async def create_encounter(
status=data.status, status=data.status,
catch_level=data.catch_level, catch_level=data.catch_level,
is_shiny=data.is_shiny, is_shiny=data.is_shiny,
origin=data.origin,
) )
session.add(encounter) session.add(encounter)
await session.commit() await session.commit()

View File

@@ -1,7 +1,7 @@
import json import json
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -131,6 +131,7 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
async def list_game_routes( async def list_game_routes(
game_id: int, game_id: int,
flat: bool = False, flat: bool = False,
allowed_types: list[str] | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
""" """
@@ -138,13 +139,18 @@ async def list_game_routes(
By default, returns a hierarchical structure with top-level routes containing By default, returns a hierarchical structure with top-level routes containing
nested children. Use `flat=True` to get a flat list of all routes. nested children. Use `flat=True` to get a flat list of all routes.
When `allowed_types` is provided, routes with no encounters matching any of
those Pokemon types are excluded.
""" """
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
result = await session.execute( result = await session.execute(
select(Route) select(Route)
.where(Route.version_group_id == vg_id) .where(Route.version_group_id == vg_id)
.options(selectinload(Route.route_encounters)) .options(
selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon)
)
.order_by(Route.order) .order_by(Route.order)
) )
all_routes = result.scalars().all() all_routes = result.scalars().all()
@@ -170,7 +176,14 @@ async def list_game_routes(
# Determine which routes have encounters for this game # Determine which routes have encounters for this game
def has_encounters(route: Route) -> bool: def has_encounters(route: Route) -> bool:
return any(re.game_id == game_id for re in route.route_encounters) encounters = [re for re in route.route_encounters if re.game_id == game_id]
if not encounters:
return False
if allowed_types:
return any(
t in allowed_types for re in encounters for t in re.pokemon.types
)
return True
# Collect IDs of parent routes that have at least one child with encounters # Collect IDs of parent routes that have at least one child with encounters
parents_with_children = set() parents_with_children = set()

View File

@@ -8,7 +8,7 @@ class Settings(BaseSettings):
extra="ignore", extra="ignore",
) )
app_name: str = "Nuzlocke Tracker API" app_name: str = "Another Nuzlocke Tracker API"
debug: bool = False debug: bool = False
# API settings # API settings

View File

@@ -24,6 +24,7 @@ class Encounter(Base):
is_shiny: Mapped[bool] = mapped_column( is_shiny: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false") Boolean, default=False, server_default=text("false")
) )
origin: Mapped[str | None] = mapped_column(String(20))
caught_at: Mapped[datetime] = mapped_column( caught_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )

View File

@@ -35,6 +35,7 @@ class EncounterResponse(CamelModel):
faint_level: int | None faint_level: int | None
death_cause: str | None death_cause: str | None
is_shiny: bool is_shiny: bool
origin: str | None
caught_at: datetime caught_at: datetime

View File

@@ -2,6 +2,7 @@
Usage: Usage:
python -m app.seeds # Run seed python -m app.seeds # Run seed
python -m app.seeds --prune # Run seed and remove stale data not in seed files
python -m app.seeds --verify # Run seed + verification python -m app.seeds --verify # Run seed + verification
python -m app.seeds --export # Export all seed data from DB to JSON files python -m app.seeds --export # Export all seed data from DB to JSON files
""" """
@@ -21,7 +22,8 @@ async def main():
await export_all() await export_all()
return return
await seed() prune = "--prune" in sys.argv
await seed(prune=prune)
if "--verify" in sys.argv: if "--verify" in sys.argv:
await verify() await verify()

View File

@@ -125,7 +125,7 @@ RUN_DEFS = [
"name": "Unova Adventure", "name": "Unova Adventure",
"status": "active", "status": "active",
"progress": 0.35, "progress": 0.35,
"rules": {}, "rules": {"randomizer": True},
"started_days_ago": 5, "started_days_ago": 5,
"ended_days_ago": None, "ended_days_ago": None,
}, },
@@ -142,15 +142,19 @@ RUN_DEFS = [
# Default rules (matches frontend DEFAULT_RULES) # Default rules (matches frontend DEFAULT_RULES)
DEFAULT_RULES = { DEFAULT_RULES = {
"firstEncounterOnly": True,
"permadeath": True,
"nicknameRequired": True,
"duplicatesClause": True, "duplicatesClause": True,
"shinyClause": True, "shinyClause": True,
"giftClause": False,
"staticClause": True,
"pinwheelClause": True, "pinwheelClause": True,
"hardcoreMode": False,
"levelCaps": False, "levelCaps": False,
"hardcoreMode": False,
"setModeOnly": False, "setModeOnly": False,
"bossTeamMatch": False,
"egglocke": False,
"wonderlocke": False,
"randomizer": False,
"allowedTypes": [],
} }

View File

@@ -1,11 +1,12 @@
"""Database upsert helpers for seed data.""" """Database upsert helpers for seed data."""
from sqlalchemy import delete, select from sqlalchemy import delete, select, update
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon from app.models.boss_pokemon import BossPokemon
from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
@@ -124,11 +125,14 @@ async def upsert_routes(
session: AsyncSession, session: AsyncSession,
version_group_id: int, version_group_id: int,
routes: list[dict], routes: list[dict],
*,
prune: bool = False,
) -> dict[str, int]: ) -> dict[str, int]:
"""Upsert route records for a version group, return {name: id} mapping. """Upsert route records for a version group, return {name: id} mapping.
Handles hierarchical routes: routes with 'children' are parent routes, Handles hierarchical routes: routes with 'children' are parent routes,
and their children get parent_route_id set accordingly. and their children get parent_route_id set accordingly.
When prune is True, deletes routes not present in the seed data.
""" """
# First pass: upsert all parent routes (without parent_route_id) # First pass: upsert all parent routes (without parent_route_id)
for route in routes: for route in routes:
@@ -185,6 +189,43 @@ async def upsert_routes(
await session.flush() await session.flush()
if prune:
seed_names: set[str] = set()
for route in routes:
seed_names.add(route["name"])
for child in route.get("children", []):
seed_names.add(child["name"])
# Find stale route IDs, excluding routes with user encounters
in_use_subq = select(Encounter.route_id).distinct().subquery()
stale_route_ids_result = await session.execute(
select(Route.id).where(
Route.version_group_id == version_group_id,
Route.name.not_in(seed_names),
Route.id.not_in(select(in_use_subq)),
)
)
stale_route_ids = [row.id for row in stale_route_ids_result]
if stale_route_ids:
# Delete encounters referencing stale routes (no DB-level cascade)
await session.execute(
delete(RouteEncounter).where(
RouteEncounter.route_id.in_(stale_route_ids)
)
)
# Nullify boss battle references to stale routes
await session.execute(
update(BossBattle)
.where(BossBattle.after_route_id.in_(stale_route_ids))
.values(after_route_id=None)
)
# Now safe to delete the routes
await session.execute(delete(Route).where(Route.id.in_(stale_route_ids)))
print(f" Pruned {len(stale_route_ids)} stale route(s)")
await session.flush()
# Return full mapping including children # Return full mapping including children
result = await session.execute( result = await session.execute(
select(Route.name, Route.id).where(Route.version_group_id == version_group_id) select(Route.name, Route.id).where(Route.version_group_id == version_group_id)
@@ -233,8 +274,15 @@ async def upsert_route_encounters(
encounters: list[dict], encounters: list[dict],
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
game_id: int, game_id: int,
*,
prune: bool = False,
) -> int: ) -> int:
"""Upsert encounters for a route and game, return count of upserted rows.""" """Upsert encounters for a route and game, return count of upserted rows.
When prune is True, deletes encounters not present in the seed data.
"""
seed_keys: set[tuple[int, str, str]] = set()
count = 0 count = 0
for enc in encounters: for enc in encounters:
pokemon_id = dex_to_id.get(enc["pokeapi_id"]) pokemon_id = dex_to_id.get(enc["pokeapi_id"])
@@ -245,6 +293,7 @@ async def upsert_route_encounters(
conditions = enc.get("conditions") conditions = enc.get("conditions")
if conditions: if conditions:
for condition_name, rate in conditions.items(): for condition_name, rate in conditions.items():
seed_keys.add((pokemon_id, enc["method"], condition_name))
await _upsert_single_encounter( await _upsert_single_encounter(
session, session,
route_id, route_id,
@@ -258,6 +307,7 @@ async def upsert_route_encounters(
) )
count += 1 count += 1
else: else:
seed_keys.add((pokemon_id, enc["method"], ""))
await _upsert_single_encounter( await _upsert_single_encounter(
session, session,
route_id, route_id,
@@ -270,6 +320,23 @@ async def upsert_route_encounters(
) )
count += 1 count += 1
if prune:
existing = await session.execute(
select(RouteEncounter).where(
RouteEncounter.route_id == route_id,
RouteEncounter.game_id == game_id,
)
)
stale_ids = [
row.id
for row in existing.scalars()
if (row.pokemon_id, row.encounter_method, row.condition) not in seed_keys
]
if stale_ids:
await session.execute(
delete(RouteEncounter).where(RouteEncounter.id.in_(stale_ids))
)
return count return count
@@ -280,8 +347,13 @@ async def upsert_bosses(
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
route_name_to_id: dict[str, int] | None = None, route_name_to_id: dict[str, int] | None = None,
slug_to_game_id: dict[str, int] | None = None, slug_to_game_id: dict[str, int] | None = None,
*,
prune: bool = False,
) -> int: ) -> int:
"""Upsert boss battles for a version group, return count of bosses upserted.""" """Upsert boss battles for a version group, return count of bosses upserted.
When prune is True, deletes boss battles not present in the seed data.
"""
count = 0 count = 0
for boss in bosses: for boss in bosses:
# Resolve after_route_name to an ID # Resolve after_route_name to an ID
@@ -364,6 +436,20 @@ async def upsert_bosses(
count += 1 count += 1
if prune:
seed_orders = {boss["order"] for boss in bosses}
pruned = await session.execute(
delete(BossBattle)
.where(
BossBattle.version_group_id == version_group_id,
BossBattle.order.not_in(seed_orders),
)
.returning(BossBattle.id)
)
pruned_count = len(pruned.all())
if pruned_count:
print(f" Pruned {pruned_count} stale boss battle(s)")
await session.flush() await session.flush()
return count return count

View File

@@ -38,9 +38,12 @@ def load_json(filename: str):
return json.load(f) return json.load(f)
async def seed(): async def seed(*, prune: bool = False):
"""Run the full seed process.""" """Run the full seed process.
print("Starting seed...")
When prune is True, removes DB rows not present in seed data.
"""
print("Starting seed..." + (" (with pruning)" if prune else ""))
async with async_session() as session, session.begin(): async with async_session() as session, session.begin():
# 1. Upsert version groups # 1. Upsert version groups
@@ -88,7 +91,7 @@ async def seed():
continue continue
# Upsert routes once per version group # Upsert routes once per version group
route_map = await upsert_routes(session, vg_id, routes_data) route_map = await upsert_routes(session, vg_id, routes_data, prune=prune)
route_maps_by_vg[vg_id] = route_map route_maps_by_vg[vg_id] = route_map
total_routes += len(route_map) total_routes += len(route_map)
print(f" {vg_slug}: {len(route_map)} routes") print(f" {vg_slug}: {len(route_map)} routes")
@@ -119,6 +122,7 @@ async def seed():
route["encounters"], route["encounters"],
dex_to_id, dex_to_id,
game_id, game_id,
prune=prune,
) )
total_encounters += enc_count total_encounters += enc_count
@@ -137,6 +141,7 @@ async def seed():
child["encounters"], child["encounters"],
dex_to_id, dex_to_id,
game_id, game_id,
prune=prune,
) )
total_encounters += enc_count total_encounters += enc_count
@@ -160,7 +165,13 @@ async def seed():
route_name_to_id = route_maps_by_vg.get(vg_id, {}) route_name_to_id = route_maps_by_vg.get(vg_id, {})
boss_count = await upsert_bosses( boss_count = await upsert_bosses(
session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_id session,
vg_id,
bosses_data,
dex_to_id,
route_name_to_id,
slug_to_id,
prune=prune,
) )
total_bosses += boss_count total_bosses += boss_count
print(f" {vg_slug}: {boss_count} bosses") print(f" {vg_slug}: {boss_count} bosses")

61
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,61 @@
import os
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.models # noqa: F401 — ensures all models register with Base.metadata
from app.core.database import Base, get_session
from app.main import app
TEST_DATABASE_URL = os.getenv(
"TEST_DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test",
)
@pytest.fixture(scope="session")
async def engine():
"""Create the test engine and schema once for the entire session."""
eng = create_async_engine(TEST_DATABASE_URL, echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await eng.dispose()
@pytest.fixture
async def db_session(engine):
"""
Provide a database session for a single test.
Overrides the FastAPI get_session dependency so endpoint handlers use the
same session. Truncates all tables after the test to isolate state.
"""
session_factory = async_sessionmaker(engine, expire_on_commit=False)
session = session_factory()
async def override_get_session():
yield session
app.dependency_overrides[get_session] = override_get_session
yield session
await session.close()
app.dependency_overrides.clear()
async with engine.begin() as conn:
for table in reversed(Base.metadata.sorted_tables):
await conn.execute(table.delete())
@pytest.fixture
async def client(db_session):
"""Async HTTP client wired to the FastAPI app with the test database session."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac

320
backend/tests/test_games.py Normal file
View File

@@ -0,0 +1,320 @@
"""Integration tests for the Games & Routes API."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game import Game
from app.models.version_group import VersionGroup
BASE = "/api/v1/games"
GAME_PAYLOAD = {
"name": "Pokemon Red",
"slug": "red",
"generation": 1,
"region": "kanto",
}
@pytest.fixture
async def game(client: AsyncClient) -> dict:
"""A game created via the API (no version_group_id)."""
response = await client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201
return response.json()
@pytest.fixture
async def game_with_vg(db_session: AsyncSession) -> tuple[int, int]:
"""A game with a VersionGroup, required for route operations."""
vg = VersionGroup(name="Red/Blue", slug="red-blue")
db_session.add(vg)
await db_session.flush()
g = Game(
name="Pokemon Red",
slug="red-vg",
generation=1,
region="kanto",
version_group_id=vg.id,
)
db_session.add(g)
await db_session.commit()
await db_session.refresh(g)
return g.id, vg.id
# ---------------------------------------------------------------------------
# Games — list
# ---------------------------------------------------------------------------
class TestListGames:
async def test_empty_returns_empty_list(self, client: AsyncClient):
response = await client.get(BASE)
assert response.status_code == 200
assert response.json() == []
async def test_returns_created_game(self, client: AsyncClient, game: dict):
response = await client.get(BASE)
assert response.status_code == 200
slugs = [g["slug"] for g in response.json()]
assert "red" in slugs
# ---------------------------------------------------------------------------
# Games — create
# ---------------------------------------------------------------------------
class TestCreateGame:
async def test_creates_and_returns_game(self, client: AsyncClient):
response = await client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Pokemon Red"
assert data["slug"] == "red"
assert isinstance(data["id"], int)
async def test_duplicate_slug_returns_409(self, client: AsyncClient, game: dict):
response = await client.post(
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
)
assert response.status_code == 409
async def test_missing_required_field_returns_422(self, client: AsyncClient):
response = await client.post(BASE, json={"name": "Pokemon Red"})
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Games — get
# ---------------------------------------------------------------------------
class TestGetGame:
async def test_returns_game_with_empty_routes(
self, client: AsyncClient, game: dict
):
response = await client.get(f"{BASE}/{game['id']}")
assert response.status_code == 200
data = response.json()
assert data["id"] == game["id"]
assert data["slug"] == "red"
assert data["routes"] == []
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Games — update
# ---------------------------------------------------------------------------
class TestUpdateGame:
async def test_updates_name(self, client: AsyncClient, game: dict):
response = await client.put(
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
)
assert response.status_code == 200
assert response.json()["name"] == "Pokemon Blue"
async def test_slug_unchanged_on_partial_update(
self, client: AsyncClient, game: dict
):
response = await client.put(f"{BASE}/{game['id']}", json={"name": "New Name"})
assert response.json()["slug"] == "red"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.put(f"{BASE}/9999", json={"name": "x"})).status_code == 404
async def test_duplicate_slug_returns_409(self, client: AsyncClient):
await client.post(BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"})
r1 = await client.post(
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
)
game_id = r1.json()["id"]
response = await client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
assert response.status_code == 409
# ---------------------------------------------------------------------------
# Games — delete
# ---------------------------------------------------------------------------
class TestDeleteGame:
async def test_deletes_game(self, client: AsyncClient, game: dict):
response = await client.delete(f"{BASE}/{game['id']}")
assert response.status_code == 204
assert (await client.get(f"{BASE}/{game['id']}")).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Games — by-region
# ---------------------------------------------------------------------------
class TestListByRegion:
async def test_returns_list(self, client: AsyncClient):
response = await client.get(f"{BASE}/by-region")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_region_structure(self, client: AsyncClient):
response = await client.get(f"{BASE}/by-region")
regions = response.json()
assert len(regions) > 0
first = regions[0]
assert "name" in first
assert "generation" in first
assert "games" in first
assert isinstance(first["games"], list)
async def test_game_appears_in_region(self, client: AsyncClient, game: dict):
response = await client.get(f"{BASE}/by-region")
all_games = [g for region in response.json() for g in region["games"]]
assert any(g["slug"] == "red" for g in all_games)
# ---------------------------------------------------------------------------
# Routes — create / get
# ---------------------------------------------------------------------------
class TestCreateRoute:
async def test_creates_route(self, client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
response = await client.post(
f"{BASE}/{game_id}/routes",
json={"name": "Pallet Town", "order": 1},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Pallet Town"
assert data["order"] == 1
assert isinstance(data["id"], int)
async def test_game_detail_includes_route(
self, client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
response = await client.get(f"{BASE}/{game_id}")
routes = response.json()["routes"]
assert len(routes) == 1
assert routes[0]["name"] == "Route 1"
async def test_game_without_version_group_returns_400(
self, client: AsyncClient, game: dict
):
response = await client.post(
f"{BASE}/{game['id']}/routes",
json={"name": "Route 1", "order": 1},
)
assert response.status_code == 400
async def test_list_routes_excludes_routes_without_encounters(
self, client: AsyncClient, game_with_vg: tuple
):
"""list_game_routes only returns routes that have Pokemon encounters."""
game_id, _ = game_with_vg
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
response = await client.get(f"{BASE}/{game_id}/routes?flat=true")
assert response.status_code == 200
assert response.json() == []
# ---------------------------------------------------------------------------
# Routes — update
# ---------------------------------------------------------------------------
class TestUpdateRoute:
async def test_updates_route_name(self, client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
r = (
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
)
).json()
response = await client.put(
f"{BASE}/{game_id}/routes/{r['id']}",
json={"name": "New Name"},
)
assert response.status_code == 200
assert response.json()["name"] == "New Name"
async def test_route_not_found_returns_404(
self, client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
assert (
await client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
).status_code == 404
# ---------------------------------------------------------------------------
# Routes — delete
# ---------------------------------------------------------------------------
class TestDeleteRoute:
async def test_deletes_route(self, client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
r = (
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
).json()
assert (
await client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
).status_code == 204
# No longer in game detail
detail = (await client.get(f"{BASE}/{game_id}")).json()
assert all(route["id"] != r["id"] for route in detail["routes"])
async def test_route_not_found_returns_404(
self, client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
assert (await client.delete(f"{BASE}/{game_id}/routes/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Routes — reorder
# ---------------------------------------------------------------------------
class TestReorderRoutes:
async def test_reorders_routes(self, client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
r1 = (
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
)
).json()
r2 = (
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
)
).json()
response = await client.put(
f"{BASE}/{game_id}/routes/reorder",
json={
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]
},
)
assert response.status_code == 200
by_id = {r["id"]: r["order"] for r in response.json()}
assert by_id[r1["id"]] == 2
assert by_id[r2["id"]] == 1

View File

@@ -0,0 +1,594 @@
"""Integration tests for the Genlockes & Bosses API."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game import Game
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.version_group import VersionGroup
GENLOCKES_BASE = "/api/v1/genlockes"
RUNS_BASE = "/api/v1/runs"
GAMES_BASE = "/api/v1/games"
STATS_BASE = "/api/v1/stats"
EXPORT_BASE = "/api/v1/export"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def games_ctx(db_session: AsyncSession) -> dict:
"""Two games with version groups."""
vg1 = VersionGroup(name="GT VG1", slug="gt-vg1")
vg2 = VersionGroup(name="GT VG2", slug="gt-vg2")
db_session.add_all([vg1, vg2])
await db_session.flush()
game1 = Game(
name="GT Game 1",
slug="gt-game-1",
generation=1,
region="kanto",
version_group_id=vg1.id,
)
game2 = Game(
name="GT Game 2",
slug="gt-game-2",
generation=2,
region="johto",
version_group_id=vg2.id,
)
db_session.add_all([game1, game2])
await db_session.commit()
return {
"game1_id": game1.id,
"game2_id": game2.id,
"vg1_id": vg1.id,
"vg2_id": vg2.id,
}
@pytest.fixture
async def ctx(db_session: AsyncSession, client: AsyncClient, games_ctx: dict) -> dict:
"""Full context: routes + pokemon + genlocke + encounter for advance/transfer tests."""
route1 = Route(name="GT Route 1", version_group_id=games_ctx["vg1_id"], order=1)
route2 = Route(name="GT Route 2", version_group_id=games_ctx["vg2_id"], order=1)
db_session.add_all([route1, route2])
pikachu = Pokemon(
pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"]
)
db_session.add(pikachu)
await db_session.commit()
r = await client.post(
GENLOCKES_BASE,
json={
"name": "Test Genlocke",
"gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]],
},
)
assert r.status_code == 201
genlocke = r.json()
leg1 = next(leg for leg in genlocke["legs"] if leg["legOrder"] == 1)
run_id = leg1["runId"]
enc_r = await client.post(
f"{RUNS_BASE}/{run_id}/encounters",
json={"routeId": route1.id, "pokemonId": pikachu.id, "status": "caught"},
)
assert enc_r.status_code == 201
return {
**games_ctx,
"route1_id": route1.id,
"route2_id": route2.id,
"pikachu_id": pikachu.id,
"genlocke_id": genlocke["id"],
"run_id": run_id,
"encounter_id": enc_r.json()["id"],
"genlocke": genlocke,
}
# ---------------------------------------------------------------------------
# Genlockes — list
# ---------------------------------------------------------------------------
class TestListGenlockes:
async def test_empty_returns_empty_list(self, client: AsyncClient):
response = await client.get(GENLOCKES_BASE)
assert response.status_code == 200
assert response.json() == []
async def test_returns_created_genlocke(self, client: AsyncClient, ctx: dict):
response = await client.get(GENLOCKES_BASE)
assert response.status_code == 200
names = [g["name"] for g in response.json()]
assert "Test Genlocke" in names
# ---------------------------------------------------------------------------
# Genlockes — create
# ---------------------------------------------------------------------------
class TestCreateGenlocke:
async def test_creates_with_legs_and_first_run(
self, client: AsyncClient, games_ctx: dict
):
response = await client.post(
GENLOCKES_BASE,
json={
"name": "My Genlocke",
"gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Genlocke"
assert data["status"] == "active"
assert len(data["legs"]) == 2
# Leg 1 should already have a run linked
leg1 = next(leg for leg in data["legs"] if leg["legOrder"] == 1)
assert leg1["runId"] is not None
# Leg 2 should not yet have a run
leg2 = next(leg for leg in data["legs"] if leg["legOrder"] == 2)
assert leg2["runId"] is None
async def test_empty_game_ids_returns_400(self, client: AsyncClient):
response = await client.post(
GENLOCKES_BASE, json={"name": "Bad", "gameIds": []}
)
assert response.status_code == 400
async def test_invalid_game_id_returns_404(self, client: AsyncClient):
response = await client.post(
GENLOCKES_BASE, json={"name": "Bad", "gameIds": [9999]}
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Genlockes — get
# ---------------------------------------------------------------------------
class TestGetGenlocke:
async def test_returns_genlocke_with_legs_and_stats(
self, client: AsyncClient, ctx: dict
):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
assert response.status_code == 200
data = response.json()
assert data["id"] == ctx["genlocke_id"]
assert len(data["legs"]) == 2
assert "stats" in data
assert data["stats"]["totalLegs"] == 2
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Genlockes — update / delete
# ---------------------------------------------------------------------------
class TestUpdateGenlocke:
async def test_updates_name(self, client: AsyncClient, ctx: dict):
response = await client.patch(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}", json={"name": "Renamed"}
)
assert response.status_code == 200
assert response.json()["name"] == "Renamed"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.patch(f"{GENLOCKES_BASE}/9999", json={"name": "x"})
).status_code == 404
class TestDeleteGenlocke:
async def test_deletes_genlocke(self, client: AsyncClient, ctx: dict):
assert (
await client.delete(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
).status_code == 204
assert (
await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{GENLOCKES_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Genlockes — legs (add / remove)
# ---------------------------------------------------------------------------
class TestGenlockeLegs:
async def test_adds_leg(self, client: AsyncClient, ctx: dict):
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs",
json={"gameId": ctx["game1_id"]},
)
assert response.status_code == 201
legs = response.json()["legs"]
assert len(legs) == 3 # was 2, now 3
async def test_remove_leg_without_run(self, client: AsyncClient, ctx: dict):
# Leg 2 has no run yet — can be removed
leg2 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 2)
response = await client.delete(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg2['id']}"
)
assert response.status_code == 204
async def test_remove_leg_with_run_returns_400(
self, client: AsyncClient, ctx: dict
):
# Leg 1 has a run — cannot remove
leg1 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 1)
response = await client.delete(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg1['id']}"
)
assert response.status_code == 400
async def test_add_leg_invalid_game_returns_404(
self, client: AsyncClient, ctx: dict
):
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs",
json={"gameId": 9999},
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Genlockes — advance leg
# ---------------------------------------------------------------------------
class TestAdvanceLeg:
async def test_uncompleted_run_returns_400(self, client: AsyncClient, ctx: dict):
"""Cannot advance when leg 1's run is still active."""
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
)
assert response.status_code == 400
async def test_no_next_leg_returns_400(self, client: AsyncClient, games_ctx: dict):
"""A single-leg genlocke cannot be advanced."""
r = await client.post(
GENLOCKES_BASE,
json={"name": "Single Leg", "gameIds": [games_ctx["game1_id"]]},
)
genlocke = r.json()
run_id = genlocke["legs"][0]["runId"]
await client.patch(f"{RUNS_BASE}/{run_id}", json={"status": "completed"})
response = await client.post(
f"{GENLOCKES_BASE}/{genlocke['id']}/legs/1/advance"
)
assert response.status_code == 400
async def test_advances_to_next_leg(self, client: AsyncClient, ctx: dict):
"""Completing the current run allows advancing to the next leg."""
await client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
)
assert response.status_code == 200
legs = response.json()["legs"]
leg2 = next(leg for leg in legs if leg["legOrder"] == 2)
assert leg2["runId"] is not None
async def test_advances_with_transfers(self, client: AsyncClient, ctx: dict):
"""Advancing with transfer_encounter_ids creates egg encounters in the next leg."""
await client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance",
json={"transferEncounterIds": [ctx["encounter_id"]]},
)
assert response.status_code == 200
legs = response.json()["legs"]
leg2 = next(leg for leg in legs if leg["legOrder"] == 2)
new_run_id = leg2["runId"]
assert new_run_id is not None
# The new run should contain the transferred (egg) encounter
run_detail = (await client.get(f"{RUNS_BASE}/{new_run_id}")).json()
assert len(run_detail["encounters"]) == 1
# ---------------------------------------------------------------------------
# Genlockes — read-only detail endpoints
# ---------------------------------------------------------------------------
class TestGenlockeGraveyard:
async def test_returns_empty_graveyard(self, client: AsyncClient, ctx: dict):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard")
assert response.status_code == 200
data = response.json()
assert data["entries"] == []
assert data["totalDeaths"] == 0
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404
class TestGenlockeLineages:
async def test_returns_empty_lineages(self, client: AsyncClient, ctx: dict):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages")
assert response.status_code == 200
data = response.json()
assert data["lineages"] == []
assert data["totalLineages"] == 0
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404
class TestGenlockeRetiredFamilies:
async def test_returns_empty_retired_families(self, client: AsyncClient, ctx: dict):
response = await client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families"
)
assert response.status_code == 200
data = response.json()
assert data["retired_pokemon_ids"] == []
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.get(f"{GENLOCKES_BASE}/9999/retired-families")
).status_code == 404
class TestLegSurvivors:
async def test_returns_survivors(self, client: AsyncClient, ctx: dict):
"""The one caught encounter in leg 1 shows up as a survivor."""
response = await client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/survivors"
)
assert response.status_code == 200
assert len(response.json()) == 1
async def test_leg_not_found_returns_404(self, client: AsyncClient, ctx: dict):
assert (
await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors")
).status_code == 404
# ---------------------------------------------------------------------------
# Boss battles — CRUD (game-scoped)
# ---------------------------------------------------------------------------
BOSS_PAYLOAD = {
"name": "Brock",
"bossType": "gym",
"levelCap": 14,
"order": 1,
"location": "Pewter City",
}
class TestBossCRUD:
async def test_empty_list(self, client: AsyncClient, games_ctx: dict):
response = await client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
assert response.status_code == 200
assert response.json() == []
async def test_creates_boss(self, client: AsyncClient, games_ctx: dict):
response = await client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Brock"
assert data["levelCap"] == 14
assert data["pokemon"] == []
async def test_updates_boss(self, client: AsyncClient, games_ctx: dict):
boss = (
await client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
)
).json()
response = await client.put(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}",
json={"levelCap": 20},
)
assert response.status_code == 200
assert response.json()["levelCap"] == 20
async def test_deletes_boss(self, client: AsyncClient, games_ctx: dict):
boss = (
await client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
)
).json()
assert (
await client.delete(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}"
)
).status_code == 204
assert (
await client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
).json() == []
async def test_boss_not_found_returns_404(
self, client: AsyncClient, games_ctx: dict
):
assert (
await client.put(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/9999",
json={"levelCap": 10},
)
).status_code == 404
async def test_invalid_game_returns_404(self, client: AsyncClient):
assert (await client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404
async def test_game_without_version_group_returns_400(self, client: AsyncClient):
game = (
await client.post(
GAMES_BASE,
json={
"name": "No VG",
"slug": "no-vg",
"generation": 1,
"region": "kanto",
},
)
).json()
assert (
await client.get(f"{GAMES_BASE}/{game['id']}/bosses")
).status_code == 400
# ---------------------------------------------------------------------------
# Boss results — CRUD (run-scoped)
# ---------------------------------------------------------------------------
class TestBossResults:
@pytest.fixture
async def boss_ctx(self, client: AsyncClient, games_ctx: dict) -> dict:
"""A boss battle and a run for boss-result tests."""
boss = (
await client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
)
).json()
run = (
await client.post(
RUNS_BASE, json={"gameId": games_ctx["game1_id"], "name": "Boss Run"}
)
).json()
return {"boss_id": boss["id"], "run_id": run["id"]}
async def test_empty_list(self, client: AsyncClient, boss_ctx: dict):
response = await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
assert response.status_code == 200
assert response.json() == []
async def test_creates_boss_result(self, client: AsyncClient, boss_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1},
)
assert response.status_code == 201
data = response.json()
assert data["result"] == "won"
assert data["attempts"] == 1
assert data["completedAt"] is not None
async def test_upserts_existing_result(self, client: AsyncClient, boss_ctx: dict):
"""POSTing the same boss twice updates the result (upsert)."""
await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1},
)
response = await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "lost", "attempts": 3},
)
assert response.status_code == 201
assert response.json()["result"] == "lost"
assert response.json()["attempts"] == 3
# Still only one record
all_results = (
await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
).json()
assert len(all_results) == 1
async def test_deletes_boss_result(self, client: AsyncClient, boss_ctx: dict):
result = (
await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won"},
)
).json()
assert (
await client.delete(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results/{result['id']}"
)
).status_code == 204
assert (
await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
).json() == []
async def test_invalid_run_returns_404(self, client: AsyncClient, boss_ctx: dict):
assert (await client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404
async def test_invalid_boss_returns_404(self, client: AsyncClient, boss_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": 9999, "result": "won"},
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
class TestStats:
async def test_returns_stats_structure(self, client: AsyncClient):
response = await client.get(STATS_BASE)
assert response.status_code == 200
data = response.json()
assert data["totalRuns"] == 0
assert data["totalEncounters"] == 0
assert data["topCaughtPokemon"] == []
assert data["typeDistribution"] == []
async def test_reflects_created_data(self, client: AsyncClient, ctx: dict):
"""Stats should reflect the run and encounter created in ctx."""
response = await client.get(STATS_BASE)
assert response.status_code == 200
data = response.json()
assert data["totalRuns"] >= 1
assert data["totalEncounters"] >= 1
assert data["caughtCount"] >= 1
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
class TestExport:
async def test_export_games_returns_list(self, client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/games")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_export_pokemon_returns_list(self, client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/pokemon")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_export_evolutions_returns_list(self, client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/evolutions")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_export_game_routes_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404
async def test_export_game_bosses_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404

View File

@@ -0,0 +1,572 @@
"""Integration tests for the Pokemon & Evolutions API."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.encounter import Encounter
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.models.route import Route
from app.models.version_group import VersionGroup
POKEMON_BASE = "/api/v1/pokemon"
EVO_BASE = "/api/v1/evolutions"
ROUTE_BASE = "/api/v1/routes"
PIKACHU_DATA = {
"pokeapiId": 25,
"nationalDex": 25,
"name": "pikachu",
"types": ["electric"],
}
CHARMANDER_DATA = {
"pokeapiId": 4,
"nationalDex": 4,
"name": "charmander",
"types": ["fire"],
}
@pytest.fixture
async def pikachu(client: AsyncClient) -> dict:
response = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert response.status_code == 201
return response.json()
@pytest.fixture
async def charmander(client: AsyncClient) -> dict:
response = await client.post(POKEMON_BASE, json=CHARMANDER_DATA)
assert response.status_code == 201
return response.json()
@pytest.fixture
async def ctx(db_session: AsyncSession, client: AsyncClient) -> dict:
"""Full context: game + route + two pokemon + nuzlocke encounter on pikachu."""
vg = VersionGroup(name="Poke Test VG", slug="poke-test-vg")
db_session.add(vg)
await db_session.flush()
game = Game(
name="Poke Game",
slug="poke-game",
generation=1,
region="kanto",
version_group_id=vg.id,
)
db_session.add(game)
await db_session.flush()
route = Route(name="Poke Route", version_group_id=vg.id, order=1)
db_session.add(route)
await db_session.flush()
r1 = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert r1.status_code == 201
pikachu = r1.json()
r2 = await client.post(POKEMON_BASE, json=CHARMANDER_DATA)
assert r2.status_code == 201
charmander = r2.json()
run = NuzlockeRun(game_id=game.id, name="Poke Run", status="active", rules={})
db_session.add(run)
await db_session.flush()
# Nuzlocke encounter on pikachu — prevents pokemon deletion (409)
enc = Encounter(
run_id=run.id,
route_id=route.id,
pokemon_id=pikachu["id"],
status="caught",
)
db_session.add(enc)
await db_session.commit()
return {
"game_id": game.id,
"route_id": route.id,
"pikachu_id": pikachu["id"],
"charmander_id": charmander["id"],
}
# ---------------------------------------------------------------------------
# Pokemon — list
# ---------------------------------------------------------------------------
class TestListPokemon:
async def test_empty_returns_paginated_response(self, client: AsyncClient):
response = await client.get(POKEMON_BASE)
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
async def test_returns_created_pokemon(self, client: AsyncClient, pikachu: dict):
response = await client.get(POKEMON_BASE)
assert response.status_code == 200
names = [p["name"] for p in response.json()["items"]]
assert "pikachu" in names
async def test_search_by_name(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.get(POKEMON_BASE, params={"search": "pika"})
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "pikachu"
async def test_filter_by_type(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.get(POKEMON_BASE, params={"type": "electric"})
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "pikachu"
async def test_pagination(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.get(POKEMON_BASE, params={"limit": 1, "offset": 0})
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["total"] == 2
# ---------------------------------------------------------------------------
# Pokemon — create
# ---------------------------------------------------------------------------
class TestCreatePokemon:
async def test_creates_pokemon(self, client: AsyncClient):
response = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert response.status_code == 201
data = response.json()
assert data["name"] == "pikachu"
assert data["pokeapiId"] == 25
assert data["types"] == ["electric"]
assert isinstance(data["id"], int)
async def test_duplicate_pokeapi_id_returns_409(
self, client: AsyncClient, pikachu: dict
):
response = await client.post(
POKEMON_BASE,
json={**PIKACHU_DATA, "name": "pikachu-copy"},
)
assert response.status_code == 409
async def test_missing_required_returns_422(self, client: AsyncClient):
response = await client.post(POKEMON_BASE, json={"name": "pikachu"})
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Pokemon — get
# ---------------------------------------------------------------------------
class TestGetPokemon:
async def test_returns_pokemon(self, client: AsyncClient, pikachu: dict):
response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}")
assert response.status_code == 200
assert response.json()["name"] == "pikachu"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{POKEMON_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Pokemon — update
# ---------------------------------------------------------------------------
class TestUpdatePokemon:
async def test_updates_name(self, client: AsyncClient, pikachu: dict):
response = await client.put(
f"{POKEMON_BASE}/{pikachu['id']}", json={"name": "Pikachu"}
)
assert response.status_code == 200
assert response.json()["name"] == "Pikachu"
async def test_duplicate_pokeapi_id_returns_409(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.put(
f"{POKEMON_BASE}/{pikachu['id']}",
json={"pokeapiId": charmander["pokeapiId"]},
)
assert response.status_code == 409
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.put(f"{POKEMON_BASE}/9999", json={"name": "x"})
).status_code == 404
# ---------------------------------------------------------------------------
# Pokemon — delete
# ---------------------------------------------------------------------------
class TestDeletePokemon:
async def test_deletes_pokemon(self, client: AsyncClient, charmander: dict):
assert (
await client.delete(f"{POKEMON_BASE}/{charmander['id']}")
).status_code == 204
assert (
await client.get(f"{POKEMON_BASE}/{charmander['id']}")
).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{POKEMON_BASE}/9999")).status_code == 404
async def test_pokemon_with_encounters_returns_409(
self, client: AsyncClient, ctx: dict
):
"""Pokemon referenced by a nuzlocke encounter cannot be deleted."""
response = await client.delete(f"{POKEMON_BASE}/{ctx['pikachu_id']}")
assert response.status_code == 409
# ---------------------------------------------------------------------------
# Pokemon — families
# ---------------------------------------------------------------------------
class TestPokemonFamilies:
async def test_empty_when_no_evolutions(self, client: AsyncClient):
response = await client.get(f"{POKEMON_BASE}/families")
assert response.status_code == 200
assert response.json()["families"] == []
async def test_returns_family_grouping(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
response = await client.get(f"{POKEMON_BASE}/families")
assert response.status_code == 200
families = response.json()["families"]
assert len(families) == 1
assert set(families[0]) == {pikachu["id"], charmander["id"]}
# ---------------------------------------------------------------------------
# Pokemon — evolution chain
# ---------------------------------------------------------------------------
class TestPokemonEvolutionChain:
async def test_empty_for_unevolved_pokemon(
self, client: AsyncClient, pikachu: dict
):
response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain")
assert response.status_code == 200
assert response.json() == []
async def test_returns_chain_for_multi_stage(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain")
assert response.status_code == 200
chain = response.json()
assert len(chain) == 1
assert chain[0]["fromPokemonId"] == pikachu["id"]
assert chain[0]["toPokemonId"] == charmander["id"]
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.get(f"{POKEMON_BASE}/9999/evolution-chain")
).status_code == 404
# ---------------------------------------------------------------------------
# Evolutions — list
# ---------------------------------------------------------------------------
class TestListEvolutions:
async def test_empty_returns_paginated_response(self, client: AsyncClient):
response = await client.get(EVO_BASE)
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
async def test_returns_created_evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
response = await client.get(EVO_BASE)
assert response.status_code == 200
assert response.json()["total"] == 1
async def test_filter_by_trigger(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "use-item",
},
)
hit = await client.get(EVO_BASE, params={"trigger": "use-item"})
assert hit.json()["total"] == 1
miss = await client.get(EVO_BASE, params={"trigger": "level-up"})
assert miss.json()["total"] == 0
# ---------------------------------------------------------------------------
# Evolutions — create
# ---------------------------------------------------------------------------
class TestCreateEvolution:
async def test_creates_evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
assert response.status_code == 201
data = response.json()
assert data["fromPokemonId"] == pikachu["id"]
assert data["toPokemonId"] == charmander["id"]
assert data["trigger"] == "level-up"
assert data["fromPokemon"]["name"] == "pikachu"
assert data["toPokemon"]["name"] == "charmander"
async def test_invalid_from_pokemon_returns_404(
self, client: AsyncClient, charmander: dict
):
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": 9999,
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
assert response.status_code == 404
async def test_invalid_to_pokemon_returns_404(
self, client: AsyncClient, pikachu: dict
):
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": 9999,
"trigger": "level-up",
},
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Evolutions — update
# ---------------------------------------------------------------------------
class TestUpdateEvolution:
@pytest.fixture
async def evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
) -> dict:
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
return response.json()
async def test_updates_trigger(self, client: AsyncClient, evolution: dict):
response = await client.put(
f"{EVO_BASE}/{evolution['id']}", json={"trigger": "use-item"}
)
assert response.status_code == 200
assert response.json()["trigger"] == "use-item"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.put(f"{EVO_BASE}/9999", json={"trigger": "level-up"})
).status_code == 404
# ---------------------------------------------------------------------------
# Evolutions — delete
# ---------------------------------------------------------------------------
class TestDeleteEvolution:
@pytest.fixture
async def evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
) -> dict:
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
return response.json()
async def test_deletes_evolution(self, client: AsyncClient, evolution: dict):
assert (await client.delete(f"{EVO_BASE}/{evolution['id']}")).status_code == 204
assert (await client.get(EVO_BASE)).json()["total"] == 0
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{EVO_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Route Encounters — list / create / update / delete
# ---------------------------------------------------------------------------
class TestRouteEncounters:
async def test_empty_list_for_route(self, client: AsyncClient, ctx: dict):
response = await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
assert response.status_code == 200
assert response.json() == []
async def test_creates_route_encounter(self, client: AsyncClient, ctx: dict):
response = await client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
assert response.status_code == 201
data = response.json()
assert data["pokemonId"] == ctx["charmander_id"]
assert data["encounterRate"] == 10
assert data["pokemon"]["name"] == "charmander"
async def test_invalid_route_returns_404(self, client: AsyncClient, ctx: dict):
response = await client.post(
f"{ROUTE_BASE}/9999/pokemon",
json={
"pokemonId": ctx["charmander_id"],
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
assert response.status_code == 404
async def test_invalid_pokemon_returns_404(self, client: AsyncClient, ctx: dict):
response = await client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": 9999,
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
assert response.status_code == 404
async def test_updates_route_encounter(self, client: AsyncClient, ctx: dict):
r = await client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
enc = r.json()
response = await client.put(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}",
json={"encounterRate": 25},
)
assert response.status_code == 200
assert response.json()["encounterRate"] == 25
async def test_update_not_found_returns_404(self, client: AsyncClient, ctx: dict):
assert (
await client.put(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999",
json={"encounterRate": 5},
)
).status_code == 404
async def test_deletes_route_encounter(self, client: AsyncClient, ctx: dict):
r = await client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
enc = r.json()
assert (
await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}")
).status_code == 204
assert (
await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
).json() == []
async def test_delete_not_found_returns_404(self, client: AsyncClient, ctx: dict):
assert (
await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999")
).status_code == 404

454
backend/tests/test_runs.py Normal file
View File

@@ -0,0 +1,454 @@
"""Integration tests for the Runs & Encounters API."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.version_group import VersionGroup
RUNS_BASE = "/api/v1/runs"
ENC_BASE = "/api/v1/encounters"
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def game_id(db_session: AsyncSession) -> int:
"""A minimal game (no version_group_id needed for run CRUD)."""
game = Game(name="Test Game", slug="test-game", generation=1, region="kanto")
db_session.add(game)
await db_session.commit()
await db_session.refresh(game)
return game.id
@pytest.fixture
async def run(client: AsyncClient, game_id: int) -> dict:
"""An active run created via the API."""
response = await client.post(RUNS_BASE, json={"gameId": game_id, "name": "My Run"})
assert response.status_code == 201
return response.json()
@pytest.fixture
async def enc_ctx(db_session: AsyncSession) -> dict:
"""Full context for encounter tests: game, run, pokemon, standalone and grouped routes."""
vg = VersionGroup(name="Enc Test VG", slug="enc-test-vg")
db_session.add(vg)
await db_session.flush()
game = Game(
name="Enc Game",
slug="enc-game",
generation=1,
region="kanto",
version_group_id=vg.id,
)
db_session.add(game)
await db_session.flush()
pikachu = Pokemon(
pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"]
)
charmander = Pokemon(
pokeapi_id=4, national_dex=4, name="charmander", types=["fire"]
)
db_session.add_all([pikachu, charmander])
await db_session.flush()
# A standalone route (no parent — no route-lock applies)
standalone = Route(name="Standalone Route", version_group_id=vg.id, order=1)
# A parent route with two children (route-lock applies to children)
parent = Route(name="Route Group", version_group_id=vg.id, order=2)
db_session.add_all([standalone, parent])
await db_session.flush()
child1 = Route(
name="Child A", version_group_id=vg.id, order=1, parent_route_id=parent.id
)
child2 = Route(
name="Child B", version_group_id=vg.id, order=2, parent_route_id=parent.id
)
db_session.add_all([child1, child2])
await db_session.flush()
run = NuzlockeRun(
game_id=game.id,
name="Enc Run",
status="active",
rules={"shinyClause": True, "giftClause": False},
)
db_session.add(run)
await db_session.commit()
for obj in [standalone, parent, child1, child2, pikachu, charmander, run]:
await db_session.refresh(obj)
return {
"run_id": run.id,
"game_id": game.id,
"pikachu_id": pikachu.id,
"charmander_id": charmander.id,
"standalone_id": standalone.id,
"parent_id": parent.id,
"child1_id": child1.id,
"child2_id": child2.id,
}
# ---------------------------------------------------------------------------
# Runs — list
# ---------------------------------------------------------------------------
class TestListRuns:
async def test_empty_returns_empty_list(self, client: AsyncClient):
response = await client.get(RUNS_BASE)
assert response.status_code == 200
assert response.json() == []
async def test_returns_created_run(self, client: AsyncClient, run: dict):
response = await client.get(RUNS_BASE)
assert response.status_code == 200
ids = [r["id"] for r in response.json()]
assert run["id"] in ids
# ---------------------------------------------------------------------------
# Runs — create
# ---------------------------------------------------------------------------
class TestCreateRun:
async def test_creates_active_run(self, client: AsyncClient, game_id: int):
response = await client.post(
RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "New Run"
assert data["status"] == "active"
assert data["gameId"] == game_id
assert isinstance(data["id"], int)
async def test_rules_stored(self, client: AsyncClient, game_id: int):
rules = {"duplicatesClause": True, "shinyClause": False}
response = await client.post(
RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules}
)
assert response.status_code == 201
assert response.json()["rules"]["duplicatesClause"] is True
async def test_invalid_game_returns_404(self, client: AsyncClient):
response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"})
assert response.status_code == 404
async def test_missing_required_returns_422(self, client: AsyncClient):
response = await client.post(RUNS_BASE, json={"name": "Run"})
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Runs — get
# ---------------------------------------------------------------------------
class TestGetRun:
async def test_returns_run_with_game_and_encounters(
self, client: AsyncClient, run: dict
):
response = await client.get(f"{RUNS_BASE}/{run['id']}")
assert response.status_code == 200
data = response.json()
assert data["id"] == run["id"]
assert "game" in data
assert data["encounters"] == []
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{RUNS_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Runs — update
# ---------------------------------------------------------------------------
class TestUpdateRun:
async def test_updates_name(self, client: AsyncClient, run: dict):
response = await client.patch(
f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"}
)
assert response.status_code == 200
assert response.json()["name"] == "Renamed"
async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict):
response = await client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "completed"
assert data["completedAt"] is not None
async def test_fail_run(self, client: AsyncClient, run: dict):
response = await client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
)
assert response.status_code == 200
assert response.json()["status"] == "failed"
async def test_ending_already_ended_run_returns_400(
self, client: AsyncClient, run: dict
):
await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"})
response = await client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
)
assert response.status_code == 400
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
).status_code == 404
# ---------------------------------------------------------------------------
# Runs — delete
# ---------------------------------------------------------------------------
class TestDeleteRun:
async def test_deletes_run(self, client: AsyncClient, run: dict):
assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Encounters — create
# ---------------------------------------------------------------------------
class TestCreateEncounter:
async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
assert response.status_code == 201
data = response.json()
assert data["runId"] == enc_ctx["run_id"]
assert data["pokemonId"] == enc_ctx["pikachu_id"]
assert data["status"] == "caught"
assert data["isShiny"] is False
async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/9999/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
assert response.status_code == 404
async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": 9999,
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
assert response.status_code == 404
async def test_invalid_pokemon_returns_404(
self, client: AsyncClient, enc_ctx: dict
):
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": 9999,
"status": "caught",
},
)
assert response.status_code == 404
async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict):
"""Cannot create an encounter directly on a parent route (use child routes)."""
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["parent_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
assert response.status_code == 400
async def test_route_lock_prevents_second_sibling_encounter(
self, client: AsyncClient, enc_ctx: dict
):
"""Once a sibling child has an encounter, other siblings in the group return 409."""
await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
"pokemonId": enc_ctx["charmander_id"],
"status": "caught",
},
)
assert response.status_code == 409
async def test_shiny_bypasses_route_lock(
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
):
"""A shiny encounter bypasses the route-lock when shinyClause is enabled."""
# First encounter occupies the group
await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
# Shiny encounter on sibling should succeed
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
"pokemonId": enc_ctx["charmander_id"],
"status": "caught",
"isShiny": True,
},
)
assert response.status_code == 201
assert response.json()["isShiny"] is True
async def test_gift_bypasses_route_lock_when_clause_on(
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
):
"""A gift encounter bypasses route-lock when giftClause is enabled."""
# Enable giftClause on the run
run = await db_session.get(NuzlockeRun, enc_ctx["run_id"])
run.rules = {"shinyClause": True, "giftClause": True}
await db_session.commit()
await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
"pokemonId": enc_ctx["charmander_id"],
"status": "caught",
"origin": "gift",
},
)
assert response.status_code == 201
assert response.json()["origin"] == "gift"
# ---------------------------------------------------------------------------
# Encounters — update
# ---------------------------------------------------------------------------
class TestUpdateEncounter:
@pytest.fixture
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
return response.json()
async def test_updates_nickname(self, client: AsyncClient, encounter: dict):
response = await client.patch(
f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"}
)
assert response.status_code == 200
assert response.json()["nickname"] == "Sparky"
async def test_updates_status_to_fainted(
self, client: AsyncClient, encounter: dict
):
response = await client.patch(
f"{ENC_BASE}/{encounter['id']}",
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "fainted"
assert data["faintLevel"] == 12
assert data["deathCause"] == "wild battle"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
).status_code == 404
# ---------------------------------------------------------------------------
# Encounters — delete
# ---------------------------------------------------------------------------
class TestDeleteEncounter:
@pytest.fixture
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
return response.json()
async def test_deletes_encounter(
self, client: AsyncClient, encounter: dict, enc_ctx: dict
):
assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204
# Run detail should no longer include it
detail = (await client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
assert all(e["id"] != encounter["id"] for e in detail["encounters"])
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404

View File

@@ -0,0 +1,306 @@
"""Unit tests for Pydantic schemas."""
import pytest
from pydantic import ValidationError
from app.schemas.base import CamelModel
from app.schemas.boss import BossReorderItem, BossReorderRequest, BossResultCreate
from app.schemas.encounter import EncounterCreate, EncounterUpdate
from app.schemas.game import (
GameCreate,
GameUpdate,
RouteReorderItem,
RouteReorderRequest,
)
from app.schemas.genlocke import GenlockeCreate
from app.schemas.pokemon import EvolutionCreate, PokemonCreate
from app.schemas.run import RunCreate, RunUpdate
class TestCamelModel:
def test_snake_case_field_name_accepted(self):
class M(CamelModel):
game_id: int
assert M(game_id=1).game_id == 1
def test_camel_case_alias_accepted(self):
class M(CamelModel):
game_id: int
assert M(**{"gameId": 1}).game_id == 1
def test_serializes_to_camel_case(self):
class M(CamelModel):
game_id: int
is_shiny: bool
data = M(game_id=1, is_shiny=True).model_dump(by_alias=True)
assert data == {"gameId": 1, "isShiny": True}
def test_snake_case_not_in_serialized_output(self):
class M(CamelModel):
version_group_id: int
data = M(version_group_id=5).model_dump(by_alias=True)
assert "version_group_id" not in data
assert "versionGroupId" in data
def test_from_attributes(self):
class FakeOrm:
game_id = 42
class M(CamelModel):
game_id: int
assert M.model_validate(FakeOrm()).game_id == 42
class TestRunCreate:
def test_valid_minimum(self):
run = RunCreate(game_id=1, name="Nuzlocke #1")
assert run.game_id == 1
assert run.name == "Nuzlocke #1"
assert run.rules == {}
assert run.naming_scheme is None
def test_camel_case_input(self):
run = RunCreate(**{"gameId": 5, "name": "Run"})
assert run.game_id == 5
def test_missing_game_id_raises(self):
with pytest.raises(ValidationError):
RunCreate(name="Run")
def test_missing_name_raises(self):
with pytest.raises(ValidationError):
RunCreate(game_id=1)
def test_rules_accepts_arbitrary_data(self):
run = RunCreate(game_id=1, name="x", rules={"duplicatesClause": True})
assert run.rules["duplicatesClause"] is True
def test_naming_scheme_accepted(self):
run = RunCreate(game_id=1, name="x", naming_scheme="nature")
assert run.naming_scheme == "nature"
class TestRunUpdate:
def test_all_fields_optional(self):
update = RunUpdate()
assert update.name is None
assert update.status is None
assert update.rules is None
assert update.naming_scheme is None
def test_partial_update(self):
update = RunUpdate(name="New Name")
assert update.name == "New Name"
assert update.status is None
def test_hof_encounter_ids(self):
update = RunUpdate(hof_encounter_ids=[1, 2, 3])
assert update.hof_encounter_ids == [1, 2, 3]
class TestGameCreate:
def test_valid_minimum(self):
game = GameCreate(name="Pokemon Red", slug="red", generation=1, region="Kanto")
assert game.name == "Pokemon Red"
assert game.slug == "red"
assert game.generation == 1
assert game.region == "Kanto"
def test_optional_fields_default_none(self):
game = GameCreate(name="Pokemon Red", slug="red", generation=1, region="Kanto")
assert game.category is None
assert game.box_art_url is None
assert game.release_year is None
assert game.color is None
def test_missing_required_field_raises(self):
with pytest.raises(ValidationError):
GameCreate(name="Pokemon Red", slug="red", generation=1) # missing region
def test_camel_case_input(self):
game = GameCreate(
**{
"name": "Gold",
"slug": "gold",
"generation": 2,
"region": "Johto",
"boxArtUrl": "/art.png",
}
)
assert game.box_art_url == "/art.png"
class TestGameUpdate:
def test_all_fields_optional(self):
assert GameUpdate().name is None
def test_partial_update(self):
update = GameUpdate(name="New Name", generation=3)
assert update.name == "New Name"
assert update.generation == 3
assert update.region is None
class TestEncounterCreate:
def test_valid_minimum(self):
enc = EncounterCreate(route_id=1, pokemon_id=25, status="caught")
assert enc.route_id == 1
assert enc.pokemon_id == 25
assert enc.status == "caught"
assert enc.is_shiny is False
assert enc.nickname is None
def test_camel_case_input(self):
enc = EncounterCreate(
**{"routeId": 1, "pokemonId": 25, "status": "caught", "isShiny": True}
)
assert enc.route_id == 1
assert enc.is_shiny is True
def test_missing_pokemon_id_raises(self):
with pytest.raises(ValidationError):
EncounterCreate(route_id=1, status="caught")
def test_missing_status_raises(self):
with pytest.raises(ValidationError):
EncounterCreate(route_id=1, pokemon_id=25)
def test_origin_accepted(self):
enc = EncounterCreate(route_id=1, pokemon_id=1, status="caught", origin="gift")
assert enc.origin == "gift"
class TestEncounterUpdate:
def test_all_fields_optional(self):
update = EncounterUpdate()
assert update.nickname is None
assert update.status is None
assert update.faint_level is None
assert update.death_cause is None
assert update.current_pokemon_id is None
class TestBossResultCreate:
def test_valid_minimum(self):
result = BossResultCreate(boss_battle_id=1, result="win")
assert result.boss_battle_id == 1
assert result.result == "win"
assert result.attempts == 1
def test_attempts_default_one(self):
assert BossResultCreate(boss_battle_id=1, result="loss").attempts == 1
def test_custom_attempts(self):
assert (
BossResultCreate(boss_battle_id=1, result="win", attempts=3).attempts == 3
)
def test_missing_boss_battle_id_raises(self):
with pytest.raises(ValidationError):
BossResultCreate(result="win")
class TestBossReorderRequest:
def test_nested_items_accepted(self):
req = BossReorderRequest(bosses=[BossReorderItem(id=1, order=2)])
assert req.bosses[0].id == 1
assert req.bosses[0].order == 2
def test_dict_input_coerced(self):
req = BossReorderRequest(**{"bosses": [{"id": 3, "order": 1}]})
assert req.bosses[0].id == 3
def test_empty_list_accepted(self):
assert BossReorderRequest(bosses=[]).bosses == []
class TestRouteReorderRequest:
def test_nested_items_accepted(self):
req = RouteReorderRequest(routes=[RouteReorderItem(id=10, order=1)])
assert req.routes[0].id == 10
def test_dict_input_coerced(self):
req = RouteReorderRequest(**{"routes": [{"id": 5, "order": 3}]})
assert req.routes[0].order == 3
class TestGenlockeCreate:
def test_valid_minimum(self):
gc = GenlockeCreate(name="My Genlocke", game_ids=[1, 2, 3])
assert gc.name == "My Genlocke"
assert gc.game_ids == [1, 2, 3]
assert gc.genlocke_rules == {}
assert gc.nuzlocke_rules == {}
assert gc.naming_scheme is None
def test_missing_name_raises(self):
with pytest.raises(ValidationError):
GenlockeCreate(game_ids=[1, 2])
def test_missing_game_ids_raises(self):
with pytest.raises(ValidationError):
GenlockeCreate(name="My Genlocke")
def test_camel_case_input(self):
gc = GenlockeCreate(**{"name": "x", "gameIds": [1], "namingScheme": "types"})
assert gc.naming_scheme == "types"
class TestPokemonCreate:
def test_valid_minimum(self):
p = PokemonCreate(
pokeapi_id=25, national_dex=25, name="Pikachu", types=["electric"]
)
assert p.name == "Pikachu"
assert p.types == ["electric"]
assert p.sprite_url is None
def test_multi_type(self):
p = PokemonCreate(
pokeapi_id=6, national_dex=6, name="Charizard", types=["fire", "flying"]
)
assert p.types == ["fire", "flying"]
def test_missing_required_raises(self):
with pytest.raises(ValidationError):
PokemonCreate(pokeapi_id=1, national_dex=1, name="x") # missing types
class TestEvolutionCreate:
def test_valid_minimum(self):
evo = EvolutionCreate(from_pokemon_id=1, to_pokemon_id=2, trigger="level-up")
assert evo.from_pokemon_id == 1
assert evo.to_pokemon_id == 2
assert evo.trigger == "level-up"
assert evo.min_level is None
assert evo.item is None
def test_all_optional_fields(self):
evo = EvolutionCreate(
from_pokemon_id=1,
to_pokemon_id=2,
trigger="use-item",
min_level=16,
item="fire-stone",
held_item=None,
condition="day",
region="Kanto",
)
assert evo.min_level == 16
assert evo.item == "fire-stone"
assert evo.region == "Kanto"
def test_missing_trigger_raises(self):
with pytest.raises(ValidationError):
EvolutionCreate(from_pokemon_id=1, to_pokemon_id=2)
def test_camel_case_input(self):
evo = EvolutionCreate(
**{"fromPokemonId": 1, "toPokemonId": 2, "trigger": "level-up"}
)
assert evo.from_pokemon_id == 1

View File

@@ -0,0 +1,174 @@
"""Unit tests for the services layer (families, naming utilities)."""
import pytest
from app.services.families import build_families, resolve_base_form
from app.services.naming import strip_roman_suffix, to_roman
# ---------------------------------------------------------------------------
# Minimal Evolution stand-in — only the two fields the services touch
# ---------------------------------------------------------------------------
class Evo:
"""Lightweight stand-in for app.models.evolution.Evolution."""
def __init__(self, from_id: int, to_id: int) -> None:
self.from_pokemon_id = from_id
self.to_pokemon_id = to_id
# ---------------------------------------------------------------------------
# build_families
# ---------------------------------------------------------------------------
class TestBuildFamilies:
def test_empty_evolutions_returns_empty_dict(self):
assert build_families([]) == {}
def test_linear_chain(self):
# A(1) → B(2) → C(3)
evos = [Evo(1, 2), Evo(2, 3)]
families = build_families(evos)
assert set(families[1]) == {1, 2, 3}
assert set(families[2]) == {1, 2, 3}
assert set(families[3]) == {1, 2, 3}
def test_branching_evolutions(self):
# Eevee-like: 1 → 2, 1 → 3, 1 → 4
evos = [Evo(1, 2), Evo(1, 3), Evo(1, 4)]
families = build_families(evos)
assert set(families[1]) == {1, 2, 3, 4}
assert set(families[2]) == {1, 2, 3, 4}
assert set(families[4]) == {1, 2, 3, 4}
def test_disjoint_chains_are_separate_families(self):
# Chain 1→2 and independent chain 3→4
evos = [Evo(1, 2), Evo(3, 4)]
families = build_families(evos)
assert set(families[1]) == {1, 2}
assert set(families[3]) == {3, 4}
assert 3 not in set(families[1])
assert 1 not in set(families[3])
def test_shedinja_case(self):
# Nincada(1) → Ninjask(2) and Nincada(1) → Shedinja(3)
evos = [Evo(1, 2), Evo(1, 3)]
families = build_families(evos)
assert set(families[1]) == {1, 2, 3}
assert set(families[3]) == {1, 2, 3}
def test_pokemon_not_in_any_evolution_not_in_result(self):
evos = [Evo(1, 2)]
families = build_families(evos)
assert 99 not in families
def test_all_family_members_have_identical_family_list(self):
evos = [Evo(10, 11), Evo(11, 12)]
families = build_families(evos)
assert set(families[10]) == set(families[11]) == set(families[12])
# ---------------------------------------------------------------------------
# resolve_base_form
# ---------------------------------------------------------------------------
class TestResolveBaseForm:
def test_pokemon_not_in_any_evolution_returns_itself(self):
assert resolve_base_form(99, []) == 99
def test_base_form_returns_itself(self):
# A(1) → B(2): base of 1 is still 1
evos = [Evo(1, 2)]
assert resolve_base_form(1, evos) == 1
def test_final_form_returns_base(self):
# A(1) → B(2) → C(3): base of 3 is 1
evos = [Evo(1, 2), Evo(2, 3)]
assert resolve_base_form(3, evos) == 1
def test_middle_form_returns_base(self):
# A(1) → B(2) → C(3): base of 2 is 1
evos = [Evo(1, 2), Evo(2, 3)]
assert resolve_base_form(2, evos) == 1
def test_branching_evolution_base(self):
# 1 → 2, 1 → 3: base of both 2 and 3 is 1
evos = [Evo(1, 2), Evo(1, 3)]
assert resolve_base_form(2, evos) == 1
assert resolve_base_form(3, evos) == 1
def test_shedinja_resolves_to_nincada(self):
# Nincada(1) → Ninjask(2), Nincada(1) → Shedinja(3)
evos = [Evo(1, 2), Evo(1, 3)]
assert resolve_base_form(3, evos) == 1
def test_empty_evolutions_returns_self(self):
assert resolve_base_form(42, []) == 42
# ---------------------------------------------------------------------------
# to_roman
# ---------------------------------------------------------------------------
class TestToRoman:
@pytest.mark.parametrize(
"n, expected",
[
(1, "I"),
(2, "II"),
(3, "III"),
(4, "IV"),
(5, "V"),
(6, "VI"),
(9, "IX"),
(10, "X"),
(11, "XI"),
(14, "XIV"),
(40, "XL"),
(50, "L"),
(90, "XC"),
(100, "C"),
],
)
def test_converts_integer_to_roman(self, n: int, expected: str):
assert to_roman(n) == expected
def test_typical_genlocke_sequence(self):
# Lineage names: Heracles I, II, III, IV, V
assert [to_roman(i) for i in range(1, 6)] == ["I", "II", "III", "IV", "V"]
# ---------------------------------------------------------------------------
# strip_roman_suffix
# ---------------------------------------------------------------------------
class TestStripRomanSuffix:
def test_strips_roman_numeral_ii(self):
assert strip_roman_suffix("Heracles II") == "Heracles"
def test_strips_roman_numeral_iii(self):
assert strip_roman_suffix("Athena III") == "Athena"
def test_strips_roman_numeral_iv(self):
assert strip_roman_suffix("Nova IV") == "Nova"
def test_strips_roman_numeral_x(self):
assert strip_roman_suffix("Zeus X") == "Zeus"
def test_no_suffix_returns_unchanged(self):
assert strip_roman_suffix("Apollo") == "Apollo"
def test_name_with_i_suffix(self):
# Single "I" at end is a valid roman numeral suffix
assert strip_roman_suffix("Heracles I") == "Heracles"
def test_round_trip_with_to_roman(self):
base = "Heracles"
for n in range(1, 6):
suffixed = f"{base} {to_roman(n)}"
assert strip_roman_suffix(suffixed) == base

View File

@@ -0,0 +1,31 @@
"""Smoke tests that verify the test infrastructure is working correctly."""
async def test_games_endpoint_returns_empty_list(client):
"""Games endpoint returns an empty list on a clean database."""
response = await client.get("/api/v1/games")
assert response.status_code == 200
assert response.json() == []
async def test_runs_endpoint_returns_empty_list(client):
"""Runs endpoint returns an empty list on a clean database."""
response = await client.get("/api/v1/runs")
assert response.status_code == 200
assert response.json() == []
async def test_pokemon_endpoint_returns_empty_list(client):
"""Pokemon endpoint returns paginated empty result on a clean database."""
response = await client.get("/api/v1/pokemon")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
async def test_database_isolation_between_tests(client):
"""Confirm state from previous tests does not leak into this one."""
response = await client.get("/api/v1/games")
assert response.status_code == 200
assert response.json() == []

670
backend/uv.lock generated Normal file
View File

@@ -0,0 +1,670 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "alembic"
version = "1.18.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/41/ab8f624929847b49f84955c594b165855efd829b0c271e1a8cac694138e5/alembic-1.18.3.tar.gz", hash = "sha256:1212aa3778626f2b0f0aa6dd4e99a5f99b94bd25a0c1ac0bba3be65e081e50b0", size = 2052564, upload-time = "2026-01-29T20:24:15.124Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/8e/d79281f323e7469b060f15bd229e48d7cdd219559e67e71c013720a88340/alembic-1.18.3-py3-none-any.whl", hash = "sha256:12a0359bfc068a4ecbb9b3b02cf77856033abfdb59e4a5aca08b7eacd7b74ddd", size = 262282, upload-time = "2026-01-29T20:24:17.488Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "another-nuzlocke-tracker-api"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = "==1.18.3" },
{ name = "asyncpg", specifier = "==0.31.0" },
{ name = "fastapi", specifier = "==0.128.4" },
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pydantic-settings", specifier = "==2.12.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "python-dotenv", specifier = "==1.2.1" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.46" },
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.17" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.40.0" },
]
provides-extras = ["dev"]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[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 = "fastapi"
version = "0.128.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/b7/21bf3d694cbff0b7cf5f459981d996c2c15e072bd5ca5609806383947f1e/fastapi-0.128.4.tar.gz", hash = "sha256:d6a2cc4c0edfbb2499f3fdec55ba62e751ee58a6354c50f85ed0dabdfbcfeb60", size = 375898, upload-time = "2026-02-07T08:14:09.616Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8b/c8050e556f5d7a1f33a93c2c94379a0bae23c58a79ad9709d7e052d0c3b8/fastapi-0.128.4-py3-none-any.whl", hash = "sha256:9321282cee605fd2075ccbc95c0f2e549d675c59de4a952bba202cd1730ac66b", size = 103684, upload-time = "2026-02-07T08:14:07.939Z" },
]
[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[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.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[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.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[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.12.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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[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/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "starlette"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "ty"
version = "0.0.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" },
{ url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" },
{ url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" },
{ url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" },
{ url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" },
{ url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" },
{ url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" },
{ url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" },
{ url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" },
{ url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ 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.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]

36
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
test-db:
image: postgres:16-alpine
ports:
- "5433:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=nuzlocke_test
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 5s
retries: 10
restart: "no"
test-api:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8100:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@test-db:5432/nuzlocke_test
- DEBUG=true
depends_on:
test-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/ || exit 1"]
interval: 3s
timeout: 5s
retries: 15
restart: "no"

6
frontend/.gitignore vendored
View File

@@ -12,6 +12,12 @@ dist
dist-ssr dist-ssr
*.local *.local
# Playwright
e2e/.fixtures.json
e2e/screenshots/
test-results/
playwright-report/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View File

@@ -0,0 +1,61 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import { loadFixtures } from './fixtures'
const fixtures = loadFixtures()
const pages = [
{ name: 'Home', path: '/' },
{ name: 'RunList', path: '/runs' },
{ name: 'NewRun', path: '/runs/new' },
{ name: 'RunEncounters', path: `/runs/${fixtures.runId}` },
{ name: 'GenlockeList', path: '/genlockes' },
{ name: 'NewGenlocke', path: '/genlockes/new' },
{ name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` },
{ name: 'Stats', path: '/stats' },
{ name: 'AdminGames', path: '/admin/games' },
{ name: 'AdminPokemon', path: '/admin/pokemon' },
{ name: 'AdminEvolutions', path: '/admin/evolutions' },
]
const themes = ['dark', 'light'] as const
for (const theme of themes) {
test.describe(`Accessibility — ${theme} mode`, () => {
test.use({
storageState: undefined,
})
for (const { name, path } of pages) {
test(`${name} (${path}) has no WCAG violations`, async ({ page }) => {
// Set theme before navigation
await page.addInitScript((t) => {
localStorage.setItem('ant-theme', t)
}, theme)
await page.goto(path, { waitUntil: 'networkidle' })
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
const violations = results.violations.map((v) => ({
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.map((n) => ({
html: n.html,
target: n.target,
failureSummary: n.failureSummary,
})),
}))
expect(
violations,
`${name} (${theme}): ${violations.length} violation(s):\n${JSON.stringify(violations, null, 2)}`,
).toHaveLength(0)
})
}
})
}

20
frontend/e2e/fixtures.ts Normal file
View File

@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
interface Fixtures {
gameId: number
runId: number
genlockeId: number
}
let cached: Fixtures | null = null
export function loadFixtures(): Fixtures {
if (cached) return cached
const raw = readFileSync(resolve(__dirname, '.fixtures.json'), 'utf-8')
cached = JSON.parse(raw) as Fixtures
return cached
}

View File

@@ -0,0 +1,124 @@
import { execSync } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const API_HOST = process.env.E2E_API_URL || 'http://localhost:8100'
const API_BASE = `${API_HOST}/api/v1`
const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml')
const COMPOSE = `docker compose -p nuzlocke-test -f ${COMPOSE_FILE}`
const FIXTURES_PATH = resolve(__dirname, '.fixtures.json')
function run(cmd: string): string {
console.log(`[setup] ${cmd}`)
return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'inherit'] })
}
async function waitForApi(url: string, maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await fetch(url)
if (res.ok) return
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 2000))
}
throw new Error(`API at ${url} not ready after ${maxAttempts} attempts`)
}
async function api<T>(
path: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`API ${options?.method ?? 'GET'} ${path}${res.status}: ${body}`)
}
return res.json() as Promise<T>
}
export default async function globalSetup() {
// 1. Start test DB + API
run(`${COMPOSE} up -d --build`)
// 2. Wait for API to be healthy
console.log('[setup] Waiting for API to be ready...')
await waitForApi(`${API_HOST}/`)
// 3. Run migrations
run(`${COMPOSE} exec -T test-api alembic -c /app/alembic.ini upgrade head`)
// 4. Seed reference data (run from /app/src where the app package lives)
run(`${COMPOSE} exec -T -w /app/src test-api python -m app.seeds`)
// 5. Create test fixtures via API
const games = await api<Array<{ id: number; name: string }>>('/games')
const game = games[0]
if (!game) throw new Error('No games found after seeding')
const routes = await api<Array<{ id: number; name: string; parentRouteId: number | null }>>(
`/games/${game.id}/routes?flat=true`,
)
// Pick leaf routes (no children — a route is a leaf if no other route has it as parent)
const parentIds = new Set(routes.map((r) => r.parentRouteId).filter(Boolean))
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
if (leafRoutes.length < 3) throw new Error(`Need ≥3 leaf routes, found ${leafRoutes.length}`)
const pokemonRes = await api<{ items: Array<{ id: number; name: string }> }>(
'/pokemon?limit=10',
)
const pokemon = pokemonRes.items
if (pokemon.length < 3) throw new Error(`Need ≥3 pokemon, found ${pokemon.length}`)
// Create a test run
const testRun = await api<{ id: number }>('/runs', {
method: 'POST',
body: JSON.stringify({
gameId: game.id,
name: 'E2E Test Run',
rules: { duplicatesClause: true, shinyClause: true },
}),
})
// Create encounters: caught, fainted, missed
const statuses = ['caught', 'fainted', 'missed'] as const
for (let i = 0; i < 3; i++) {
await api(`/runs/${testRun.id}/encounters`, {
method: 'POST',
body: JSON.stringify({
routeId: leafRoutes[i]!.id,
pokemonId: pokemon[i]!.id,
nickname: `Test ${statuses[i]}`,
status: statuses[i],
catchLevel: statuses[i] === 'missed' ? null : 5 + i * 10,
}),
})
}
// Create a genlocke with 2 game legs
const secondGame = games[1] ?? game
const genlocke = await api<{ id: number }>('/genlockes', {
method: 'POST',
body: JSON.stringify({
name: 'E2E Test Genlocke',
gameIds: [game.id, secondGame.id],
genlockeRules: {},
nuzlockeRules: { duplicatesClause: true },
}),
})
// 6. Write fixtures file
const fixtures = {
gameId: game.id,
runId: testRun.id,
genlockeId: genlocke.id,
}
writeFileSync(FIXTURES_PATH, JSON.stringify(fixtures, null, 2))
console.log('[setup] Fixtures written:', fixtures)
}

View File

@@ -0,0 +1,24 @@
import { execSync } from 'node:child_process'
import { rmSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml')
const COMPOSE = `docker compose -p nuzlocke-test -f ${COMPOSE_FILE}`
const FIXTURES_PATH = resolve(__dirname, '.fixtures.json')
export default async function globalTeardown() {
console.log('[teardown] Stopping test containers...')
execSync(`${COMPOSE} down -v --remove-orphans`, {
encoding: 'utf-8',
stdio: 'inherit',
})
try {
rmSync(FIXTURES_PATH)
console.log('[teardown] Removed fixtures file')
} catch {
// File may not exist if setup failed
}
}

View File

@@ -0,0 +1,71 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import { loadFixtures } from './fixtures'
const fixtures = loadFixtures()
const pages = [
{ name: 'Home', path: '/' },
{ name: 'RunList', path: '/runs' },
{ name: 'NewRun', path: '/runs/new' },
{ name: 'RunEncounters', path: `/runs/${fixtures.runId}` },
{ name: 'GenlockeList', path: '/genlockes' },
{ name: 'NewGenlocke', path: '/genlockes/new' },
{ name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` },
{ name: 'Stats', path: '/stats' },
{ name: 'AdminGames', path: '/admin/games' },
{ name: 'AdminPokemon', path: '/admin/pokemon' },
{ name: 'AdminEvolutions', path: '/admin/evolutions' },
]
const viewports = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 },
] as const
for (const viewport of viewports) {
test.describe(`Mobile layout — ${viewport.name} (${viewport.width}x${viewport.height})`, () => {
test.use({
viewport: { width: viewport.width, height: viewport.height },
})
for (const { name, path } of pages) {
test(`${name} (${path}) has no overflow or touch target issues`, async ({ page }) => {
await page.goto(path, { waitUntil: 'networkidle' })
// Assert no horizontal overflow
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}))
expect(
overflow.scrollWidth,
`${name} at ${viewport.name}: horizontal overflow detected (scrollWidth=${overflow.scrollWidth}, innerWidth=${overflow.innerWidth})`,
).toBeLessThanOrEqual(overflow.innerWidth)
// Run axe-core target-size rule for touch target validation
const axeResults = await new AxeBuilder({ page })
.withRules(['target-size'])
.analyze()
const violations = axeResults.violations.map((v) => ({
id: v.id,
impact: v.impact,
nodes: v.nodes.length,
}))
expect(
violations,
`${name} at ${viewport.name}: ${violations.length} touch target violations:\n${JSON.stringify(violations, null, 2)}`,
).toHaveLength(0)
// Capture full-page screenshot
await page.screenshot({
path: `e2e/screenshots/${viewport.name}/${name.toLowerCase()}.png`,
fullPage: true,
})
})
}
})
}

View File

@@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nuzlocke Tracker</title> <title>ANT - Another Nuzlocke Tracker</title>
<meta name="description" content="Track your Nuzlocke challenge runs across all Pokemon games" /> <meta name="description" content="Track your Nuzlocke challenge runs across all Pokemon games" />
<meta name="theme-color" content="#DC2626" /> <meta name="theme-color" content="#DC2626" />
<meta property="og:title" content="Nuzlocke Tracker" /> <meta property="og:title" content="ANT - Another Nuzlocke Tracker" />
<meta property="og:description" content="Track your Nuzlocke challenge runs across all Pokemon games" /> <meta property="og:description" content="Track your Nuzlocke challenge runs across all Pokemon games" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@@ -17,6 +17,15 @@
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
</head> </head>
<body> <body>
<script>
(function() {
var t = localStorage.getItem('ant-theme');
if (t === 'light' || (!t && window.matchMedia('(prefers-color-scheme: light)').matches)) {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.style.colorScheme = 'light';
}
})();
</script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name": "nuzlocke-tracker-frontend", "name": "another-nuzlocke-tracker",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
@@ -11,7 +11,9 @@
"format:check": "oxfmt --check src/", "format:check": "oxfmt --check src/",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
@@ -24,11 +26,17 @@
"sonner": "2.0.7" "sonner": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.11.1",
"@playwright/test": "1.58.2",
"@tailwindcss/vite": "4.1.18", "@tailwindcss/vite": "4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "24.10.10", "@types/node": "24.10.10",
"@types/react": "19.2.11", "@types/react": "19.2.11",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.3", "@vitejs/plugin-react": "5.1.3",
"jsdom": "^28.1.0",
"oxfmt": "0.33.0", "oxfmt": "0.33.0",
"oxlint": "1.48.0", "oxlint": "1.48.0",
"tailwindcss": "4.1.18", "tailwindcss": "4.1.18",

View File

@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,27 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- Pokeball top (red) --> <path d="M0,0 L54,0 L95,20 L123,34 L146,45 L168,56 L179,61 L183,78 L184,84 L184,110 L177,120 L163,143 L149,165 L136,186 L121,210 L107,233 L102,249 L95,275 L90,298 L85,328 L82,354 L68,362 L41,377 L28,384 L23,383 L0,370 L-17,361 L-28,354 L-31,331 L-34,307 L-39,282 L-45,258 L-50,241 L-54,230 L-65,213 L-79,191 L-91,172 L-103,153 L-117,131 L-129,112 L-130,111 L-129,86 L-124,61 L-108,53 L-70,34 L-47,23 L-14,7 Z " fill="#EAEEF3" transform="translate(485,266)"/>
<path d="M32 4C16.536 4 4 16.536 4 32h56C60 16.536 47.464 4 32 4z" fill="#DC2626"/> <path d="M0,0 L3,0 L-1,22 L-2,47 L8,62 L23,86 L37,108 L49,127 L61,146 L75,168 L81,187 L90,222 L95,249 L97,267 L99,285 L99,290 L87,299 L66,315 L52,326 L39,336 L26,346 L13,356 L-5,370 L-17,379 L-21,377 L-32,364 L-44,348 L-58,324 L-66,308 L-74,289 L-79,273 L-84,256 L-87,235 L-88,232 L-89,220 L-89,184 L-85,150 L-79,124 L-68,91 L-56,63 L-40,30 L-33,20 L-23,12 L-7,4 Z " fill="#2B3E4C" transform="translate(358,330)"/>
<!-- Pokeball bottom (white) --> <path d="M0,0 L9,3 L28,13 L35,20 L43,33 L58,64 L68,88 L77,113 L84,138 L88,160 L90,180 L90,212 L88,234 L83,257 L79,272 L72,292 L63,312 L52,333 L41,350 L28,367 L21,375 L16,374 L-3,360 L-20,347 L-32,338 L-49,325 L-62,315 L-80,301 L-96,289 L-99,287 L-95,254 L-89,220 L-81,189 L-76,172 L-72,162 L-62,146 L-50,127 L-37,106 L-22,82 L-7,58 L2,44 L3,43 L3,17 Z " fill="#395E73" transform="translate(665,333)"/>
<path d="M4 32c0 15.464 12.536 28 28 28s28-12.536 28-28H4z" fill="#F5F5F5"/> <path d="M0,0 L9,0 L64,20 L72,23 L78,23 L97,16 L128,5 L140,0 L148,0 L157,6 L175,19 L191,30 L208,42 L228,56 L237,62 L241,68 L242,71 L242,96 L236,106 L223,119 L216,127 L210,133 L206,131 L193,121 L181,112 L178,111 L167,111 L157,120 L146,131 L139,139 L115,163 L108,171 L99,175 L52,175 L44,171 L-11,116 L-11,114 L-15,112 L-18,111 L-28,111 L-37,117 L-54,131 L-59,133 L-82,110 L-90,100 L-91,97 L-91,69 L-87,63 L-77,55 L-60,43 L-41,29 L-24,17 L-6,4 Z " fill="#727B86" transform="translate(437,657)"/>
<!-- Center band --> <path d="M0,0 L27,0 L27,384 L22,382 L-10,365 L-27,355 L-29,349 L-31,331 L-34,307 L-39,282 L-45,258 L-50,241 L-54,230 L-65,213 L-79,191 L-91,172 L-103,153 L-117,131 L-129,112 L-130,111 L-129,86 L-124,61 L-108,53 L-70,34 L-47,23 L-14,7 Z " fill="#A4ACB5" transform="translate(485,266)"/>
<rect x="4" y="29" width="56" height="6" fill="#1F2937"/> <path d="M0,0 L8,0 L17,6 L35,19 L51,30 L68,42 L88,56 L97,62 L101,68 L102,71 L102,96 L96,106 L83,119 L76,127 L70,133 L66,131 L53,121 L41,112 L38,111 L27,111 L17,120 L6,131 L-1,139 L-25,163 L-32,171 L-38,174 L-65,174 L-65,24 L-40,15 L-7,3 Z " fill="#8B9AA6" transform="translate(577,657)"/>
<!-- Center circle (dark background for skull) --> <path d="M0,0 L11,0 L21,6 L27,15 L27,27 L26,34 L32,44 L47,73 L56,94 L66,122 L71,137 L76,154 L82,174 L89,195 L98,235 L105,273 L106,288 L106,317 L110,319 L114,325 L114,335 L108,352 L104,362 L99,367 L94,369 L86,369 L70,361 L66,356 L64,353 L64,343 L68,333 L74,318 L80,311 L85,310 L83,277 L78,246 L72,218 L68,203 L62,183 L58,172 L52,150 L44,125 L36,103 L25,77 L16,61 L8,48 L8,46 L-2,45 L-9,41 L-14,35 L-17,28 L-17,18 L-13,9 L-5,2 Z " fill="#A4ACB5" transform="translate(764,142)"/>
<circle cx="32" cy="32" r="12" fill="#1F2937"/> <path d="M0,0 L9,0 L17,4 L24,12 L26,17 L26,29 L22,37 L15,43 L11,45 L1,45 L-1,50 L-9,66 L-19,87 L-30,116 L-36,133 L-40,146 L-47,170 L-52,184 L-57,202 L-62,218 L-67,239 L-72,270 L-73,281 L-73,310 L-67,314 L-63,319 L-54,343 L-54,351 L-58,359 L-65,364 L-77,369 L-86,369 L-92,365 L-97,355 L-102,341 L-104,337 L-104,325 L-100,319 L-96,316 L-95,272 L-88,233 L-79,195 L-66,159 L-61,142 L-55,122 L-46,96 L-34,68 L-23,46 L-16,35 L-18,28 L-18,16 L-12,6 L-4,1 Z " fill="#A5ACB6" transform="translate(250,142)"/>
<!-- Skull --> <path d="M0,0 L12,0 L22,4 L30,10 L38,18 L45,29 L50,41 L52,49 L52,70 L47,80 L43,85 L33,90 L30,91 L20,91 L6,84 L-4,75 L-12,65 L-19,49 L-21,38 L-21,24 L-18,15 L-12,7 L-3,1 Z " fill="#7EB0CE" transform="translate(345,518)"/>
<g transform="translate(32, 30)"> <path d="M0,0 L17,0 L26,6 L32,13 L36,22 L36,36 L34,48 L28,61 L21,72 L12,81 L2,87 L-3,89 L-18,89 L-24,86 L-30,81 L-35,71 L-37,65 L-37,50 L-33,35 L-27,23 L-19,13 L-10,5 Z " fill="#7EB1CF" transform="translate(664,519)"/>
<!-- Skull cranium --> <path d="M0,0 L2,0 L2,12 L7,23 L2,26 L-10,37 L-18,44 L-30,55 L-40,64 L-48,71 L-58,80 L-68,89 L-76,96 L-86,105 L-104,123 L-109,127 L-119,142 L-124,149 L-144,140 L-150,137 L-146,130 L-137,119 L-126,107 L-119,100 L-111,93 L-99,82 L-88,73 L-75,61 L-64,52 L-51,40 L-40,31 L-27,20 L-7,5 Z " fill="#304250" transform="translate(746,158)"/>
<ellipse cx="0" cy="0" rx="8" ry="7.5" fill="#F5F5F5"/> <path d="M0,0 L9,5 L25,17 L39,28 L52,39 L66,51 L78,62 L89,71 L103,84 L111,91 L123,102 L136,115 L147,129 L151,135 L151,137 L153,138 L128,150 L124,145 L115,132 L111,125 L100,114 L92,107 L85,100 L77,93 L64,81 L56,74 L45,64 L37,57 L23,45 L12,36 L3,28 L-1,25 L-7,26 L-1,17 L0,14 Z " fill="#304250" transform="translate(275,157)"/>
<!-- Left eye --> <path d="M0,0 L5,0 L23,14 L35,23 L53,37 L70,50 L89,64 L106,77 L118,86 L121,88 L113,96 L111,96 L110,100 L110,98 L105,96 L87,83 L68,70 L54,60 L34,46 L23,38 L21,37 L13,37 L-13,47 L-47,59 L-49,60 L-55,60 L-53,59 L-52,29 L-36,20 L-7,4 Z " fill="#333E46" transform="translate(564,621)"/>
<ellipse cx="-3" cy="-1" rx="2.2" ry="2.5" fill="#1F2937"/> <path d="M0,0 L5,2 L29,15 L52,28 L56,30 L56,57 L55,60 L48,59 L6,44 L-10,38 L-19,38 L-36,50 L-54,63 L-68,73 L-86,86 L-104,99 L-107,99 L-115,91 L-117,89 L-111,85 L-93,71 L-75,57 L-58,44 L-45,34 L-32,24 L-14,10 Z " fill="#222A31" transform="translate(456,620)"/>
<!-- Right eye --> <path d="M0,0 L4,1 L19,13 L21,14 L20,18 L4,35 L-12,52 L-20,60 L-27,68 L-36,77 L-43,85 L-51,92 L-57,95 L-96,95 L-96,63 L-64,62 L-56,58 L-43,45 L-36,37 L-23,24 L-21,21 L-19,21 L-17,17 Z " fill="#3A5E73" transform="translate(608,788)"/>
<ellipse cx="3" cy="-1" rx="2.2" ry="2.5" fill="#1F2937"/> <path d="M0,0 L4,2 L52,50 L60,57 L65,61 L68,62 L99,63 L99,95 L62,95 L52,89 L40,77 L33,69 L-4,32 L-11,24 L-18,17 L-16,13 Z " fill="#2B3D4B" transform="translate(413,788)"/>
<!-- Nose --> <path d="M0,0 L1,0 L2,20 L8,24 L12,29 L21,53 L21,61 L17,69 L10,74 L-2,79 L-11,79 L-17,75 L-22,65 L-27,51 L-29,47 L-29,35 L-25,29 L-18,24 L-6,20 L0,20 Z " fill="#7F8B97" transform="translate(175,432)"/>
<path d="M-0.8 3 L0.8 3 L0 4.2z" fill="#1F2937"/> <path d="M0,0 L1,0 L1,29 L5,31 L9,37 L9,47 L3,64 L-1,74 L-6,79 L-11,81 L-19,81 L-35,73 L-39,68 L-41,65 L-41,55 L-37,45 L-31,30 L-25,23 L-23,22 L-15,22 L-2,27 L0,28 Z " fill="#7F8B96" transform="translate(869,430)"/>
<!-- Teeth -->
<rect x="-4.5" y="5.5" width="9" height="3.5" rx="0.8" fill="#F5F5F5" stroke="#1F2937" stroke-width="0.8"/>
<line x1="-1.5" y1="5.5" x2="-1.5" y2="9" stroke="#1F2937" stroke-width="0.6"/>
<line x1="1.5" y1="5.5" x2="1.5" y2="9" stroke="#1F2937" stroke-width="0.6"/>
</g>
<!-- Outline -->
<circle cx="32" cy="32" r="28" fill="none" stroke="#1F2937" stroke-width="2.5"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,92 @@
Copyright (c) 2023 Vercel, in collaboration with basement.studio
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,6 +1,6 @@
{ {
"name": "Nuzlocke Tracker", "name": "Another Nuzlocke Tracker",
"short_name": "Nuzlocke", "short_name": "ANT",
"icons": [ "icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }

View File

@@ -13,10 +13,12 @@ export function getGame(id: number): Promise<GameDetail> {
return api.get(`/games/${id}`) return api.get(`/games/${id}`)
} }
export function getGameRoutes(gameId: number): Promise<Route[]> { export function getGameRoutes(gameId: number, allowedTypes?: string[]): Promise<Route[]> {
// Use flat=true to get all routes in a flat list // Use flat=true to get all routes in a flat list
// The frontend organizes them into hierarchy based on parentRouteId // The frontend organizes them into hierarchy based on parentRouteId
return api.get(`/games/${gameId}/routes?flat=true`) const params = new URLSearchParams({ flat: 'true' })
for (const t of allowedTypes ?? []) params.append('allowed_types', t)
return api.get(`/games/${gameId}/routes?${params}`)
} }
export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> { export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {

View File

@@ -66,15 +66,15 @@ export function BossDefeatModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} /> <div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4"> <div className="relative bg-surface-1 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="px-6 py-4 border-b border-border-default">
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2> <h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{boss.location}</p> <p className="text-sm text-text-tertiary">{boss.location}</p>
</div> </div>
{/* Boss team preview */} {/* Boss team preview */}
{boss.pokemon.length > 0 && ( {boss.pokemon.length > 0 && (
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700"> <div className="px-6 py-3 border-b border-border-default">
{showPills && ( {showPills && (
<div className="flex gap-1 mb-2 flex-wrap"> <div className="flex gap-1 mb-2 flex-wrap">
{variantLabels.map((label) => ( {variantLabels.map((label) => (
@@ -84,8 +84,8 @@ export function BossDefeatModal({
onClick={() => setSelectedVariant(label)} onClick={() => setSelectedVariant(label)}
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${ className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${
selectedVariant === label selectedVariant === label
? 'bg-blue-600 text-white' ? 'bg-accent-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' : 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`} }`}
> >
{label} {label}
@@ -101,14 +101,10 @@ export function BossDefeatModal({
{bp.pokemon.spriteUrl ? ( {bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" /> <img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
) : ( ) : (
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" /> <div className="w-10 h-10 bg-surface-3 rounded-full" />
)} )}
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize"> <span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
{bp.pokemon.name} <span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
</span>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
Lv.{bp.level}
</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" /> <ConditionBadge condition={bp.conditionLabel} size="xs" />
</div> </div>
))} ))}
@@ -127,7 +123,7 @@ export function BossDefeatModal({
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${ className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
result === 'won' result === 'won'
? 'bg-green-600 text-white border-green-600' ? 'bg-green-600 text-white border-green-600'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700' : 'border-border-default hover:bg-surface-2'
}`} }`}
> >
Won Won
@@ -139,7 +135,7 @@ export function BossDefeatModal({
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${ className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
result === 'lost' result === 'lost'
? 'bg-red-600 text-white border-red-600' ? 'bg-red-600 text-white border-red-600'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700' : 'border-border-default hover:bg-surface-2'
}`} }`}
> >
Lost Lost
@@ -156,24 +152,24 @@ export function BossDefeatModal({
min={1} min={1}
value={attempts} value={attempts}
onChange={(e) => setAttempts(e.target.value)} onChange={(e) => setAttempts(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
)} )}
</div> </div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3"> <div className="px-6 py-4 border-t border-border-default flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700" className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={isPending} disabled={isPending}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
> >
{isPending ? 'Saving...' : 'Save Result'} {isPending ? 'Saving...' : 'Save Result'}
</button> </button>

View File

@@ -1,19 +1,19 @@
const CONDITION_CONFIG: Record<string, { label: string; color: string }> = { const CONDITION_CONFIG: Record<string, { label: string; color: string }> = {
'Mega Evolution': { 'Mega Evolution': {
label: 'Mega', label: 'Mega',
color: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-900/40 dark:text-fuchsia-300', color: 'bg-fuchsia-900/40 text-fuchsia-300 light:bg-fuchsia-100 light:text-fuchsia-700',
}, },
Gigantamax: { Gigantamax: {
label: 'G-Max', label: 'G-Max',
color: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', color: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-700',
}, },
Dynamax: { Dynamax: {
label: 'D-Max', label: 'D-Max',
color: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300', color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
}, },
Terastallize: { Terastallize: {
label: 'Tera', label: 'Tera',
color: 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300', color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
}, },
} }

View File

@@ -76,17 +76,14 @@ export function EggEncounterModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} /> <div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto"> <div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-green-300 dark:border-green-600 px-6 py-4 rounded-t-xl"> <div className="sticky top-0 bg-surface-1 border-b border-green-600 px-6 py-4 rounded-t-xl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2"> <h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<span className="text-green-500">&#x1F95A;</span> <span className="text-green-500">&#x1F95A;</span>
Log Egg Hatch Log Egg Hatch
</h2> </h2>
<button <button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -97,7 +94,7 @@ export function EggEncounterModal({
</svg> </svg>
</button> </button>
</div> </div>
<p className="text-sm text-green-600 dark:text-green-400 mt-1"> <p className="text-sm text-status-active mt-1">
Egg hatches bypass the one-per-route rule Egg hatches bypass the one-per-route rule
</p> </p>
</div> </div>
@@ -105,13 +102,13 @@ export function EggEncounterModal({
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
{/* Route selector */} {/* Route selector */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-text-secondary mb-1">
Hatch Location Hatch Location
</label> </label>
<select <select
value={selectedRouteId ?? ''} value={selectedRouteId ?? ''}
onChange={(e) => setSelectedRouteId(Number(e.target.value))} onChange={(e) => setSelectedRouteId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-green-500"
> >
<option value="">Select a location...</option> <option value="">Select a location...</option>
{leafRoutes.map((r) => ( {leafRoutes.map((r) => (
@@ -124,11 +121,9 @@ export function EggEncounterModal({
{/* Pokemon search */} {/* Pokemon search */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
Pokemon
</label>
{selectedPokemon ? ( {selectedPokemon ? (
<div className="flex items-center gap-3 p-3 rounded-lg border border-green-300 dark:border-green-600 bg-green-50 dark:bg-green-900/20"> <div className="flex items-center gap-3 p-3 rounded-lg border border-green-600 bg-green-900/20">
{selectedPokemon.spriteUrl ? ( {selectedPokemon.spriteUrl ? (
<img <img
src={selectedPokemon.spriteUrl} src={selectedPokemon.spriteUrl}
@@ -136,11 +131,11 @@ export function EggEncounterModal({
className="w-10 h-10" className="w-10 h-10"
/> />
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold"> <div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{selectedPokemon.name[0]?.toUpperCase()} {selectedPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<span className="font-medium text-gray-900 dark:text-gray-100 capitalize"> <span className="font-medium text-text-primary capitalize">
{selectedPokemon.name} {selectedPokemon.name}
</span> </span>
<button <button
@@ -149,7 +144,7 @@ export function EggEncounterModal({
setSearch('') setSearch('')
setSearchResults([]) setSearchResults([])
}} }}
className="ml-auto text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" className="ml-auto text-sm text-text-tertiary hover:text-text-secondary"
> >
Change Change
</button> </button>
@@ -161,7 +156,7 @@ export function EggEncounterModal({
placeholder="Search pokemon by name..." placeholder="Search pokemon by name..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-green-500"
/> />
{isSearching && ( {isSearching && (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
@@ -175,16 +170,16 @@ export function EggEncounterModal({
key={p.id} key={p.id}
type="button" type="button"
onClick={() => setSelectedPokemon(p)} onClick={() => setSelectedPokemon(p)}
className="flex flex-col items-center p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 text-center transition-colors" className="flex flex-col items-center p-2 rounded-lg border border-border-default hover:border-green-600 text-center transition-colors"
> >
{p.spriteUrl ? ( {p.spriteUrl ? (
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" /> <img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold"> <div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{p.name[0]?.toUpperCase()} {p.name[0]?.toUpperCase()}
</div> </div>
)} )}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize"> <span className="text-xs text-text-secondary mt-1 capitalize">
{p.name} {p.name}
</span> </span>
</button> </button>
@@ -192,7 +187,7 @@ export function EggEncounterModal({
</div> </div>
)} )}
{search.length >= 2 && !isSearching && searchResults.length === 0 && ( {search.length >= 2 && !isSearching && searchResults.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">No pokemon found</p> <p className="text-sm text-text-tertiary py-2">No pokemon found</p>
)} )}
</> </>
)} )}
@@ -203,7 +198,7 @@ export function EggEncounterModal({
<div> <div>
<label <label
htmlFor="egg-nickname" htmlFor="egg-nickname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Nickname Nickname
</label> </label>
@@ -213,7 +208,7 @@ export function EggEncounterModal({
value={nickname} value={nickname}
onChange={(e) => setNickname(e.target.value)} onChange={(e) => setNickname(e.target.value)}
placeholder="Give it a name..." placeholder="Give it a name..."
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-green-500"
/> />
</div> </div>
)} )}
@@ -223,7 +218,7 @@ export function EggEncounterModal({
<div> <div>
<label <label
htmlFor="egg-catch-level" htmlFor="egg-catch-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Hatch Level Hatch Level
</label> </label>
@@ -235,17 +230,17 @@ export function EggEncounterModal({
value={catchLevel} value={catchLevel}
onChange={(e) => setCatchLevel(e.target.value)} onChange={(e) => setCatchLevel(e.target.value)}
placeholder="1" placeholder="1"
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500" className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-green-500"
/> />
</div> </div>
)} )}
</div> </div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3"> <div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" className="px-4 py-2 text-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 transition-colors"
> >
Cancel Cancel
</button> </button>

View File

@@ -1,55 +1,59 @@
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = { export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
starter: { starter: {
label: 'Starter', label: 'Starter',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300', color: 'bg-yellow-900/40 text-yellow-300 light:bg-yellow-100 light:text-yellow-800',
}, },
gift: { gift: {
label: 'Gift', label: 'Gift',
color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300', color: 'bg-pink-900/40 text-pink-300 light:bg-pink-100 light:text-pink-700',
}, },
fossil: { fossil: {
label: 'Fossil', label: 'Fossil',
color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', color: 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-800',
}, },
trade: { trade: {
label: 'Trade', label: 'Trade',
color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300', color: 'bg-emerald-900/40 text-emerald-300 light:bg-emerald-100 light:text-emerald-700',
},
static: {
label: 'Static',
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
}, },
walk: { walk: {
label: 'Grass', label: 'Grass',
color: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', color: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-700',
}, },
headbutt: { headbutt: {
label: 'Headbutt', label: 'Headbutt',
color: 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300', color: 'bg-lime-900/40 text-lime-300 light:bg-lime-100 light:text-lime-800',
}, },
surf: { surf: {
label: 'Surfing', label: 'Surfing',
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', color: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700',
}, },
'rock-smash': { 'rock-smash': {
label: 'Rock Smash', label: 'Rock Smash',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300', color: 'bg-orange-900/40 text-orange-300 light:bg-orange-100 light:text-orange-800',
}, },
'old-rod': { 'old-rod': {
label: 'Old Rod', label: 'Old Rod',
color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300', color: 'bg-cyan-900/40 text-cyan-300 light:bg-cyan-100 light:text-cyan-700',
}, },
'good-rod': { 'good-rod': {
label: 'Good Rod', label: 'Good Rod',
color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300', color: 'bg-sky-900/40 text-sky-300 light:bg-sky-100 light:text-sky-700',
}, },
'super-rod': { 'super-rod': {
label: 'Super Rod', label: 'Super Rod',
color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300', color: 'bg-indigo-900/40 text-indigo-300 light:bg-indigo-100 light:text-indigo-700',
}, },
horde: { horde: {
label: 'Horde', label: 'Horde',
color: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300', color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
}, },
sos: { sos: {
label: 'SOS', label: 'SOS',
color: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300', color: 'bg-violet-900/40 text-violet-300 light:bg-violet-100 light:text-violet-700',
}, },
} }
@@ -59,6 +63,7 @@ export const METHOD_ORDER = [
'gift', 'gift',
'fossil', 'fossil',
'trade', 'trade',
'static',
'walk', 'walk',
'headbutt', 'headbutt',
'surf', 'surf',
@@ -78,9 +83,7 @@ export function getMethodLabel(method: string): string {
} }
export function getMethodColor(method: string): string { export function getMethodColor(method: string): string {
return ( return METHOD_CONFIG[method]?.color ?? 'bg-surface-3 text-text-secondary'
METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
)
} }
export function EncounterMethodBadge({ export function EncounterMethodBadge({
@@ -92,7 +95,7 @@ export function EncounterMethodBadge({
}) { }) {
const config = METHOD_CONFIG[method] const config = METHOD_CONFIG[method]
if (!config) return null if (!config) return null
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5' const sizeClass = size === 'xs' ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-0.5'
return ( return (
<span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}> <span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}>
{config.label} {config.label}

View File

@@ -1,8 +1,15 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { api } from '../api/client'
import { useRoutePokemon } from '../hooks/useGames' import { useRoutePokemon } from '../hooks/useGames'
import { useNameSuggestions } from '../hooks/useRuns' import { useNameSuggestions } from '../hooks/useRuns'
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge' import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
import type { Route, EncounterDetail, EncounterStatus, RouteEncounterDetail } from '../types' import type {
Route,
EncounterDetail,
EncounterStatus,
RouteEncounterDetail,
Pokemon,
} from '../types'
interface EncounterModalProps { interface EncounterModalProps {
route: Route route: Route
@@ -19,6 +26,7 @@ interface EncounterModalProps {
nickname?: string | undefined nickname?: string | undefined
status: EncounterStatus status: EncounterStatus
catchLevel?: number | undefined catchLevel?: number | undefined
origin?: string | undefined
}) => void }) => void
onUpdate?: onUpdate?:
| ((data: { | ((data: {
@@ -33,6 +41,9 @@ interface EncounterModalProps {
| undefined | undefined
onClose: () => void onClose: () => void
isPending: boolean isPending: boolean
useAllPokemon?: boolean | undefined
staticClause?: boolean | undefined
allowedTypes?: string[] | undefined
} }
const statusOptions: { const statusOptions: {
@@ -44,19 +55,18 @@ const statusOptions: {
value: 'caught', value: 'caught',
label: 'Caught', label: 'Caught',
color: color:
'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700', 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800 border-green-700 light:border-green-300',
}, },
{ {
value: 'fainted', value: 'fainted',
label: 'Fainted', label: 'Fainted',
color: color:
'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700', 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800 border-red-700 light:border-red-300',
}, },
{ {
value: 'missed', value: 'missed',
label: 'Missed / Ran', label: 'Missed / Ran',
color: color: 'bg-surface-2 text-text-primary border-border-default',
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
}, },
] ]
@@ -124,7 +134,8 @@ function groupByMethod(
} else { } else {
// Determine the display rate // Determine the display rate
let displayRate: number | null = null let displayRate: number | null = null
const isSpecial = SPECIAL_METHODS.includes(rp.encounterMethod) const isSpecial =
SPECIAL_METHODS.includes(rp.encounterMethod) || rp.encounterMethod === 'static'
if (!isSpecial) { if (!isSpecial) {
if (selectedCondition) { if (selectedCondition) {
const key = `${rp.pokemonId}:${rp.encounterMethod}` const key = `${rp.pokemonId}:${rp.encounterMethod}`
@@ -189,8 +200,14 @@ export function EncounterModal({
onUpdate, onUpdate,
onClose, onClose,
isPending, isPending,
useAllPokemon,
staticClause = true,
allowedTypes,
}: EncounterModalProps) { }: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(route.id, gameId) const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
useAllPokemon ? null : route.id,
useAllPokemon ? undefined : gameId
)
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null) const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught') const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
@@ -200,6 +217,8 @@ export function EncounterModal({
const [deathCause, setDeathCause] = useState('') const [deathCause, setDeathCause] = useState('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedCondition, setSelectedCondition] = useState<string | null>(null) const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
const [allPokemonResults, setAllPokemonResults] = useState<Pokemon[]>([])
const [isSearchingAll, setIsSearchingAll] = useState(false)
const isEditing = !!existing const isEditing = !!existing
@@ -219,13 +238,41 @@ export function EncounterModal({
} }
}, [existing, routePokemon]) }, [existing, routePokemon])
// Debounced all-Pokemon search (variant rules)
useEffect(() => {
if (!useAllPokemon) return
if (search.length < 2) {
setAllPokemonResults([])
return
}
const timer = setTimeout(async () => {
setIsSearchingAll(true)
try {
const data = await api.get<{ items: Pokemon[] }>(
`/pokemon?search=${encodeURIComponent(search)}&limit=20`
)
setAllPokemonResults(data.items)
} catch {
setAllPokemonResults([])
} finally {
setIsSearchingAll(false)
}
}, 300)
return () => clearTimeout(timer)
}, [search, useAllPokemon])
const availableConditions = useMemo( const availableConditions = useMemo(
() => (routePokemon ? getUniqueConditions(routePokemon) : []), () => (routePokemon ? getUniqueConditions(routePokemon) : []),
[routePokemon] [routePokemon]
) )
const filteredPokemon = routePokemon?.filter((rp) => const filteredPokemon = routePokemon?.filter(
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()) (rp) =>
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()) &&
(!allowedTypes?.length || rp.pokemon.types.some((t) => allowedTypes.includes(t)))
) )
const groupedPokemon = useMemo( const groupedPokemon = useMemo(
@@ -252,6 +299,7 @@ export function EncounterModal({
nickname: nickname || undefined, nickname: nickname || undefined,
status, status,
catchLevel: catchLevel ? Number(catchLevel) : undefined, catchLevel: catchLevel ? Number(catchLevel) : undefined,
origin: SPECIAL_METHODS.includes(selectedPokemon.encounterMethod) ? 'gift' : undefined,
}) })
} }
} }
@@ -261,16 +309,13 @@ export function EncounterModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} /> <div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto"> <div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl"> <div className="sticky top-0 bg-surface-1 border-b border-border-default px-6 py-4 rounded-t-xl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-semibold text-text-primary">
{isEditing ? 'Edit Encounter' : 'Log Encounter'} {isEditing ? 'Edit Encounter' : 'Log Encounter'}
</h2> </h2>
<button <button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -281,17 +326,118 @@ export function EncounterModal({
</svg> </svg>
</button> </button>
</div> </div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{route.name}</p> <p className="text-sm text-text-tertiary mt-1">{route.name}</p>
</div> </div>
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
{/* Pokemon Selection (only for new encounters) */} {/* Pokemon Selection (only for new encounters) */}
{!isEditing && ( {!isEditing && useAllPokemon && (
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
{selectedPokemon ? (
<div className="flex items-center gap-3 p-3 rounded-lg border border-accent-400 bg-accent-900/20">
{selectedPokemon.pokemon.spriteUrl ? (
<img
src={selectedPokemon.pokemon.spriteUrl}
alt={selectedPokemon.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{selectedPokemon.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="font-medium text-text-primary capitalize">
{selectedPokemon.pokemon.name}
</span>
<button
onClick={() => {
setSelectedPokemon(null)
setSearch('')
setAllPokemonResults([])
}}
className="ml-auto text-sm text-text-tertiary hover:text-text-secondary"
>
Change
</button>
</div>
) : (
<>
<input
type="text"
placeholder="Search all pokemon by name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
{isSearchingAll && (
<div className="flex items-center justify-center py-4">
<div className="w-6 h-6 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
{allPokemonResults.length > 0 && (
<div className="mt-2 max-h-64 overflow-y-auto grid grid-cols-3 gap-2">
{allPokemonResults.map((p) => {
const isDuped = dupedPokemonIds?.has(p.id) ?? false
return (
<button
key={p.id}
type="button"
onClick={() => {
if (!isDuped) {
setSelectedPokemon({
id: 0,
routeId: 0,
gameId: 0,
pokemonId: p.id,
pokemon: p,
encounterMethod: 'walking',
encounterRate: 0,
condition: '',
minLevel: 1,
maxLevel: 100,
})
}
}}
disabled={isDuped}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped
? 'opacity-40 cursor-not-allowed border-border-default'
: 'border-border-default hover:border-accent-400'
}`}
>
{p.spriteUrl ? (
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{p.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-text-secondary mt-1 capitalize">
{p.name}
</span>
{isDuped && (
<span className="text-[10px] text-text-tertiary italic">
{retiredPokemonIds?.has(p.id) ? 'retired (HoF)' : 'already caught'}
</span>
)}
</button>
)
})}
</div>
)}
{search.length >= 2 && !isSearchingAll && allPokemonResults.length === 0 && (
<p className="text-sm text-text-tertiary py-2">No pokemon found</p>
)}
</>
)}
</div>
)}
{!isEditing && !useAllPokemon && (
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-text-secondary">Pokemon</label>
Pokemon
</label>
{!loadingPokemon && routePokemon && routePokemon.length > 0 && ( {!loadingPokemon && routePokemon && routePokemon.length > 0 && (
<button <button
type="button" type="button"
@@ -304,10 +450,17 @@ export function EncounterModal({
} }
onClick={() => { onClick={() => {
if (routePokemon) { if (routePokemon) {
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds)) const eligible = routePokemon
.filter((rp) => staticClause || rp.encounterMethod !== 'static')
.filter(
(rp) =>
!allowedTypes?.length ||
rp.pokemon.types.some((t) => allowedTypes.includes(t))
)
setSelectedPokemon(pickRandomPokemon(eligible, dupedPokemonIds))
} }
}} }}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
{selectedPokemon ? 'Re-roll' : 'Randomize'} {selectedPokemon ? 'Re-roll' : 'Randomize'}
</button> </button>
@@ -315,7 +468,7 @@ export function EncounterModal({
</div> </div>
{loadingPokemon ? ( {loadingPokemon ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" /> <div className="w-10 h-10 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
</div> </div>
) : filteredPokemon && filteredPokemon.length > 0 ? ( ) : filteredPokemon && filteredPokemon.length > 0 ? (
<> <>
@@ -325,7 +478,7 @@ export function EncounterModal({
placeholder="Search pokemon..." placeholder="Search pokemon..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-1.5 mb-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-400"
/> />
)} )}
{availableConditions.length > 0 && ( {availableConditions.length > 0 && (
@@ -335,8 +488,8 @@ export function EncounterModal({
onClick={() => setSelectedCondition(null)} onClick={() => setSelectedCondition(null)}
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors ${ className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors ${
selectedCondition === null selectedCondition === null
? 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300' ? 'bg-purple-900/40 border-purple-600 text-purple-300'
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-300 dark:hover:border-purple-600' : 'border-border-default text-text-tertiary hover:border-purple-600'
}`} }`}
> >
All All
@@ -348,8 +501,8 @@ export function EncounterModal({
onClick={() => setSelectedCondition(cond)} onClick={() => setSelectedCondition(cond)}
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors capitalize ${ className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors capitalize ${
selectedCondition === cond selectedCondition === cond
? 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300' ? 'bg-purple-900/40 border-purple-600 text-purple-300'
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-300 dark:hover:border-purple-600' : 'border-border-default text-text-tertiary hover:border-purple-600'
}`} }`}
> >
{cond} {cond}
@@ -360,17 +513,18 @@ export function EncounterModal({
<div className="max-h-64 overflow-y-auto space-y-3"> <div className="max-h-64 overflow-y-auto space-y-3">
{groupedPokemon.map(({ method, pokemon }, groupIdx) => ( {groupedPokemon.map(({ method, pokemon }, groupIdx) => (
<div key={method}> <div key={method}>
{groupIdx > 0 && ( {groupIdx > 0 && <div className="border-t border-border-default mb-3" />}
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
)}
{hasMultipleGroups && ( {hasMultipleGroups && (
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5"> <div className="text-xs font-medium text-text-tertiary mb-1.5">
{getMethodLabel(method)} {getMethodLabel(method)}
</div> </div>
)} )}
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{pokemon.map(({ encounter: rp, conditions, displayRate }) => { {pokemon.map(({ encounter: rp, conditions, displayRate }) => {
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
const isStaticDisabled =
!staticClause && rp.encounterMethod === 'static'
const isDisabled = isDuped || isStaticDisabled
const isSelected = const isSelected =
selectedPokemon?.pokemonId === rp.pokemonId && selectedPokemon?.pokemonId === rp.pokemonId &&
selectedPokemon?.encounterMethod === rp.encounterMethod selectedPokemon?.encounterMethod === rp.encounterMethod
@@ -378,14 +532,14 @@ export function EncounterModal({
<button <button
key={`${rp.encounterMethod}-${rp.pokemonId}`} key={`${rp.encounterMethod}-${rp.pokemonId}`}
type="button" type="button"
onClick={() => !isDuped && setSelectedPokemon(rp)} onClick={() => !isDisabled && setSelectedPokemon(rp)}
disabled={isDuped} disabled={isDisabled}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${ className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped isDisabled
? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700' ? 'opacity-40 cursor-not-allowed border-border-default'
: isSelected : isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' ? 'border-accent-400 bg-accent-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' : 'border-border-default hover:border-border-default'
}`} }`}
> >
{rp.pokemon.spriteUrl ? ( {rp.pokemon.spriteUrl ? (
@@ -395,37 +549,46 @@ export function EncounterModal({
className="w-10 h-10" className="w-10 h-10"
/> />
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold"> <div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0]?.toUpperCase()} {rp.pokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize"> <span className="text-xs text-text-secondary mt-1 capitalize">
{rp.pokemon.name} {rp.pokemon.name}
</span> </span>
{isDuped && ( {isDuped && (
<span className="text-[10px] text-gray-400 italic"> <span className="text-[10px] text-text-tertiary italic">
{retiredPokemonIds?.has(rp.pokemonId) {retiredPokemonIds?.has(rp.pokemonId)
? 'retired (HoF)' ? 'retired (HoF)'
: 'already caught'} : 'already caught'}
</span> </span>
)} )}
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && ( {isStaticDisabled && (
<EncounterMethodBadge method={rp.encounterMethod} /> <span className="text-[10px] text-text-tertiary italic">
)} static clause off
{!isDuped && displayRate !== null && displayRate !== undefined && (
<span className="text-[10px] text-purple-500 dark:text-purple-400 font-medium">
{displayRate}%
</span> </span>
)} )}
{!isDuped && {!isDisabled &&
(SPECIAL_METHODS.includes(rp.encounterMethod) ||
rp.encounterMethod === 'static') && (
<EncounterMethodBadge method={rp.encounterMethod} />
)}
{!isDisabled &&
displayRate !== null &&
displayRate !== undefined && (
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
{displayRate}%
</span>
)}
{!isDisabled &&
selectedCondition === null && selectedCondition === null &&
conditions.length > 0 && ( conditions.length > 0 && (
<span className="text-[10px] text-purple-500 dark:text-purple-400"> <span className="text-[10px] text-purple-400 light:text-purple-700">
{conditions.join(', ')} {conditions.join(', ')}
</span> </span>
)} )}
{!isDuped && ( {!isDisabled && (
<span className="text-[10px] text-gray-400"> <span className="text-[10px] text-text-tertiary">
Lv. {rp.minLevel} Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`} {rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span> </span>
@@ -439,16 +602,14 @@ export function EncounterModal({
</div> </div>
</> </>
) : ( ) : (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2"> <p className="text-sm text-text-tertiary py-2">No pokemon data for this route</p>
No pokemon data for this route
</p>
)} )}
</div> </div>
)} )}
{/* Editing: show pokemon info */} {/* Editing: show pokemon info */}
{isEditing && existing && ( {isEditing && existing && (
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"> <div className="flex items-center gap-3 p-3 bg-surface-0/50 rounded-lg">
{existing.pokemon.spriteUrl ? ( {existing.pokemon.spriteUrl ? (
<img <img
src={existing.pokemon.spriteUrl} src={existing.pokemon.spriteUrl}
@@ -456,15 +617,15 @@ export function EncounterModal({
className="w-12 h-12" className="w-12 h-12"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold"> <div className="w-12 h-12 rounded-full bg-surface-3 flex items-center justify-center text-lg font-bold">
{existing.pokemon.name[0]?.toUpperCase()} {existing.pokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<div> <div>
<div className="font-medium text-gray-900 dark:text-gray-100 capitalize"> <div className="font-medium text-text-primary capitalize">
{existing.pokemon.name} {existing.pokemon.name}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-text-tertiary">
Caught at Lv. {existing.catchLevel ?? '?'} Caught at Lv. {existing.catchLevel ?? '?'}
</div> </div>
</div> </div>
@@ -473,9 +634,7 @@ export function EncounterModal({
{/* Status */} {/* Status */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-text-secondary mb-1">Status</label>
Status
</label>
<div className="flex gap-2"> <div className="flex gap-2">
{statusOptions.map((opt) => ( {statusOptions.map((opt) => (
<button <button
@@ -485,7 +644,7 @@ export function EncounterModal({
className={`flex-1 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${ className={`flex-1 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
status === opt.value status === opt.value
? opt.color ? opt.color
: 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-gray-300' : 'border-border-default text-text-tertiary hover:border-border-accent'
}`} }`}
> >
{opt.label} {opt.label}
@@ -499,7 +658,7 @@ export function EncounterModal({
<div> <div>
<label <label
htmlFor="nickname" htmlFor="nickname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Nickname Nickname
</label> </label>
@@ -509,19 +668,17 @@ export function EncounterModal({
value={nickname} value={nickname}
onChange={(e) => setNickname(e.target.value)} onChange={(e) => setNickname(e.target.value)}
placeholder="Give it a name..." placeholder="Give it a name..."
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
/> />
{showSuggestions && suggestions && suggestions.length > 0 && ( {showSuggestions && suggestions && suggestions.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-text-tertiary">Suggestions ({namingScheme})</span>
Suggestions ({namingScheme})
</span>
<button <button
type="button" type="button"
onClick={() => regenerate()} onClick={() => regenerate()}
disabled={loadingSuggestions} disabled={loadingSuggestions}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 disabled:opacity-50 transition-colors" className="text-xs text-text-link hover:text-accent-300 disabled:opacity-50 transition-colors"
> >
{loadingSuggestions ? 'Loading...' : 'Regenerate'} {loadingSuggestions ? 'Loading...' : 'Regenerate'}
</button> </button>
@@ -534,8 +691,8 @@ export function EncounterModal({
onClick={() => setNickname(name)} onClick={() => setNickname(name)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${ className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
nickname === name nickname === name
? 'bg-blue-100 border-blue-300 text-blue-800 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-300' ? 'bg-accent-900/40 border-accent-600 text-accent-300 light:bg-accent-100 light:text-accent-700'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-blue-300 dark:hover:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20' : 'border-border-default text-text-secondary hover:border-accent-600 hover:bg-accent-900/20'
}`} }`}
> >
{name} {name}
@@ -552,7 +709,7 @@ export function EncounterModal({
<div> <div>
<label <label
htmlFor="catch-level" htmlFor="catch-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Catch Level Catch Level
</label> </label>
@@ -568,7 +725,7 @@ export function EncounterModal({
? `${selectedPokemon.minLevel}${selectedPokemon.maxLevel}` ? `${selectedPokemon.minLevel}${selectedPokemon.maxLevel}`
: 'Level' : 'Level'
} }
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
/> />
</div> </div>
)} )}
@@ -579,9 +736,9 @@ export function EncounterModal({
<div> <div>
<label <label
htmlFor="faint-level" htmlFor="faint-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Faint Level <span className="font-normal text-gray-400">(mark as dead)</span> Faint Level <span className="font-normal text-text-tertiary">(mark as dead)</span>
</label> </label>
<input <input
id="faint-level" id="faint-level"
@@ -591,15 +748,15 @@ export function EncounterModal({
value={faintLevel} value={faintLevel}
onChange={(e) => setFaintLevel(e.target.value)} onChange={(e) => setFaintLevel(e.target.value)}
placeholder="Leave empty if still alive" placeholder="Leave empty if still alive"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="death-cause" htmlFor="death-cause"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Cause of Death <span className="font-normal text-gray-400">(optional)</span> Cause of Death <span className="font-normal text-text-tertiary">(optional)</span>
</label> </label>
<input <input
id="death-cause" id="death-cause"
@@ -608,18 +765,18 @@ export function EncounterModal({
value={deathCause} value={deathCause}
onChange={(e) => setDeathCause(e.target.value)} onChange={(e) => setDeathCause(e.target.value)}
placeholder="e.g. Crit from rival's Charizard" placeholder="e.g. Crit from rival's Charizard"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
/> />
</div> </div>
</> </>
)} )}
</div> </div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3"> <div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" className="px-4 py-2 text-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -627,7 +784,7 @@ export function EncounterModal({
type="button" type="button"
disabled={!canSubmit || isPending} disabled={!canSubmit || isPending}
onClick={handleSubmit} onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'} {isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'}
</button> </button>

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EndRunModal } from './EndRunModal'
function setup(overrides: Partial<React.ComponentProps<typeof EndRunModal>> = {}) {
const props = {
onConfirm: vi.fn(),
onClose: vi.fn(),
...overrides,
}
render(<EndRunModal {...props} />)
return props
}
describe('EndRunModal', () => {
it('renders Victory, Defeat, and Cancel buttons', () => {
setup()
expect(screen.getByRole('button', { name: /victory/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /defeat/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
it('calls onConfirm with "completed" when Victory is clicked', async () => {
const { onConfirm } = setup()
await userEvent.click(screen.getByRole('button', { name: /victory/i }))
expect(onConfirm).toHaveBeenCalledWith('completed')
})
it('calls onConfirm with "failed" when Defeat is clicked', async () => {
const { onConfirm } = setup()
await userEvent.click(screen.getByRole('button', { name: /defeat/i }))
expect(onConfirm).toHaveBeenCalledWith('failed')
})
it('calls onClose when Cancel is clicked', async () => {
const { onClose } = setup()
await userEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onClose when the backdrop is clicked', async () => {
const { onClose } = setup()
const backdrop = document.querySelector('.fixed.inset-0.bg-black\\/50') as HTMLElement
await userEvent.click(backdrop)
expect(onClose).toHaveBeenCalledOnce()
})
it('disables all buttons when isPending is true', () => {
setup({ isPending: true })
expect(screen.getByRole('button', { name: /victory/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /defeat/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled()
})
it('shows default description text without a genlocke context', () => {
setup()
expect(screen.getByText('Beat the game successfully')).toBeInTheDocument()
expect(screen.getByText('All Pokemon fainted or gave up')).toBeInTheDocument()
})
it('shows genlocke-specific description for non-final legs', () => {
setup({ genlockeContext: { isFinalLeg: false, legOrder: 1, totalLegs: 3 } as never })
expect(screen.getByText('Complete this leg and continue your genlocke')).toBeInTheDocument()
expect(screen.getByText('This will end the entire genlocke')).toBeInTheDocument()
})
it('shows final-leg description on the last genlocke leg', () => {
setup({ genlockeContext: { isFinalLeg: true, legOrder: 3, totalLegs: 3 } as never })
expect(screen.getByText('Complete the final leg of your genlocke!')).toBeInTheDocument()
})
})

View File

@@ -21,17 +21,17 @@ export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }:
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} /> <div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4"> <div className="relative bg-surface-1 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="px-6 py-4 border-b border-border-default">
<h2 className="text-lg font-semibold">End Run</h2> <h2 className="text-lg font-semibold">End Run</h2>
</div> </div>
<div className="px-6 py-6"> <div className="px-6 py-6">
<p className="text-gray-600 dark:text-gray-400 mb-6">How did your run end?</p> <p className="text-text-tertiary mb-6">How did your run end?</p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<button <button
onClick={() => onConfirm('completed')} onClick={() => onConfirm('completed')}
disabled={isPending} disabled={isPending}
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 hover:border-blue-400 dark:hover:border-blue-600 disabled:opacity-50 transition-colors" className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-blue-800 bg-blue-900/20 text-blue-300 hover:border-blue-600 disabled:opacity-50 transition-colors"
> >
<div className="font-semibold">Victory</div> <div className="font-semibold">Victory</div>
<div className="text-sm opacity-80">{victoryDescription}</div> <div className="text-sm opacity-80">{victoryDescription}</div>
@@ -39,18 +39,18 @@ export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }:
<button <button
onClick={() => onConfirm('failed')} onClick={() => onConfirm('failed')}
disabled={isPending} disabled={isPending}
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 hover:border-red-400 dark:hover:border-red-600 disabled:opacity-50 transition-colors" className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-red-800 bg-status-failed-bg text-red-300 hover:border-red-600 disabled:opacity-50 transition-colors"
> >
<div className="font-semibold">Defeat</div> <div className="font-semibold">Defeat</div>
<div className="text-sm opacity-80">{defeatDescription}</div> <div className="text-sm opacity-80">{defeatDescription}</div>
</button> </button>
</div> </div>
</div> </div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end"> <div className="px-6 py-4 border-t border-border-default flex justify-end">
<button <button
onClick={onClose} onClick={onClose}
disabled={isPending} disabled={isPending}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700" className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
> >
Cancel Cancel
</button> </button>

View File

@@ -18,8 +18,8 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
<button <button
type="button" type="button"
onClick={() => onSelect(game)} onClick={() => onSelect(game)}
className={`relative w-full rounded-lg overflow-hidden transition-all duration-200 hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${ className={`relative w-full rounded-lg overflow-hidden transition-all duration-200 hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-surface-0 ${
selected ? 'ring-2 ring-blue-500 scale-105 shadow-lg' : 'shadow' selected ? 'ring-2 ring-accent-500 scale-105 shadow-lg' : 'shadow'
}`} }`}
> >
{imgIdx < boxArtSrcs.length ? ( {imgIdx < boxArtSrcs.length ? (
@@ -38,14 +38,14 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
</span> </span>
</div> </div>
)} )}
<div className="p-3 bg-white dark:bg-gray-800 text-left"> <div className="p-3 bg-surface-1 text-left">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{game.name}</h3> <h3 className="font-semibold text-text-primary">{game.name}</h3>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"> <span className="text-xs px-2 py-0.5 rounded-full bg-surface-2 text-text-tertiary">
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} {game.region.charAt(0).toUpperCase() + game.region.slice(1)}
</span> </span>
{game.releaseYear && ( {game.releaseYear && (
<span className="text-xs text-gray-500 dark:text-gray-400">{game.releaseYear}</span> <span className="text-xs text-text-tertiary">{game.releaseYear}</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { Game } from '../types'
import { GameGrid } from './GameGrid'
const RED: Game = {
id: 1,
name: 'Pokemon Red',
slug: 'red',
generation: 1,
region: 'kanto',
category: null,
boxArtUrl: null,
color: null,
releaseYear: null,
versionGroupId: 1,
}
const GOLD: Game = {
id: 2,
name: 'Pokemon Gold',
slug: 'gold',
generation: 2,
region: 'johto',
category: null,
boxArtUrl: null,
color: null,
releaseYear: null,
versionGroupId: 2,
}
const RUBY: Game = {
id: 3,
name: 'Pokemon Ruby',
slug: 'ruby',
generation: 3,
region: 'hoenn',
category: null,
boxArtUrl: null,
color: null,
releaseYear: null,
versionGroupId: 3,
}
function setup(overrides: Partial<React.ComponentProps<typeof GameGrid>> = {}) {
const props = {
games: [RED, GOLD, RUBY],
selectedId: null,
onSelect: vi.fn(),
...overrides,
}
render(<GameGrid {...props} />)
return props
}
describe('GameGrid', () => {
it('renders all game names', () => {
setup()
expect(screen.getByText('Pokemon Red')).toBeInTheDocument()
expect(screen.getByText('Pokemon Gold')).toBeInTheDocument()
expect(screen.getByText('Pokemon Ruby')).toBeInTheDocument()
})
it('renders generation filter pills for each unique generation', () => {
setup()
expect(screen.getByRole('button', { name: 'Gen 1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Gen 2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Gen 3' })).toBeInTheDocument()
})
it('filters games when a generation pill is clicked', async () => {
setup()
await userEvent.click(screen.getByRole('button', { name: 'Gen 1' }))
expect(screen.getByText('Pokemon Red')).toBeInTheDocument()
expect(screen.queryByText('Pokemon Gold')).not.toBeInTheDocument()
expect(screen.queryByText('Pokemon Ruby')).not.toBeInTheDocument()
})
it('restores all games when "All" generation pill is clicked', async () => {
setup()
await userEvent.click(screen.getByRole('button', { name: 'Gen 1' }))
await userEvent.click(screen.getAllByRole('button', { name: 'All' })[0]!)
expect(screen.getByText('Pokemon Red')).toBeInTheDocument()
expect(screen.getByText('Pokemon Gold')).toBeInTheDocument()
expect(screen.getByText('Pokemon Ruby')).toBeInTheDocument()
})
it('filters games when a region pill is clicked', async () => {
setup()
await userEvent.click(screen.getByRole('button', { name: 'Johto' }))
expect(screen.queryByText('Pokemon Red')).not.toBeInTheDocument()
expect(screen.getByText('Pokemon Gold')).toBeInTheDocument()
expect(screen.queryByText('Pokemon Ruby')).not.toBeInTheDocument()
})
it('calls onSelect with the game when a game card is clicked', async () => {
const { onSelect } = setup()
await userEvent.click(screen.getByText('Pokemon Red'))
expect(onSelect).toHaveBeenCalledWith(RED)
})
it('hides games with active runs when the checkbox is ticked', async () => {
setup({
runs: [{ id: 10, gameId: 1, status: 'active' } as never],
})
await userEvent.click(screen.getByLabelText(/hide games with active run/i))
expect(screen.queryByText('Pokemon Red')).not.toBeInTheDocument()
expect(screen.getByText('Pokemon Gold')).toBeInTheDocument()
})
it('does not render run-based checkboxes when runs prop is omitted', () => {
setup({ runs: undefined })
expect(screen.queryByLabelText(/hide games with active run/i)).not.toBeInTheDocument()
})
})

View File

@@ -70,16 +70,14 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
const pillClass = (active: boolean) => const pillClass = (active: boolean) =>
`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${ `px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
active active ? 'bg-blue-600 text-white' : 'bg-surface-2 text-text-secondary hover:bg-surface-3'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}` }`
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Gen:</span> <span className="text-xs font-medium text-text-tertiary mr-1">Gen:</span>
<button <button
type="button" type="button"
onClick={() => setFilter(null)} onClick={() => setFilter(null)}
@@ -100,7 +98,7 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Region:</span> <span className="text-xs font-medium text-text-tertiary mr-1">Region:</span>
<button <button
type="button" type="button"
onClick={() => setRegionFilter(null)} onClick={() => setRegionFilter(null)}
@@ -122,21 +120,21 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
{runs && ( {runs && (
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer"> <label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={hideWithActiveRun} checked={hideWithActiveRun}
onChange={(e) => setHideWithActiveRun(e.target.checked)} onChange={(e) => setHideWithActiveRun(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" className="rounded border-border-default text-blue-600 focus:ring-accent-400"
/> />
Hide games with active run Hide games with active run
</label> </label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer"> <label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={hideCompleted} checked={hideCompleted}
onChange={(e) => setHideCompleted(e.target.checked)} onChange={(e) => setHideCompleted(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" className="rounded border-border-default text-blue-600 focus:ring-accent-400"
/> />
Hide completed games Hide completed games
</label> </label>
@@ -146,7 +144,7 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
{grouped.map(({ generation, games }) => ( {grouped.map(({ generation, games }) => (
<div key={generation}> <div key={generation}>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3"> <h3 className="text-lg font-semibold text-text-primary mb-3">
{GENERATION_LABELS[generation] ?? `Generation ${generation}`} {GENERATION_LABELS[generation] ?? `Generation ${generation}`}
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@@ -14,7 +14,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
const isEvolved = entry.currentPokemon !== null const isEvolved = entry.currentPokemon !== null
return ( return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex flex-col items-center text-center opacity-60 grayscale"> <div className="bg-surface-1 rounded-lg shadow p-4 flex flex-col items-center text-center opacity-60 grayscale">
{displayPokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
<img <img
src={displayPokemon.spriteUrl} src={displayPokemon.spriteUrl}
@@ -23,20 +23,18 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300"> <div className="w-25 h-25 rounded-full bg-surface-3 flex items-center justify-center text-xl font-bold text-text-secondary">
{displayPokemon.name[0]?.toUpperCase()} {displayPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<div className="mt-2 flex items-center gap-1.5"> <div className="mt-2 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full shrink-0 bg-red-500" /> <span className="w-2 h-2 rounded-full shrink-0 bg-red-500" />
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm"> <span className="font-semibold text-text-primary text-sm">
{entry.nickname || displayPokemon.name} {entry.nickname || displayPokemon.name}
</span> </span>
</div> </div>
{entry.nickname && ( {entry.nickname && <div className="text-xs text-text-tertiary">{displayPokemon.name}</div>}
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
)}
<div className="flex flex-col items-center gap-0.5 mt-1"> <div className="flex flex-col items-center gap-0.5 mt-1">
{displayPokemon.types.map((type) => ( {displayPokemon.types.map((type) => (
@@ -44,24 +42,22 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
))} ))}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="text-xs text-text-tertiary mt-1">
Lv. {entry.catchLevel} &rarr; {entry.faintLevel} Lv. {entry.catchLevel} &rarr; {entry.faintLevel}
</div> </div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{entry.routeName}</div> <div className="text-xs text-text-muted mt-0.5">{entry.routeName}</div>
<div className="text-[10px] text-purple-600 dark:text-purple-400 mt-0.5 font-medium"> <div className="text-[10px] text-purple-400 light:text-purple-700 mt-0.5 font-medium">
Leg {entry.legOrder} &mdash; {entry.gameName} Leg {entry.legOrder} &mdash; {entry.gameName}
</div> </div>
{isEvolved && ( {isEvolved && (
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5"> <div className="text-[10px] text-text-muted mt-0.5">Originally: {entry.pokemon.name}</div>
Originally: {entry.pokemon.name}
</div>
)} )}
{entry.deathCause && ( {entry.deathCause && (
<div className="text-[10px] italic text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2"> <div className="text-[10px] italic text-text-muted mt-0.5 line-clamp-2">
{entry.deathCause} {entry.deathCause}
</div> </div>
)} )}
@@ -107,7 +103,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
if (error) { if (error) {
return ( return (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400"> <div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
Failed to load graveyard data. Failed to load graveyard data.
</div> </div>
) )
@@ -115,7 +111,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
if (!data || data.totalDeaths === 0) { if (!data || data.totalDeaths === 0) {
return ( return (
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-6 text-center text-gray-500 dark:text-gray-400"> <div className="rounded-lg bg-surface-1/50 p-6 text-center text-text-tertiary">
No deaths recorded across any leg. No deaths recorded across any leg.
</div> </div>
) )
@@ -125,11 +121,11 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
<div className="space-y-4"> <div className="space-y-4">
{/* Summary bar */} {/* Summary bar */}
<div className="flex flex-wrap items-center gap-4 text-sm"> <div className="flex flex-wrap items-center gap-4 text-sm">
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-text-primary">
{data.totalDeaths} total death{data.totalDeaths !== 1 ? 's' : ''} {data.totalDeaths} total death{data.totalDeaths !== 1 ? 's' : ''}
</span> </span>
{data.deadliestLeg && ( {data.deadliestLeg && (
<span className="text-gray-500 dark:text-gray-400"> <span className="text-text-tertiary">
Deadliest: Leg {data.deadliestLeg.legOrder} &mdash; {data.deadliestLeg.gameName} ( Deadliest: Leg {data.deadliestLeg.legOrder} &mdash; {data.deadliestLeg.gameName} (
{data.deadliestLeg.deathCount}) {data.deadliestLeg.deathCount})
</span> </span>
@@ -141,7 +137,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
<select <select
value={filterLeg ?? ''} value={filterLeg ?? ''}
onChange={(e) => setFilterLeg(e.target.value ? Number(e.target.value) : null)} onChange={(e) => setFilterLeg(e.target.value ? Number(e.target.value) : null)}
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
> >
<option value="">All Legs</option> <option value="">All Legs</option>
{data.deathsPerLeg.map((leg) => ( {data.deathsPerLeg.map((leg) => (
@@ -154,7 +150,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
<select <select
value={sortKey} value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)} onChange={(e) => setSortKey(e.target.value as SortKey)}
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
> >
<option value="leg">Sort by Leg</option> <option value="leg">Sort by Leg</option>
<option value="level">Sort by Level</option> <option value="level">Sort by Level</option>

View File

@@ -29,7 +29,7 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
return ( return (
<div className="group relative flex flex-col items-center"> <div className="group relative flex flex-col items-center">
<div <div
className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 ring-gray-200 dark:ring-gray-600`} className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-surface-1 ring-border-default`}
/> />
{/* Tooltip */} {/* Tooltip */}
@@ -48,18 +48,18 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
<div <div
className={`font-medium ${ className={`font-medium ${
leg.faintLevel !== null leg.faintLevel !== null
? 'text-red-300' ? 'text-red-300 light:text-red-700'
: leg.wasTransferred : leg.wasTransferred
? 'text-blue-300' ? 'text-blue-300 light:text-blue-700'
: leg.enteredHof : leg.enteredHof
? 'text-yellow-300' ? 'text-yellow-300 light:text-amber-700'
: 'text-green-300' : 'text-green-300 light:text-green-700'
}`} }`}
> >
{label} {label}
</div> </div>
{leg.enteredHof && leg.faintLevel === null && ( {leg.enteredHof && leg.faintLevel === null && (
<div className="text-yellow-300">Hall of Fame</div> <div className="text-yellow-300 light:text-amber-700">Hall of Fame</div>
)} )}
</div> </div>
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" /> <div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
@@ -97,11 +97,11 @@ function TimelineGrid({
<div key={legOrder} className="flex justify-center relative" style={{ height: '20px' }}> <div key={legOrder} className="flex justify-center relative" style={{ height: '20px' }}>
{/* Left half connector */} {/* Left half connector */}
{showLeftLine && ( {showLeftLine && (
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" /> <div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-surface-3" />
)} )}
{/* Right half connector */} {/* Right half connector */}
{showRightLine && ( {showRightLine && (
<div className="absolute top-[9px] left-1/2 right-0 h-0.5 bg-gray-300 dark:bg-gray-600" /> <div className="absolute top-[9px] left-1/2 right-0 h-0.5 bg-surface-3" />
)} )}
{/* Dot or empty */} {/* Dot or empty */}
{leg ? ( {leg ? (
@@ -123,7 +123,7 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
return ( return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center gap-4"> <div className="bg-surface-1 rounded-lg shadow p-4 flex items-center gap-4">
{/* Left: Pokemon sprite + nickname */} {/* Left: Pokemon sprite + nickname */}
<div className="flex flex-col items-center min-w-[80px]"> <div className="flex flex-col items-center min-w-[80px]">
{displayPokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
@@ -134,17 +134,15 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300"> <div className="w-16 h-16 rounded-full bg-surface-3 flex items-center justify-center text-xl font-bold text-text-secondary">
{displayPokemon.name[0]?.toUpperCase()} {displayPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center"> <span className="text-sm font-semibold text-text-primary mt-1 text-center">
{lineage.nickname || lineage.pokemon.name} {lineage.nickname || lineage.pokemon.name}
</span> </span>
{lineage.nickname && ( {lineage.nickname && (
<span className="text-[10px] text-gray-500 dark:text-gray-400"> <span className="text-[10px] text-text-tertiary">{lineage.pokemon.name}</span>
{lineage.pokemon.name}
</span>
)} )}
</div> </div>
@@ -158,8 +156,8 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
<span <span
className={`px-2.5 py-1 rounded-full text-xs font-medium ${ className={`px-2.5 py-1 rounded-full text-xs font-medium ${
lineage.status === 'alive' lineage.status === 'alive'
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' ? 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800'
: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' : 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800'
}`} }`}
> >
{lineage.status === 'alive' ? 'Alive' : 'Dead'} {lineage.status === 'alive' ? 'Alive' : 'Dead'}
@@ -200,7 +198,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
if (error) { if (error) {
return ( return (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400"> <div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
Failed to load lineage data. Failed to load lineage data.
</div> </div>
) )
@@ -208,7 +206,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
if (!data || data.totalLineages === 0) { if (!data || data.totalLineages === 0) {
return ( return (
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-6 text-center text-gray-500 dark:text-gray-400"> <div className="rounded-lg bg-surface-1/50 p-6 text-center text-text-tertiary">
No Pokemon have been transferred between legs yet. No Pokemon have been transferred between legs yet.
</div> </div>
) )
@@ -218,7 +216,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
<div className="space-y-4"> <div className="space-y-4">
{/* Summary bar */} {/* Summary bar */}
<div className="flex flex-wrap items-center gap-4 text-sm"> <div className="flex flex-wrap items-center gap-4 text-sm">
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-text-primary">
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '} {data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''} {allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
</span> </span>
@@ -237,10 +235,10 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
> >
{allLegOrders.map((legOrder) => ( {allLegOrders.map((legOrder) => (
<div key={legOrder} className="flex flex-col items-center"> <div key={legOrder} className="flex flex-col items-center">
<span className="text-[10px] font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap"> <span className="text-[10px] font-medium text-text-tertiary whitespace-nowrap">
Leg {legOrder} Leg {legOrder}
</span> </span>
<span className="text-[9px] text-gray-400 dark:text-gray-500 whitespace-nowrap truncate max-w-[48px]"> <span className="text-[9px] text-text-muted whitespace-nowrap truncate max-w-[48px]">
{legGameNames.get(legOrder)} {legGameNames.get(legOrder)}
</span> </span>
</div> </div>

View File

@@ -30,12 +30,10 @@ export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModa
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" /> <div className="fixed inset-0 bg-black/50" />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto"> <div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl"> <div className="sticky top-0 bg-surface-1 border-b border-border-default px-6 py-4 rounded-t-xl">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-semibold text-text-primary">Hall of Fame Team</h2>
Hall of Fame Team <p className="text-sm text-text-tertiary mt-1">
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Select the Pokemon that entered the Hall of Fame (max 6) Select the Pokemon that entered the Hall of Fame (max 6)
</p> </p>
</div> </div>
@@ -55,10 +53,10 @@ export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModa
disabled={atMax} disabled={atMax}
className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${ className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${
isSelected isSelected
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20' ? 'border-yellow-500 bg-yellow-900/20'
: atMax : atMax
? 'border-gray-200 dark:border-gray-700 opacity-40 cursor-not-allowed' ? 'border-border-default opacity-40 cursor-not-allowed'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' : 'border-border-default hover:border-border-default'
}`} }`}
> >
{displayPokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
@@ -68,15 +66,15 @@ export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModa
className="w-14 h-14" className="w-14 h-14"
/> />
) : ( ) : (
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold"> <div className="w-14 h-14 rounded-full bg-surface-3 flex items-center justify-center text-lg font-bold">
{displayPokemon.name[0]?.toUpperCase()} {displayPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize"> <span className="text-xs font-medium text-text-secondary mt-1 capitalize">
{enc.nickname || displayPokemon.name} {enc.nickname || displayPokemon.name}
</span> </span>
{enc.nickname && ( {enc.nickname && (
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span> <span className="text-[10px] text-text-tertiary">{displayPokemon.name}</span>
)} )}
</button> </button>
) )
@@ -84,24 +82,22 @@ export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModa
</div> </div>
</div> </div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex items-center justify-between"> <div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex items-center justify-between">
<button <button
type="button" type="button"
onClick={onSkip} onClick={onSkip}
disabled={isPending} disabled={isPending}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-50" className="text-sm text-text-tertiary hover:text-text-primary disabled:opacity-50"
> >
Skip Skip
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-gray-400 dark:text-gray-500"> <span className="text-sm text-text-muted">{selected.size}/6 selected</span>
{selected.size}/6 selected
</span>
<button <button
type="button" type="button"
disabled={selected.size === 0 || isPending} disabled={selected.size === 0 || isPending}
onClick={() => onSubmit([...selected])} onClick={() => onSubmit([...selected])}
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{isPending ? 'Saving...' : 'Confirm'} {isPending ? 'Saving...' : 'Confirm'}
</button> </button>

View File

@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Layout } from './Layout'
vi.mock('../hooks/useTheme', () => ({
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
}))
function renderLayout(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Layout />
</MemoryRouter>
)
}
describe('Layout', () => {
it('renders all desktop navigation links', () => {
renderLayout()
expect(screen.getAllByRole('link', { name: /new run/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /my runs/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /admin/i })[0]).toBeInTheDocument()
})
it('renders the brand logo link', () => {
renderLayout()
expect(screen.getByRole('link', { name: /ant/i })).toBeInTheDocument()
})
it('renders the theme toggle button', () => {
renderLayout()
expect(screen.getAllByRole('button', { name: /switch to light mode/i })[0]).toBeInTheDocument()
})
it('initially hides the mobile dropdown menu', () => {
renderLayout()
// Mobile menu items exist in DOM but menu is hidden; the mobile dropdown
// only appears inside the sm:hidden block after state toggle.
// The hamburger button should be present.
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
})
it('shows the mobile dropdown when the hamburger is clicked', async () => {
renderLayout()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
await userEvent.click(hamburger)
// After click, the menu open state adds a dropdown with nav links
// We can verify the menu is open by checking a class change or that
// the nav links appear in the mobile dropdown section.
// The mobile dropdown renders navLinks in a div inside sm:hidden
expect(screen.getAllByRole('link', { name: /my runs/i }).length).toBeGreaterThan(1)
})
it('renders the footer with PokeDB attribution', () => {
renderLayout()
expect(screen.getByRole('link', { name: /pokedb/i })).toBeInTheDocument()
})
})

View File

@@ -1,61 +1,119 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet, useLocation } from 'react-router-dom'
import { useTheme } from '../hooks/useTheme'
const navLinks = [
{ to: '/runs/new', label: 'New Run' },
{ to: '/runs', label: 'My Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
{ to: '/admin', label: 'Admin' },
]
function NavLink({
to,
active,
children,
onClick,
className = '',
}: {
to: string
active: boolean
children: React.ReactNode
onClick?: () => void
className?: string
}) {
return (
<Link
to={to}
onClick={onClick}
className={`${className} px-3 py-2 rounded-md text-sm font-medium transition-colors ${
active
? 'bg-accent-600/20 text-accent-300'
: 'text-text-secondary hover:text-text-primary hover:bg-surface-3'
}`}
>
{children}
</Link>
)
}
function ThemeToggle() {
const { theme, toggle } = useTheme()
return (
<button
type="button"
onClick={toggle}
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m8.66-13.66l-.71.71M4.05 19.95l-.71.71M21 12h-1M4 12H3m16.66 7.66l-.71-.71M4.05 4.05l-.71-.71M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
)}
</button>
)
}
export function Layout() { export function Layout() {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation()
function isActive(to: string) {
if (to === '/runs/new') return location.pathname === '/runs/new'
return location.pathname.startsWith(to)
}
return ( return (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100"> <div className="min-h-screen flex flex-col bg-surface-0 text-text-primary">
<nav className="bg-white dark:bg-gray-800 shadow-sm"> <nav className="sticky top-0 z-40 bg-surface-1/80 backdrop-blur-lg border-b border-border-default">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16"> <div className="flex justify-between h-14">
<div className="flex items-center"> <div className="flex items-center gap-2">
<Link to="/" className="text-xl font-bold"> <Link to="/" className="flex items-center gap-2 group">
Nuzlocke Tracker <img
src="/favicon.svg"
alt=""
className="w-7 h-7 transition-transform group-hover:scale-110"
/>
<span className="text-lg font-bold tracking-tight text-text-primary">ANT</span>
</Link> </Link>
</div> </div>
{/* Desktop nav */} {/* Desktop nav */}
<div className="hidden sm:flex items-center space-x-4"> <div className="hidden sm:flex items-center gap-1">
<Link {navLinks.map((link) => (
to="/runs/new" <NavLink key={link.to} to={link.to} active={isActive(link.to)}>
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700" {link.label}
> </NavLink>
New Run ))}
</Link> <ThemeToggle />
<Link
to="/runs"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
My Runs
</Link>
<Link
to="/genlockes"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Genlockes
</Link>
<Link
to="/stats"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Stats
</Link>
<Link
to="/admin"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Admin
</Link>
</div> </div>
{/* Mobile hamburger */} {/* Mobile hamburger */}
<div className="flex items-center sm:hidden"> <div className="flex items-center gap-1 sm:hidden">
<ThemeToggle />
<button <button
type="button" type="button"
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700" className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
aria-label="Toggle menu" aria-label="Toggle menu"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{menuOpen ? ( {menuOpen ? (
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -78,43 +136,19 @@ export function Layout() {
</div> </div>
{/* Mobile dropdown */} {/* Mobile dropdown */}
{menuOpen && ( {menuOpen && (
<div className="sm:hidden border-t border-gray-200 dark:border-gray-700"> <div className="sm:hidden border-t border-border-default">
<div className="px-2 pt-2 pb-3 space-y-1"> <div className="px-2 pt-2 pb-3 space-y-1">
<Link {navLinks.map((link) => (
to="/runs/new" <NavLink
onClick={() => setMenuOpen(false)} key={link.to}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" to={link.to}
> active={isActive(link.to)}
New Run onClick={() => setMenuOpen(false)}
</Link> className="block"
<Link >
to="/runs" {link.label}
onClick={() => setMenuOpen(false)} </NavLink>
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" ))}
>
My Runs
</Link>
<Link
to="/genlockes"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Genlockes
</Link>
<Link
to="/stats"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Stats
</Link>
<Link
to="/admin"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Admin
</Link>
</div> </div>
</div> </div>
)} )}
@@ -122,12 +156,12 @@ export function Layout() {
<main className="flex-1"> <main className="flex-1">
<Outlet /> <Outlet />
</main> </main>
<footer className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"> <footer className="border-t border-border-default bg-surface-1/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 text-center text-xs text-gray-500 dark:text-gray-400"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 text-center text-xs text-text-tertiary">
Pokémon encounter data from{' '} Encounter data from{' '}
<a <a
href="https://pokedb.org" href="https://pokedb.org"
className="underline hover:text-gray-700 dark:hover:text-gray-300" className="underline hover:text-text-secondary transition-colors"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >

View File

@@ -16,29 +16,27 @@ export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardP
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex flex-col items-center text-center ${ className={`bg-surface-1 rounded-xl border border-border-default p-4 flex flex-col items-center text-center transition-all ${
isDead ? 'opacity-60 grayscale' : '' isDead ? 'opacity-50 grayscale' : ''
} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`} } ${onClick ? 'cursor-pointer hover:border-accent-400/30 hover:-translate-y-0.5' : ''}`}
> >
{displayPokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-25 h-25" /> <img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-25 h-25" />
) : ( ) : (
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300"> <div className="w-25 h-25 rounded-full bg-surface-3 flex items-center justify-center text-xl font-bold text-text-secondary">
{displayPokemon.name[0]?.toUpperCase()} {displayPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<div className="mt-2 flex items-center gap-1.5"> <div className="mt-2 flex items-center gap-1.5">
<span <span
className={`w-2 h-2 rounded-full shrink-0 ${isDead ? 'bg-red-500' : 'bg-green-500'}`} className={`w-2 h-2 rounded-full shrink-0 ${isDead ? 'bg-status-dead' : 'bg-status-alive'}`}
/> />
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm"> <span className="font-semibold text-text-primary text-sm">
{nickname || displayPokemon.name} {nickname || displayPokemon.name}
</span> </span>
</div> </div>
{nickname && ( {nickname && <div className="text-xs text-text-secondary">{displayPokemon.name}</div>}
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
)}
<div className="flex flex-col items-center gap-0.5 mt-1"> <div className="flex flex-col items-center gap-0.5 mt-1">
{displayPokemon.types.map((type) => ( {displayPokemon.types.map((type) => (
@@ -46,22 +44,20 @@ export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardP
))} ))}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="text-xs font-mono text-text-secondary mt-1">
{showFaintLevel && isDead {showFaintLevel && isDead
? `Lv. ${catchLevel}${faintLevel}` ? `Lv. ${catchLevel}${faintLevel}`
: `Lv. ${catchLevel ?? '?'}`} : `Lv. ${catchLevel ?? '?'}`}
</div> </div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{route.name}</div> <div className="text-xs text-text-tertiary mt-0.5">{route.name}</div>
{isEvolved && ( {isEvolved && (
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5"> <div className="text-[10px] text-text-tertiary mt-0.5">Originally: {pokemon.name}</div>
Originally: {pokemon.name}
</div>
)} )}
{isDead && deathCause && ( {isDead && deathCause && (
<div className="text-[10px] italic text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2"> <div className="text-[10px] italic text-text-tertiary mt-0.5 line-clamp-2">
{deathCause} {deathCause}
</div> </div>
)} )}

View File

@@ -1,5 +1,6 @@
import type { NuzlockeRules } from '../types' import type { NuzlockeRules } from '../types'
import { RULE_DEFINITIONS } from '../types/rules' import { RULE_DEFINITIONS } from '../types/rules'
import { TypeBadge } from './TypeBadge'
interface RuleBadgesProps { interface RuleBadgesProps {
rules: NuzlockeRules rules: NuzlockeRules
@@ -7,9 +8,10 @@ interface RuleBadgesProps {
export function RuleBadges({ rules }: RuleBadgesProps) { export function RuleBadges({ rules }: RuleBadgesProps) {
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key]) const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
const allowedTypes = rules.allowedTypes ?? []
if (enabledRules.length === 0) { if (enabledRules.length === 0 && allowedTypes.length === 0) {
return <span className="text-sm text-gray-500 dark:text-gray-400">No rules enabled</span> return <span className="text-sm text-text-tertiary">No rules enabled</span>
} }
return ( return (
@@ -20,15 +22,26 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
title={def.description} title={def.description}
className={`px-2 py-0.5 rounded-full text-xs font-medium ${ className={`px-2 py-0.5 rounded-full text-xs font-medium ${
def.category === 'core' def.category === 'core'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300' ? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700'
: def.category === 'completion' : def.category === 'variant'
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' ? 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300' : 'bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-700'
}`} }`}
> >
{def.name} {def.name}
</span> </span>
))} ))}
{allowedTypes.length > 0 && (
<span
title={`Type restriction: ${allowedTypes.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(', ')}`}
className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700 flex items-center gap-1"
>
<span>Type Restriction</span>
{allowedTypes.map((t) => (
<TypeBadge key={t} type={t} size="sm" />
))}
</span>
)}
</div> </div>
) )
} }

View File

@@ -11,13 +11,13 @@ export function RuleToggle({ name, description, enabled, onChange }: RuleToggleP
const [showTooltip, setShowTooltip] = useState(false) const [showTooltip, setShowTooltip] = useState(false)
return ( return (
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-0"> <div className="flex items-center justify-between py-3 border-b border-border-default last:border-0">
<div className="flex-1 pr-4"> <div className="flex-1 pr-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-100">{name}</span> <span className="font-medium text-text-primary">{name}</span>
<button <button
type="button" type="button"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" className="text-text-tertiary hover:text-text-secondary"
onMouseEnter={() => setShowTooltip(true)} onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)} onMouseLeave={() => setShowTooltip(false)}
onClick={() => setShowTooltip(!showTooltip)} onClick={() => setShowTooltip(!showTooltip)}
@@ -33,17 +33,15 @@ export function RuleToggle({ name, description, enabled, onChange }: RuleToggleP
</svg> </svg>
</button> </button>
</div> </div>
{showTooltip && ( {showTooltip && <p className="mt-1 text-sm text-text-tertiary">{description}</p>}
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
)}
</div> </div>
<button <button
type="button" type="button"
role="switch" role="switch"
aria-checked={enabled} aria-checked={enabled}
onClick={() => onChange(!enabled)} onClick={() => onChange(!enabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 ${
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600' enabled ? 'bg-blue-600' : 'bg-surface-3'
}`} }`}
> >
<span <span

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RulesConfiguration } from './RulesConfiguration'
import { DEFAULT_RULES } from '../types/rules'
import type { NuzlockeRules } from '../types/rules'
function setup(overrides: Partial<React.ComponentProps<typeof RulesConfiguration>> = {}) {
const props = {
rules: { ...DEFAULT_RULES },
onChange: vi.fn(),
...overrides,
}
render(<RulesConfiguration {...props} />)
return props
}
describe('RulesConfiguration', () => {
it('renders all rule section headings', () => {
setup()
expect(screen.getByText('Core Rules')).toBeInTheDocument()
expect(screen.getByText('Playstyle')).toBeInTheDocument()
expect(screen.getByText('Run Variant')).toBeInTheDocument()
expect(screen.getByText('Type Restriction')).toBeInTheDocument()
})
it('renders the enabled/total count', () => {
setup()
expect(screen.getByText(/\d+ of \d+ rules enabled/)).toBeInTheDocument()
})
it('renders the Reset to Default button', () => {
setup()
expect(screen.getByRole('button', { name: /reset to default/i })).toBeInTheDocument()
})
it('calls onChange with updated rules when a rule is toggled off', async () => {
const { onChange } = setup()
// RuleToggle renders a role="switch" with no accessible name; navigate
// to it via the sibling label text.
const label = screen.getByText('Duplicates Clause')
// Structure: span → .flex.items-center.gap-2 → .flex-1.pr-4 → row div → switch button
const switchEl = label
.closest('div[class]')
?.parentElement?.parentElement?.querySelector('[role="switch"]') as HTMLElement
await userEvent.click(switchEl)
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ duplicatesClause: false }))
})
it('calls onChange with DEFAULT_RULES when Reset to Default is clicked', async () => {
const { onChange } = setup({ rules: { ...DEFAULT_RULES, duplicatesClause: false } })
await userEvent.click(screen.getByRole('button', { name: /reset to default/i }))
expect(onChange).toHaveBeenCalledWith(DEFAULT_RULES)
})
it('calls onReset when Reset to Default is clicked', async () => {
const onReset = vi.fn()
setup({ onReset })
await userEvent.click(screen.getByRole('button', { name: /reset to default/i }))
expect(onReset).toHaveBeenCalledOnce()
})
it('toggles a type on when a type button is clicked', async () => {
const { onChange } = setup()
await userEvent.click(screen.getByRole('button', { name: /fire/i }))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ allowedTypes: ['fire'] }))
})
it('shows Clear selection button when types are selected', () => {
setup({ rules: { ...DEFAULT_RULES, allowedTypes: ['fire'] } })
expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument()
})
it('clears selected types when Clear selection is clicked', async () => {
const { onChange } = setup({ rules: { ...DEFAULT_RULES, allowedTypes: ['fire', 'water'] } })
await userEvent.click(screen.getByRole('button', { name: /clear selection/i }))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ allowedTypes: [] }))
})
it('hides rules in the hiddenRules set', () => {
const hiddenRules = new Set<keyof NuzlockeRules>(['duplicatesClause'])
setup({ hiddenRules })
expect(screen.queryByText('Duplicates Clause')).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,28 @@
import type { NuzlockeRules } from '../types/rules' import type { NuzlockeRules } from '../types/rules'
import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules' import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules'
import { RuleToggle } from './RuleToggle' import { RuleToggle } from './RuleToggle'
import { TypeBadge } from './TypeBadge'
const POKEMON_TYPES = [
'bug',
'dark',
'dragon',
'electric',
'fairy',
'fighting',
'fire',
'flying',
'ghost',
'grass',
'ground',
'ice',
'normal',
'poison',
'psychic',
'rock',
'steel',
'water',
] as const
interface RulesConfigurationProps { interface RulesConfigurationProps {
rules: NuzlockeRules rules: NuzlockeRules
@@ -19,8 +41,8 @@ export function RulesConfiguration({
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key)) ? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
: RULE_DEFINITIONS : RULE_DEFINITIONS
const coreRules = visibleRules.filter((r) => r.category === 'core') const coreRules = visibleRules.filter((r) => r.category === 'core')
const difficultyRules = visibleRules.filter((r) => r.category === 'difficulty') const playstyleRules = visibleRules.filter((r) => r.category === 'playstyle')
const completionRules = visibleRules.filter((r) => r.category === 'completion') const variantRules = visibleRules.filter((r) => r.category === 'variant')
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => { const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
onChange({ ...rules, [key]: value }) onChange({ ...rules, [key]: value })
@@ -31,33 +53,41 @@ export function RulesConfiguration({
onReset?.() onReset?.()
} }
const enabledCount = visibleRules.filter((r) => rules[r.key]).length const allowedTypes = rules.allowedTypes ?? []
const totalCount = visibleRules.length
const toggleType = (type: string) => {
const next = allowedTypes.includes(type)
? allowedTypes.filter((t) => t !== type)
: [...allowedTypes, type]
onChange({ ...rules, allowedTypes: next })
}
const enabledCount =
visibleRules.filter((r) => rules[r.key]).length + (allowedTypes.length > 0 ? 1 : 0)
const totalCount = visibleRules.length + 1 // +1 for type restriction
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-xl font-semibold text-text-primary">Rules Configuration</h2>
Rules Configuration <p className="text-sm text-text-tertiary">
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{enabledCount} of {totalCount} rules enabled {enabledCount} of {totalCount} rules enabled
</p> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={handleResetToDefault} onClick={handleResetToDefault}
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" className="text-sm text-text-link hover:text-accent-300"
> >
Reset to Default Reset to Default
</button> </button>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow"> <div className="bg-surface-1 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <div className="px-4 py-3 border-b border-border-default">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Core Rules</h3> <h3 className="text-lg font-medium text-text-primary">Core Rules</h3>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-text-tertiary">
The fundamental rules of a Nuzlocke challenge The fundamental rules of a Nuzlocke challenge
</p> </p>
</div> </div>
@@ -74,17 +104,15 @@ export function RulesConfiguration({
</div> </div>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow"> <div className="bg-surface-1 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <div className="px-4 py-3 border-b border-border-default">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-medium text-text-primary">Playstyle</h3>
Difficulty Modifiers <p className="text-sm text-text-tertiary">
</h3> Describe how you're playing — doesn't affect tracker behavior
<p className="text-sm text-gray-500 dark:text-gray-400">
Optional rules to increase the challenge
</p> </p>
</div> </div>
<div className="px-4"> <div className="px-4">
{difficultyRules.map((rule) => ( {playstyleRules.map((rule) => (
<RuleToggle <RuleToggle
key={rule.key} key={rule.key}
name={rule.name} name={rule.name}
@@ -96,27 +124,63 @@ export function RulesConfiguration({
</div> </div>
</div> </div>
{completionRules.length > 0 && ( <div className="bg-surface-1 rounded-lg shadow">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow"> <div className="px-4 py-3 border-b border-border-default">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <h3 className="text-lg font-medium text-text-primary">Run Variant</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Completion</h3> <p className="text-sm text-text-tertiary">
<p className="text-sm text-gray-500 dark:text-gray-400"> Changes which Pokémon can appear affects the encounter selector
When is the run considered complete </p>
</p> </div>
</div> <div className="px-4">
<div className="px-4"> {variantRules.map((rule) => (
{completionRules.map((rule) => ( <RuleToggle
<RuleToggle key={rule.key}
key={rule.key} name={rule.name}
name={rule.name} description={rule.description}
description={rule.description} enabled={rules[rule.key]}
enabled={rules[rule.key]} onChange={(value) => handleRuleChange(rule.key, value)}
onChange={(value) => handleRuleChange(rule.key, value)} />
/> ))}
</div>
</div>
<div className="bg-surface-1 rounded-lg shadow">
<div className="px-4 py-3 border-b border-border-default">
<h3 className="text-lg font-medium text-text-primary">Type Restriction</h3>
<p className="text-sm text-text-tertiary">
Monolocke and variants. Select allowed types a Pokémon qualifies if it shares at least
one type. Leave all deselected to disable.
</p>
</div>
<div className="px-4 py-4">
<div className="flex flex-wrap gap-2">
{POKEMON_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => toggleType(type)}
title={type.charAt(0).toUpperCase() + type.slice(1)}
className={`p-1.5 rounded-lg border-2 transition-colors ${
allowedTypes.includes(type)
? 'border-accent-400 bg-accent-900/20'
: 'border-transparent opacity-40 hover:opacity-70'
}`}
>
<TypeBadge type={type} size="md" />
</button>
))} ))}
</div> </div>
{allowedTypes.length > 0 && (
<button
type="button"
onClick={() => onChange({ ...rules, allowedTypes: [] })}
className="mt-3 text-xs text-text-tertiary hover:text-text-secondary"
>
Clear selection
</button>
)}
</div> </div>
)} </div>
</div> </div>
) )
} }

View File

@@ -8,11 +8,11 @@ interface ShinyBoxProps {
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) { export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
return ( return (
<div className="border-2 border-yellow-400 dark:border-yellow-600 rounded-lg p-4"> <div className="border-2 border-yellow-600 rounded-lg p-4">
<h3 className="text-sm font-semibold text-yellow-600 dark:text-yellow-400 mb-3 flex items-center gap-1.5"> <h3 className="text-sm font-semibold text-yellow-400 light:text-amber-700 mb-3 flex items-center gap-1.5">
<span>&#10022;</span> <span>&#10022;</span>
Shiny Box Shiny Box
<span className="text-xs font-normal text-gray-400 dark:text-gray-500 ml-1"> <span className="text-xs font-normal text-text-muted ml-1">
{encounters.length} {encounters.length === 1 ? 'shiny' : 'shinies'} {encounters.length} {encounters.length === 1 ? 'shiny' : 'shinies'}
</span> </span>
</h3> </h3>
@@ -27,9 +27,7 @@ export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-gray-400 dark:text-gray-500 text-center py-2"> <p className="text-sm text-text-muted text-center py-2">No shinies found yet</p>
No shinies found yet
</p>
)} )}
</div> </div>
) )

View File

@@ -92,17 +92,14 @@ export function ShinyEncounterModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} /> <div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto"> <div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-yellow-300 dark:border-yellow-600 px-6 py-4 rounded-t-xl"> <div className="sticky top-0 bg-surface-1 border-b border-yellow-600 px-6 py-4 rounded-t-xl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2"> <h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
<span className="text-yellow-500">&#10022;</span> <span className="text-yellow-500">&#10022;</span>
Log Shiny Encounter Log Shiny Encounter
</h2> </h2>
<button <button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -113,7 +110,7 @@ export function ShinyEncounterModal({
</svg> </svg>
</button> </button>
</div> </div>
<p className="text-sm text-yellow-600 dark:text-yellow-400 mt-1"> <p className="text-sm text-yellow-400 light:text-amber-700 mt-1">
Shiny catches bypass the one-per-route rule Shiny catches bypass the one-per-route rule
</p> </p>
</div> </div>
@@ -121,13 +118,11 @@ export function ShinyEncounterModal({
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
{/* Route selector */} {/* Route selector */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-text-secondary mb-1">Route</label>
Route
</label>
<select <select
value={selectedRouteId ?? ''} value={selectedRouteId ?? ''}
onChange={(e) => handleRouteChange(Number(e.target.value))} onChange={(e) => handleRouteChange(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-yellow-500"
> >
<option value="">Select a route...</option> <option value="">Select a route...</option>
{leafRoutes.map((r) => ( {leafRoutes.map((r) => (
@@ -141,9 +136,7 @@ export function ShinyEncounterModal({
{/* Pokemon Selection */} {/* Pokemon Selection */}
{selectedRouteId && ( {selectedRouteId && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
Pokemon
</label>
{loadingPokemon ? ( {loadingPokemon ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<div className="w-6 h-6 border-2 border-yellow-500 border-t-transparent rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-yellow-500 border-t-transparent rounded-full animate-spin" />
@@ -156,17 +149,15 @@ export function ShinyEncounterModal({
placeholder="Search pokemon..." placeholder="Search pokemon..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-500" className="w-full px-3 py-1.5 mb-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-yellow-500"
/> />
)} )}
<div className="max-h-64 overflow-y-auto space-y-3"> <div className="max-h-64 overflow-y-auto space-y-3">
{groupedPokemon.map(({ method, pokemon }, groupIdx) => ( {groupedPokemon.map(({ method, pokemon }, groupIdx) => (
<div key={method}> <div key={method}>
{groupIdx > 0 && ( {groupIdx > 0 && <div className="border-t border-border-default mb-3" />}
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
)}
{hasMultipleGroups && ( {hasMultipleGroups && (
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5"> <div className="text-xs font-medium text-text-tertiary mb-1.5">
{getMethodLabel(method)} {getMethodLabel(method)}
</div> </div>
)} )}
@@ -178,8 +169,8 @@ export function ShinyEncounterModal({
onClick={() => setSelectedPokemon(rp)} onClick={() => setSelectedPokemon(rp)}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${ className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
selectedPokemon?.id === rp.id selectedPokemon?.id === rp.id
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/30' ? 'border-yellow-500 bg-yellow-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' : 'border-border-default hover:border-border-default'
}`} }`}
> >
{rp.pokemon.spriteUrl ? ( {rp.pokemon.spriteUrl ? (
@@ -189,17 +180,17 @@ export function ShinyEncounterModal({
className="w-10 h-10" className="w-10 h-10"
/> />
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold"> <div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0]?.toUpperCase()} {rp.pokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize"> <span className="text-xs text-text-secondary mt-1 capitalize">
{rp.pokemon.name} {rp.pokemon.name}
</span> </span>
{SPECIAL_METHODS.includes(rp.encounterMethod) && ( {SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge method={rp.encounterMethod} /> <EncounterMethodBadge method={rp.encounterMethod} />
)} )}
<span className="text-[10px] text-gray-400"> <span className="text-[10px] text-text-tertiary">
Lv. {rp.minLevel} Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`} {rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span> </span>
@@ -211,9 +202,7 @@ export function ShinyEncounterModal({
</div> </div>
</> </>
) : ( ) : (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2"> <p className="text-sm text-text-tertiary py-2">No pokemon data for this route</p>
No pokemon data for this route
</p>
)} )}
</div> </div>
)} )}
@@ -223,7 +212,7 @@ export function ShinyEncounterModal({
<div> <div>
<label <label
htmlFor="shiny-nickname" htmlFor="shiny-nickname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Nickname Nickname
</label> </label>
@@ -233,7 +222,7 @@ export function ShinyEncounterModal({
value={nickname} value={nickname}
onChange={(e) => setNickname(e.target.value)} onChange={(e) => setNickname(e.target.value)}
placeholder="Give it a name..." placeholder="Give it a name..."
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-yellow-500"
/> />
</div> </div>
)} )}
@@ -243,7 +232,7 @@ export function ShinyEncounterModal({
<div> <div>
<label <label
htmlFor="shiny-catch-level" htmlFor="shiny-catch-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Catch Level Catch Level
</label> </label>
@@ -259,17 +248,17 @@ export function ShinyEncounterModal({
? `${selectedPokemon.minLevel}${selectedPokemon.maxLevel}` ? `${selectedPokemon.minLevel}${selectedPokemon.maxLevel}`
: 'Level' : 'Level'
} }
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500" className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-yellow-500"
/> />
</div> </div>
)} )}
</div> </div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3"> <div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex justify-end gap-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" className="px-4 py-2 text-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 transition-colors"
> >
Cancel Cancel
</button> </button>

View File

@@ -6,26 +6,26 @@ interface StatCardProps {
} }
const colorClasses: Record<string, string> = { const colorClasses: Record<string, string> = {
blue: 'border-blue-500', blue: 'border-status-completed',
green: 'border-green-500', green: 'border-status-alive',
red: 'border-red-500', red: 'border-status-failed',
purple: 'border-purple-500', purple: 'border-purple-500',
amber: 'border-amber-500', amber: 'border-amber-500',
gray: 'border-gray-500', gray: 'border-text-tertiary',
} }
export function StatCard({ label, value, total, color }: StatCardProps) { export function StatCard({ label, value, total, color }: StatCardProps) {
return ( return (
<div <div
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 ${colorClasses[color] ?? 'border-gray-500'}`} className={`bg-surface-1 rounded-lg border border-border-default p-4 border-l-4 ${colorClasses[color] ?? 'border-text-tertiary'}`}
> >
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100"> <div className="text-2xl font-bold font-mono text-text-primary">
{value} {value}
{total !== undefined && ( {total !== undefined && (
<span className="text-sm font-normal text-gray-500 dark:text-gray-400"> / {total}</span> <span className="text-sm font-normal font-sans text-text-secondary"> / {total}</span>
)} )}
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400">{label}</div> <div className="text-sm text-text-secondary">{label}</div>
</div> </div>
) )
} }

View File

@@ -91,16 +91,13 @@ export function StatusChangeModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} /> <div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-sm w-full"> <div className="relative bg-surface-1 rounded-xl shadow-xl max-w-sm w-full">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-border-default">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-semibold text-text-primary">
{isDead ? 'Death Details' : 'Pokemon Status'} {isDead ? 'Death Details' : 'Pokemon Status'}
</h2> </h2>
<button <button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -122,51 +119,46 @@ export function StatusChangeModal({
className={`w-16 h-16 ${isDead ? 'grayscale opacity-60' : ''}`} className={`w-16 h-16 ${isDead ? 'grayscale opacity-60' : ''}`}
/> />
) : ( ) : (
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300"> <div className="w-16 h-16 rounded-full bg-surface-3 flex items-center justify-center text-xl font-bold text-text-secondary">
{displayPokemon.name[0]?.toUpperCase()} {displayPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<div> <div>
<div className="font-semibold text-gray-900 dark:text-gray-100"> <div className="font-semibold text-text-primary">
{nickname || displayPokemon.name} {nickname || displayPokemon.name}
</div> </div>
{nickname && ( {nickname && (
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize"> <div className="text-xs text-text-tertiary capitalize">{displayPokemon.name}</div>
{displayPokemon.name}
</div>
)} )}
<div className="flex flex-col items-start gap-0.5 mt-1"> <div className="flex flex-col items-start gap-0.5 mt-1">
{displayPokemon.types.map((type) => ( {displayPokemon.types.map((type) => (
<TypeBadge key={type} type={type} /> <TypeBadge key={type} type={type} />
))} ))}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="text-xs text-text-tertiary mt-1">
Lv. {catchLevel ?? '?'} &middot; {route.name} Lv. {catchLevel ?? '?'} &middot; {route.name}
</div> </div>
{currentPokemon && ( {currentPokemon && (
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5"> <div className="text-[10px] text-text-muted mt-0.5">Originally: {pokemon.name}</div>
Originally: {pokemon.name}
</div>
)} )}
</div> </div>
</div> </div>
{/* Dead pokemon: view-only details */} {/* Dead pokemon: view-only details */}
{isDead && ( {isDead && (
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 space-y-2"> <div className="bg-status-failed-bg rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400 font-medium text-sm"> <div className="flex items-center gap-2 text-status-failed font-medium text-sm">
<span className="w-2 h-2 rounded-full bg-red-500" /> <span className="w-2 h-2 rounded-full bg-red-500" />
Deceased Deceased
</div> </div>
{faintLevel !== null && ( {faintLevel !== null && (
<div className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-text-secondary">
<span className="text-gray-500 dark:text-gray-400">Level at death:</span>{' '} <span className="text-text-tertiary">Level at death:</span> {faintLevel}
{faintLevel}
</div> </div>
)} )}
{deathCause && ( {deathCause && (
<div className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-text-secondary">
<span className="text-gray-500 dark:text-gray-400">Cause:</span> {deathCause} <span className="text-text-tertiary">Cause:</span> {deathCause}
</div> </div>
)} )}
</div> </div>
@@ -178,7 +170,7 @@ export function StatusChangeModal({
<button <button
type="button" type="button"
onClick={() => setShowEvolve(true)} onClick={() => setShowEvolve(true)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors" className="flex-1 px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-colors"
> >
Evolve Evolve
</button> </button>
@@ -205,22 +197,20 @@ export function StatusChangeModal({
{!isDead && showEvolve && ( {!isDead && showEvolve && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"> <h3 className="text-sm font-medium text-text-secondary">Evolve into:</h3>
Evolve into:
</h3>
<button <button
type="button" type="button"
onClick={() => setShowEvolve(false)} onClick={() => setShowEvolve(false)}
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" className="text-xs text-text-tertiary hover:text-text-secondary"
> >
Back Back
</button> </button>
</div> </div>
{evolutionsLoading && ( {evolutionsLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p> <p className="text-sm text-text-tertiary">Loading evolutions...</p>
)} )}
{!evolutionsLoading && normalEvolutions.length === 0 && ( {!evolutionsLoading && normalEvolutions.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions available</p> <p className="text-sm text-text-tertiary">No evolutions available</p>
)} )}
{!evolutionsLoading && normalEvolutions.length > 0 && ( {!evolutionsLoading && normalEvolutions.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
@@ -230,7 +220,7 @@ export function StatusChangeModal({
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => handleEvolve(evo.toPokemon.id)} onClick={() => handleEvolve(evo.toPokemon.id)}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600 transition-colors disabled:opacity-50" className="w-full flex items-center gap-3 p-3 rounded-lg border border-border-default hover:bg-accent-900/20 hover:border-accent-600 transition-colors disabled:opacity-50"
> >
{evo.toPokemon.spriteUrl ? ( {evo.toPokemon.spriteUrl ? (
<img <img
@@ -239,15 +229,15 @@ export function StatusChangeModal({
className="w-10 h-10" className="w-10 h-10"
/> />
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300"> <div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-sm font-bold text-text-secondary">
{evo.toPokemon.name[0]?.toUpperCase()} {evo.toPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<div className="text-left"> <div className="text-left">
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm"> <div className="font-medium text-text-primary text-sm">
{evo.toPokemon.name} {evo.toPokemon.name}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-text-tertiary">
{formatEvolutionMethod(evo)} {formatEvolutionMethod(evo)}
</div> </div>
</div> </div>
@@ -262,9 +252,7 @@ export function StatusChangeModal({
{!isDead && showShedConfirm && shedCompanion && ( {!isDead && showShedConfirm && shedCompanion && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"> <h3 className="text-sm font-medium text-text-secondary">Shed Evolution</h3>
Shed Evolution
</h3>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -273,12 +261,12 @@ export function StatusChangeModal({
setShedNickname('') setShedNickname('')
setShowEvolve(true) setShowEvolve(true)
}} }}
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" className="text-xs text-text-tertiary hover:text-text-secondary"
> >
Back Back
</button> </button>
</div> </div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-3"> <div className="bg-amber-900/20 border border-amber-700 rounded-lg p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{shedCompanion.toPokemon.spriteUrl ? ( {shedCompanion.toPokemon.spriteUrl ? (
<img <img
@@ -287,11 +275,11 @@ export function StatusChangeModal({
className="w-12 h-12" className="w-12 h-12"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300"> <div className="w-12 h-12 rounded-full bg-surface-3 flex items-center justify-center text-sm font-bold text-text-secondary">
{shedCompanion.toPokemon.name[0]?.toUpperCase()} {shedCompanion.toPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<p className="text-sm text-amber-800 dark:text-amber-300"> <p className="text-sm text-amber-300">
{displayPokemon.name} shed its shell! Would you also like to add{' '} {displayPokemon.name} shed its shell! Would you also like to add{' '}
<span className="font-semibold">{shedCompanion.toPokemon.name}</span>? <span className="font-semibold">{shedCompanion.toPokemon.name}</span>?
</p> </p>
@@ -300,9 +288,9 @@ export function StatusChangeModal({
<div> <div>
<label <label
htmlFor="shed-nickname" htmlFor="shed-nickname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Nickname <span className="font-normal text-gray-400">(optional)</span> Nickname <span className="font-normal text-text-tertiary">(optional)</span>
</label> </label>
<input <input
id="shed-nickname" id="shed-nickname"
@@ -311,7 +299,7 @@ export function StatusChangeModal({
value={shedNickname} value={shedNickname}
onChange={(e) => setShedNickname(e.target.value)} onChange={(e) => setShedNickname(e.target.value)}
placeholder={shedCompanion.toPokemon.name} placeholder={shedCompanion.toPokemon.name}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-amber-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-amber-500"
/> />
</div> </div>
<div className="flex gap-3 pt-1"> <div className="flex gap-3 pt-1">
@@ -319,7 +307,7 @@ export function StatusChangeModal({
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => applyEvolution(false)} onClick={() => applyEvolution(false)}
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors" className="flex-1 px-4 py-2 bg-surface-2 text-text-secondary rounded-lg font-medium hover:bg-surface-3 disabled:opacity-50 transition-colors"
> >
Skip Skip
</button> </button>
@@ -339,13 +327,11 @@ export function StatusChangeModal({
{!isDead && showFormChange && ( {!isDead && showFormChange && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"> <h3 className="text-sm font-medium text-text-secondary">Change form to:</h3>
Change form to:
</h3>
<button <button
type="button" type="button"
onClick={() => setShowFormChange(false)} onClick={() => setShowFormChange(false)}
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" className="text-xs text-text-tertiary hover:text-text-secondary"
> >
Back Back
</button> </button>
@@ -358,19 +344,17 @@ export function StatusChangeModal({
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => handleEvolve(form.id)} onClick={() => handleEvolve(form.id)}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 dark:hover:border-purple-600 transition-colors disabled:opacity-50" className="w-full flex items-center gap-3 p-3 rounded-lg border border-border-default hover:bg-purple-900/20 hover:border-purple-600 transition-colors disabled:opacity-50"
> >
{form.spriteUrl ? ( {form.spriteUrl ? (
<img src={form.spriteUrl} alt={form.name} className="w-10 h-10" /> <img src={form.spriteUrl} alt={form.name} className="w-10 h-10" />
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300"> <div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-sm font-bold text-text-secondary">
{form.name[0]?.toUpperCase()} {form.name[0]?.toUpperCase()}
</div> </div>
)} )}
<div className="text-left"> <div className="text-left">
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm"> <div className="font-medium text-text-primary text-sm">{form.name}</div>
{form.name}
</div>
<div className="flex gap-1"> <div className="flex gap-1">
{form.types.map((type) => ( {form.types.map((type) => (
<TypeBadge key={type} type={type} /> <TypeBadge key={type} type={type} />
@@ -387,8 +371,8 @@ export function StatusChangeModal({
{/* Confirmation form */} {/* Confirmation form */}
{!isDead && showConfirm && ( {!isDead && showConfirm && (
<div className="space-y-3"> <div className="space-y-3">
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3"> <div className="bg-status-failed-bg rounded-lg p-3">
<p className="text-sm text-red-700 dark:text-red-400 font-medium"> <p className="text-sm text-status-failed font-medium">
This cannot be undone (Nuzlocke rules). This cannot be undone (Nuzlocke rules).
</p> </p>
</div> </div>
@@ -396,9 +380,9 @@ export function StatusChangeModal({
<div> <div>
<label <label
htmlFor="death-level" htmlFor="death-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Level at Death <span className="font-normal text-gray-400">(optional)</span> Level at Death <span className="font-normal text-text-tertiary">(optional)</span>
</label> </label>
<input <input
id="death-level" id="death-level"
@@ -408,16 +392,16 @@ export function StatusChangeModal({
value={deathLevel} value={deathLevel}
onChange={(e) => setDeathLevel(e.target.value)} onChange={(e) => setDeathLevel(e.target.value)}
placeholder="Level" placeholder="Level"
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-500" className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-red-500"
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="death-cause" htmlFor="death-cause"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-text-secondary mb-1"
> >
Cause of Death <span className="font-normal text-gray-400">(optional)</span> Cause of Death <span className="font-normal text-text-tertiary">(optional)</span>
</label> </label>
<input <input
id="death-cause" id="death-cause"
@@ -426,7 +410,7 @@ export function StatusChangeModal({
value={cause} value={cause}
onChange={(e) => setCause(e.target.value)} onChange={(e) => setCause(e.target.value)}
placeholder="e.g. Crit from rival's Charizard" placeholder="e.g. Crit from rival's Charizard"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-500" className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-red-500"
/> />
</div> </div>
@@ -434,7 +418,7 @@ export function StatusChangeModal({
<button <button
type="button" type="button"
onClick={() => setShowConfirm(false)} onClick={() => setShowConfirm(false)}
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" className="flex-1 px-4 py-2 bg-surface-2 text-text-secondary rounded-lg font-medium hover:bg-surface-3 transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -454,11 +438,11 @@ export function StatusChangeModal({
{/* Footer for dead/no-confirm/no-evolve views */} {/* Footer for dead/no-confirm/no-evolve views */}
{(isDead || {(isDead ||
(!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && ( (!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end"> <div className="px-6 py-4 border-t border-border-default flex justify-end">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" className="px-4 py-2 bg-surface-2 text-text-secondary rounded-lg font-medium hover:bg-surface-3 transition-colors"
> >
Close Close
</button> </button>

View File

@@ -27,10 +27,10 @@ export function StepIndicator({
disabled={!isCompleted} disabled={!isCompleted}
className={`flex items-center gap-2 text-sm font-medium ${ className={`flex items-center gap-2 text-sm font-medium ${
isCompleted isCompleted
? 'text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300' ? 'text-text-link cursor-pointer hover:text-accent-300'
: isCurrent : isCurrent
? 'text-gray-900 dark:text-gray-100' ? 'text-text-primary'
: 'text-gray-400 dark:text-gray-500 cursor-default' : 'text-text-muted cursor-default'
}`} }`}
> >
<span <span
@@ -38,8 +38,8 @@ export function StepIndicator({
isCompleted isCompleted
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: isCurrent : isCurrent
? 'border-2 border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' ? 'border-2 border-accent-400 text-accent-400'
: 'border-2 border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500' : 'border-2 border-border-default text-text-muted'
}`} }`}
> >
{isCompleted ? ( {isCompleted ? (
@@ -61,7 +61,7 @@ export function StepIndicator({
{i < steps.length - 1 && ( {i < steps.length - 1 && (
<div <div
className={`flex-1 h-0.5 mx-3 ${ className={`flex-1 h-0.5 mx-3 ${
step < currentStep ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600' step < currentStep ? 'bg-blue-600' : 'bg-surface-3'
}`} }`}
/> />
)} )}

View File

@@ -26,12 +26,10 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" /> <div className="fixed inset-0 bg-black/50" />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto"> <div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl"> <div className="sticky top-0 bg-surface-1 border-b border-border-default px-6 py-4 rounded-t-xl">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-semibold text-text-primary">Transfer Pokemon to Next Leg</h2>
Transfer Pokemon to Next Leg <p className="text-sm text-text-tertiary mt-1">
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Selected Pokemon will be bred down to their base form and appear as level 1 encounters Selected Pokemon will be bred down to their base form and appear as level 1 encounters
in the next leg. in the next leg.
</p> </p>
@@ -50,8 +48,8 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
onClick={() => toggle(enc.id)} onClick={() => toggle(enc.id)}
className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${ className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${
isSelected isSelected
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20' ? 'border-indigo-500 bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' : 'border-border-default hover:border-border-default'
}`} }`}
> >
{displayPokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
@@ -61,34 +59,34 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
className="w-14 h-14" className="w-14 h-14"
/> />
) : ( ) : (
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold"> <div className="w-14 h-14 rounded-full bg-surface-3 flex items-center justify-center text-lg font-bold">
{displayPokemon.name[0]?.toUpperCase()} {displayPokemon.name[0]?.toUpperCase()}
</div> </div>
)} )}
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize"> <span className="text-xs font-medium text-text-secondary mt-1 capitalize">
{enc.nickname || displayPokemon.name} {enc.nickname || displayPokemon.name}
</span> </span>
{enc.nickname && ( {enc.nickname && (
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span> <span className="text-[10px] text-text-tertiary">{displayPokemon.name}</span>
)} )}
<span className="text-[10px] text-gray-400 mt-0.5">{enc.route.name}</span> <span className="text-[10px] text-text-tertiary mt-0.5">{enc.route.name}</span>
</button> </button>
) )
})} })}
</div> </div>
</div> </div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex items-center justify-between"> <div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex items-center justify-between">
<button <button
type="button" type="button"
onClick={onSkip} onClick={onSkip}
disabled={isPending} disabled={isPending}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-50" className="text-sm text-text-tertiary hover:text-text-primary disabled:opacity-50"
> >
Skip (No Transfers) Skip (No Transfers)
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-gray-400 dark:text-gray-500"> <span className="text-sm text-text-muted">
{selected.size}/{hofTeam.length} selected {selected.size}/{hofTeam.length} selected
</span> </span>
<button <button

View File

@@ -22,8 +22,8 @@ export function AdminLayout() {
className={({ isActive }) => className={({ isActive }) =>
`block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${ `block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${
isActive isActive
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200' ? 'bg-accent-900/40 text-accent-300 light:bg-accent-100 light:text-accent-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-700' : 'hover:bg-surface-2'
}` }`
} }
> >

View File

@@ -61,26 +61,26 @@ export function AdminTable<T>({
if (isLoading) { if (isLoading) {
return ( return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="border border-border-default rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-border-default">
<thead className="bg-gray-50 dark:bg-gray-800"> <thead className="bg-surface-1">
<tr> <tr>
{columns.map((col) => ( {columns.map((col) => (
<th <th
key={col.header} key={col.header}
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''}`} className={`px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider ${col.className ?? ''}`}
> >
{col.header} {col.header}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-surface-0 divide-y divide-border-default">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<tr key={i}> <tr key={i}>
{columns.map((col) => ( {columns.map((col) => (
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}> <td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" /> <div className="h-4 bg-surface-3 rounded animate-pulse" />
</td> </td>
))} ))}
</tr> </tr>
@@ -93,17 +93,17 @@ export function AdminTable<T>({
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg"> <div className="text-center py-8 text-text-tertiary border border-border-default rounded-lg">
{emptyMessage} {emptyMessage}
</div> </div>
) )
} }
return ( return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="border border-border-default rounded-lg overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-border-default">
<thead className="bg-gray-50 dark:bg-gray-800"> <thead className="bg-surface-1">
<tr> <tr>
{columns.map((col) => { {columns.map((col) => {
const sortable = !!col.sortKey const sortable = !!col.sortKey
@@ -112,7 +112,7 @@ export function AdminTable<T>({
<th <th
key={col.header} key={col.header}
onClick={sortable ? () => handleSort(col.header) : undefined} onClick={sortable ? () => handleSort(col.header) : undefined}
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`} className={`px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-text-primary' : ''}`}
> >
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{col.header} {col.header}
@@ -127,14 +127,12 @@ export function AdminTable<T>({
})} })}
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-surface-0 divide-y divide-border-default">
{sortedData.map((row) => ( {sortedData.map((row) => (
<tr <tr
key={keyFn(row)} key={keyFn(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined} onClick={onRowClick ? () => onRowClick(row) : undefined}
className={ className={onRowClick ? 'cursor-pointer hover:bg-surface-2' : ''}
onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''
}
> >
{columns.map((col) => ( {columns.map((col) => (
<td <td

View File

@@ -107,7 +107,7 @@ export function BossBattleFormModal({
<button <button
type="button" type="button"
onClick={onEditTeam} onClick={onEditTeam}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline" className="text-sm text-text-link hover:underline"
> >
Edit Team ({boss?.pokemon.length ?? 0}) Edit Team ({boss?.pokemon.length ?? 0})
</button> </button>
@@ -123,7 +123,7 @@ export function BossBattleFormModal({
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Brock" placeholder="e.g. Brock"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
<div> <div>
@@ -131,7 +131,7 @@ export function BossBattleFormModal({
<select <select
value={bossType} value={bossType}
onChange={(e) => setBossType(e.target.value as typeof bossType)} onChange={(e) => setBossType(e.target.value as typeof bossType)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
> >
{BOSS_TYPES.map((t) => ( {BOSS_TYPES.map((t) => (
<option key={t.value} value={t.value}> <option key={t.value} value={t.value}>
@@ -145,7 +145,7 @@ export function BossBattleFormModal({
<select <select
value={specialtyType} value={specialtyType}
onChange={(e) => setSpecialtyType(e.target.value)} onChange={(e) => setSpecialtyType(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600 capitalize" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default capitalize"
> >
<option value="">None</option> <option value="">None</option>
{POKEMON_TYPES.map((t) => ( {POKEMON_TYPES.map((t) => (
@@ -165,7 +165,7 @@ export function BossBattleFormModal({
value={location} value={location}
onChange={(e) => setLocation(e.target.value)} onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Pewter City Gym" placeholder="e.g. Pewter City Gym"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
@@ -178,7 +178,7 @@ export function BossBattleFormModal({
min={1} min={1}
value={levelCap} value={levelCap}
onChange={(e) => setLevelCap(e.target.value)} onChange={(e) => setLevelCap(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
<div> <div>
@@ -189,7 +189,7 @@ export function BossBattleFormModal({
min={1} min={1}
value={order} value={order}
onChange={(e) => setOrder(e.target.value)} onChange={(e) => setOrder(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
</div> </div>
@@ -202,7 +202,7 @@ export function BossBattleFormModal({
value={section} value={section}
onChange={(e) => setSection(e.target.value)} onChange={(e) => setSection(e.target.value)}
placeholder="e.g. Main Story, Endgame" placeholder="e.g. Main Story, Endgame"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
{games && games.length > 1 && ( {games && games.length > 1 && (
@@ -211,7 +211,7 @@ export function BossBattleFormModal({
<select <select
value={gameId} value={gameId}
onChange={(e) => setGameId(e.target.value)} onChange={(e) => setGameId(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
> >
<option value="">All games</option> <option value="">All games</option>
{games.map((g) => ( {games.map((g) => (
@@ -229,7 +229,7 @@ export function BossBattleFormModal({
<select <select
value={afterRouteId} value={afterRouteId}
onChange={(e) => setAfterRouteId(e.target.value)} onChange={(e) => setAfterRouteId(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
> >
<option value="">None</option> <option value="">None</option>
{sortedRoutes.map((r) => ( {sortedRoutes.map((r) => (
@@ -248,7 +248,7 @@ export function BossBattleFormModal({
value={badgeName} value={badgeName}
onChange={(e) => setBadgeName(e.target.value)} onChange={(e) => setBadgeName(e.target.value)}
placeholder="Optional" placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
<div> <div>
@@ -258,7 +258,7 @@ export function BossBattleFormModal({
value={badgeImageUrl} value={badgeImageUrl}
onChange={(e) => setBadgeImageUrl(e.target.value)} onChange={(e) => setBadgeImageUrl(e.target.value)}
placeholder="Optional" placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
</div> </div>
@@ -270,7 +270,7 @@ export function BossBattleFormModal({
value={spriteUrl} value={spriteUrl}
onChange={(e) => setSpriteUrl(e.target.value)} onChange={(e) => setSpriteUrl(e.target.value)}
placeholder="Optional" placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </div>
</FormModal> </FormModal>

Some files were not shown because too many files have changed in this diff Show More