Compare commits

8 Commits

Author SHA1 Message Date
321b940398 Merge pull request 'Fix FK violations when pruning stale routes' (#32) from develop into main
Reviewed-on: #32
2026-02-21 17:56:57 +01:00
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
e21a8acc60 Merge pull request 'Housekeeping: archive beans, add seed pruning' (#31) from develop into main
Reviewed-on: #31
2026-02-21 17:46:57 +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
38 changed files with 174 additions and 779 deletions

View File

@@ -1,31 +0,0 @@
---
# nuzlocke-tracker-0arz
title: Integration tests for Runs & Encounters API
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:21Z
updated_at: 2026-02-21T11:54:42Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the core run tracking and encounter API endpoints. This is the heart of the application.
## Checklist
- [x] Test run CRUD operations (create, list, get, update, delete)
- [x] Test run creation with rules configuration (JSONB field)
- [x] Test encounter logging on a run (create encounter on a route)
- [x] Test encounter status changes (alive → dead, faintLevel, deathCause)
- [x] Test route-lock enforcement (duplicate sibling encounter → 409)
- [x] Test shiny encounter handling (shinyClause bypasses route-lock)
- [x] Test gift clause bypass (giftClause=true, origin=gift bypasses route-lock)
- [x] Test ending a run (completion/failure, completed_at set, 400 on double-end)
- [x] Test error cases (404 for invalid run/route/pokemon, 400 for parent route, 422 for missing fields)
## 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,21 +0,0 @@
---
# nuzlocke-tracker-1guz
title: Component tests for key frontend components
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:45Z
updated_at: 2026-02-21T12:53:51Z
parent: nuzlocke-tracker-yzpb
---
Write component tests for key frontend React components, focusing on user interactions and rendering correctness.
Test components with no external hook dependencies directly; mock `useTheme` where needed. Use @testing-library/user-event for interactions.
## Checklist
- [x] Test `EndRunModal` — Victory/Defeat/Cancel button callbacks, genlocke description text, disabled state
- [x] Test `GameGrid` — renders games, generation filter, region filter, onSelect callback
- [x] Test `RulesConfiguration` — renders rule sections, toggle calls onChange, type restriction toggle, reset button
- [x] Test `Layout` — nav links present, mobile menu toggle, theme toggle button

View File

@@ -1,9 +0,0 @@
---
# nuzlocke-tracker-1qzo
title: Fix WCAG AA color contrast violations
status: completed
type: bug
priority: high
created_at: 2026-02-20T19:19:32Z
updated_at: 2026-02-20T19:20:25Z
---

View File

@@ -1,53 +0,0 @@
---
# nuzlocke-tracker-49xj
title: Overhaul Nuzlocke Rules System
status: completed
type: epic
priority: normal
created_at: 2026-02-20T13:22:23Z
updated_at: 2026-02-21T11:23:31Z
---
Audit and overhaul the nuzlocke rules configuration. The current rules are a flat collection of boolean settings, some of which don't meaningfully affect tracker behavior. This epic cleans up existing rules and adds new rules for popular variants with actual tracker logic.
## Scope
### Rules to REMOVE (5)
These rules either define what a nuzlocke is (always true) or don't affect tracker behavior at all:
- `firstEncounterOnly` — implicit; it's a nuzlocke tracker
- `permadeath` — implicit; it's a nuzlocke tracker
- `nicknameRequired` — not enforced or tracked
- `setModeOnly` — not enforced or tracked
- `postGameCompletion` — not enforced or tracked
### Rules to KEEP (5)
These actively affect tracker logic:
- `duplicatesClause` — used in encounter creation and bulk randomization
- `shinyClause` — used in encounter creation (bypass route-lock)
- `pinwheelClause` — used for zone-based encounter logic
- `hardcoreMode` — used in BossDefeatModal (auto-win, 1 attempt)
- `levelCaps` — displayed in sticky bar on encounters page
### New rules to ADD (4)
These are boolean flags with real tracker logic:
- `egglocke` — all caught Pokemon are replaced with traded eggs. When enabled, encounter Pokemon selection should allow picking from ALL Pokemon (not just the game's regional dex), similar to the admin panel encounter creation / boss team creation flow.
- `wonderlocke` — all caught Pokemon are Wonder Traded away. Same as egglocke: encounter Pokemon selection allows picking from ALL Pokemon.
- `randomizer` — the run uses a randomized ROM. Same behavior: encounter Pokemon selection allows picking from ALL Pokemon since the dex is randomized.
- `giftClause` — in-game gift Pokemon are free and do not count against the area's encounter limit. When enabled, gift-origin encounters should bypass the route-lock check (similar to how shinyClause bypasses it for shinies).
### Complex rules (need design work)
These need more complex logic and are tracked as draft sub-tasks:
- Type Restrictions (Monolocke) — bs0y
- Team Size Limit — fv7w
- Static/Legendary Clause — knnc
## Children
Work is tracked in sub-tasks:
- **o7r8** — Remove unused nuzlocke rules
- **fitk** — Add egglocke, wonderlocke, and randomizer rules
- **sij8** — Add gift clause rule
- **bs0y** — Add type restriction rules (monolocke) *(draft)*
- **fv7w** — Add team size limit rule *(draft)*
- **knnc** — Add static/legendary clause rule *(draft)*

View File

@@ -1,25 +0,0 @@
---
# nuzlocke-tracker-4a6i
title: Replace CI pipeline with test suite
status: completed
type: task
priority: normal
created_at: 2026-02-21T13:01:01Z
updated_at: 2026-02-21T13:10:15Z
---
Replace the current `.github/workflows/ci.yml` with a workflow that runs the actual test suites. The existing jobs (lint, format, type check) are already enforced by pre-commit hooks (prek), so CI should focus on test execution instead.
## Context
- **Backend integration tests**: pytest with `TEST_DATABASE_URL` pointing at a postgres service container. Default URL: `postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test`. Tests live in `backend/tests/`.
- **Frontend unit tests**: vitest (`npm run test -- --run`). No external services needed.
- **E2e tests**: Playwright. `e2e/global-setup.ts` uses `docker compose -p nuzlocke-test -f docker-compose.test.yml up -d --build` to start a test API + DB, then seeds data via the API. `playwright.config.ts` spins up `npm run dev` as the webServer. Need to install Chromium via `npx playwright install --with-deps chromium`.
## Checklist
- [x] Add `backend-tests` job: postgres service container (image postgres:16-alpine, user/pass/db matching conftest defaults), install deps with `uv`, run `pytest backend/tests/ -q`
- [x] Add `frontend-tests` job: node 24, `npm ci` in `frontend/`, run `npm run test -- --run`
- [x] Add `e2e-tests` job: install Docker Compose, install Playwright + Chromium deps, run `npx playwright test` from `frontend/`; upload HTML report as artifact on failure
- [x] Keep the `actions-lint` job (actionlint + zizmor); remove `backend-lint` and `frontend-lint` jobs
- [x] Pin all action versions to SHA with version comments; pass `zizmor` audit

View File

@@ -1,31 +0,0 @@
---
# nuzlocke-tracker-9c66
title: Integration tests for Genlockes & Bosses API
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:26Z
updated_at: 2026-02-21T12:20:37Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the genlocke challenge and boss battle API endpoints.
## Checklist
- [x] Test genlocke CRUD operations (create, list, get, update, delete)
- [x] Test leg management (add/remove legs to a genlocke)
- [x] Test Pokemon transfers between genlocke legs
- [x] Test boss battle CRUD (create, list, update, delete per game)
- [x] Test boss battle results per run (record win/loss)
- [x] Test stats endpoint for run statistics
- [x] Test export endpoint
- [x] 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: completed
type: task
priority: normal
created_at: 2026-02-10T14:46:09Z
updated_at: 2026-02-17T19:17:22Z
---
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
- [x] Update project name in package.json / config files
- [x] Update page titles, meta tags, and any visible app name references
- [x] Update README and any documentation with the new name
- [x] Design or source a Durant-themed logo/icon
- [x] Update favicon and app icons

View File

@@ -1,33 +0,0 @@
---
# nuzlocke-tracker-bs0y
title: Add type restriction rules (monolocke)
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:16Z
updated_at: 2026-02-21T11:22:16Z
parent: nuzlocke-tracker-49xj
---
Restrict team composition to specific types (monolocke and similar variants).
## Design Decisions
**Type selection:** Multi-select from the 18 standard Pokemon types. A monolocke selects one type; multi-type variants (e.g., "fire and water only") select multiple.
**Dual-type matching:** A Pokemon qualifies if at least one of its types is in the allowed set. This matches the community standard for monolocke — e.g., in a Fire monolocke, Charizard (Fire/Flying) is allowed because it has Fire.
**Enforcement:** Soft enforcement via UI warnings, not hard blocks. The tracker warns when a caught Pokemon doesn't match the allowed types but doesn't prevent logging it. Reason: players sometimes need to use HM slaves or have edge cases the tracker shouldn't block.
**Data model:** Add `allowedTypes: string[]` to `NuzlockeRules`. Empty array means no restriction (disabled). This keeps it in the existing JSONB rules blob on the run.
**UI:** Add a "Type Restrictions" section to `RulesConfiguration` with a multi-select type picker (reuse the type badge styling from `TypeBadge`). Show a warning badge on encounters that don't match.
## Checklist
- [x] Add `allowedTypes: string[]` to `NuzlockeRules` interface (default: `[]`)
- [x] Add a new `BooleanRuleKeys` type to `RuleDefinition` to exclude non-boolean fields
- [x] Add type multi-select UI to `RulesConfiguration` (shown when allowedTypes toggle is on)
- [x] Show warning indicator on `PokemonCard` and encounter list for Pokemon that don't match allowed types
- [x] Add `RuleBadge` display for active type restriction (e.g., "Monolocke: Fire")
- [x] Update `RuleBadges` to handle `allowedTypes` separately from boolean rules

View File

@@ -1,31 +0,0 @@
---
# nuzlocke-tracker-ch77
title: Integration tests for Games & Routes API
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:13Z
updated_at: 2026-02-21T11:48:10Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the games and routes API endpoints in backend/src/app/api/games.py.
## Key behaviors to test
- Game CRUD: create (201), list, get with routes, update, delete (204)
- Slug uniqueness enforced at create and update (409)
- 404 for missing games
- 422 for invalid request bodies
- Route operations require version_group_id on the game (need VersionGroup fixture via db_session)
- list_game_routes only returns routes with encounters (or parents of routes with encounters)
- Game detail (GET /{id}) returns all routes regardless
- Route create, update, delete, reorder
## Checklist
- [x] Test CRUD operations for games (create, list, get, update, delete)
- [x] Test route management within a game (create, list, update, delete, reorder)
- [x] Test error cases (404, 409 duplicate slug, 422 validation)
- [x] Test list_game_routes filtering behavior (empty routes excluded)
- [x] Test by-region endpoint structure

View File

@@ -1,33 +0,0 @@
---
# nuzlocke-tracker-d8cp
title: Set up frontend test infrastructure
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:33Z
updated_at: 2026-02-21T12:32:34Z
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
- [x] Install Vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom
- [x] Configure Vitest in `vite.config.ts` or a dedicated `vitest.config.ts`
- [x] Set up jsdom as the test environment
- [x] Create a test setup file (e.g. `src/test/setup.ts`) that imports @testing-library/jest-dom matchers
- [x] Create test utility helpers (e.g. render wrapper with providers — QueryClientProvider, BrowserRouter)
- [x] Add a \`test\` script to package.json
- [x] Verify the setup by writing a simple smoke test
- [x] Set up MSW (Mock Service Worker) or a similar API mocking strategy for hook/component tests — using `vi.mock` instead; MSW deferred until needed
## 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

@@ -1,127 +0,0 @@
---
# nuzlocke-tracker-dpw7
title: Modernize website design and look-and-feel
status: completed
type: feature
priority: normal
created_at: 2026-02-17T19:16:39Z
updated_at: 2026-02-20T19:05:21Z
---
Overhaul the UI to a dark-first, techy aesthetic with a cohesive brand identity derived from the ANT steel ant logo.
## Design direction
**Dark & techy** — dark-first surfaces, subtle glow/accent effects, code-editor-influenced aesthetic. Think GitHub dark, Discord, or Linear dark mode. Light mode becomes the secondary theme.
## 1. Brand palette + Tailwind theme
Define custom Tailwind v4 theme tokens in `index.css` using `@theme`:
- **Surfaces:** dark navy/charcoal base (`#0f1117`, `#161b22`, `#1c2128`) with layered elevation (darker = further back, lighter = elevated)
- **Accent:** steel blue from the logo (`#395E73`, `#7EB0CE`) as the primary interactive color
- **Text:** off-white primary (`#e6edf3`), muted gray secondary (`#7d8590`)
- **Status colors:** keep green/red/blue semantics but shift to darker, more saturated variants that work on dark surfaces
- **Borders:** subtle (`rgba(255,255,255,0.08)`) instead of gray-200/700
Replace ad-hoc Tailwind color classes throughout all components with theme tokens.
## 2. Typography
Self-host **Geist** (or Inter/JetBrains Mono pairing):
- Geist Sans for UI text (headings, labels, body)
- Geist Mono for data-heavy elements (stats numbers, encounter rates, levels)
- Set up via `@font-face` in `index.css`, configure in Tailwind `@theme`
- Establish clear size/weight hierarchy: page titles (2xl bold), section headers (lg semibold), body (sm regular), labels (xs medium)
## 3. Navigation redesign
- Add the ant SVG logo mark next to "ANT" in the nav
- Active route indicator (accent-colored underline or background highlight)
- Subtle bottom border glow or gradient accent line
- Slightly translucent/backdrop-blur nav background for depth
- Better mobile menu transitions (slide or fade instead of instant toggle)
## 4. Home page hero
- Full-width dark gradient hero section with the ant logo as a subtle watermark/background element
- Tagline with stronger typography hierarchy
- Stats summary (total runs, completion rate) as glowing stat pills if the user has data
- CTA button with accent glow/gradient
## 5. Cards & surfaces
- Dark elevated cards (`bg-[#161b22]`) with subtle border (`border-white/[0.06]`)
- Hover state: slight border brightness increase + subtle shadow glow in accent color
- Active/selected states with accent border
- Pokemon cards: dark backgrounds make sprites pop better, accent ring on hover
- Stat cards: accent-colored left border or top gradient
- Modals: dark overlay with backdrop-blur, card-style modal surface
## 6. Status indicators & badges
- Status badges: more vibrant on dark backgrounds (alive=emerald glow, dead=red glow, caught=blue)
- Type badges: use the established Pokemon type colors but tuned for dark surfaces
- Encounter method badges: same treatment
- Pulse animation on active run indicators
## 7. Micro-interactions
- Smooth transitions on all interactive elements (`transition-all duration-150`)
- Hover lift on cards (`hover:-translate-y-0.5`)
- Button press feedback (`active:scale-[0.98]`)
- Loading spinners in accent color
- Skeleton loading states for data-heavy pages
## 8. Dark/light mode
- Dark is the default and primary design target
- Light mode: invert surfaces to white/gray-50, keep accent colors, adjust contrast
- Toggle in nav (sun/moon icon)
- Persist preference in localStorage, respect `prefers-color-scheme`
## Checklist
- [x] Define Tailwind v4 `@theme` tokens (colors, fonts, spacing) in `index.css`
- [x] Self-host Geist font family, configure in theme
- [x] Redesign nav bar (logo mark, active states, backdrop blur, dark surface)
- [x] Redesign home page hero section
- [x] Update card/surface styles globally (Layout, PokemonCard, StatCard, GameCard)
- [x] Update all page-level backgrounds and containers
- [x] Update modal styles (EncounterModal, StatusChangeModal, etc.)
- [x] Update badge/indicator styles (TypeBadge, RuleBadges, EncounterMethodBadge)
- [x] Add dark/light mode toggle to nav
- [x] Polish hover states and transitions across all interactive elements
- [x] Add automated Playwright accessibility and mobile layout tests
- [x] Verify accessibility (contrast ratios, focus indicators)
- [x] Verify mobile layout and touch targets
## Automated verification approach
Add a Playwright test suite that covers both accessibility and mobile layout:
### Accessibility (axe-core + Playwright)
- Install `@axe-core/playwright` as a dev dependency
- Write a test that visits each major page and runs axe-core
- Pages to cover: Home, RunList, RunDashboard, RunEncounters, Stats, NewRun, GenlockeList, GenlockeDetail, NewGenlocke, admin pages
- Check for: color contrast (WCAG AA), missing ARIA labels, heading hierarchy, focus indicators, form label associations
- Run as part of CI
### Mobile layout (Playwright viewports)
- Test each major page at 3 viewport sizes: mobile (375x667), tablet (768x1024), desktop (1280x800)
- Assert no horizontal overflow (`document.documentElement.scrollWidth <= window.innerWidth`)
- Assert touch targets are at least 44x44px (axe-core `target-size` rule)
- Screenshot each page at each viewport for visual review
### Implementation
- Add test file: `frontend/e2e/accessibility.spec.ts`
- Add Playwright config if not present
- Add `test:a11y` script to `package.json`
## Constraints
- Tailwind-only (no additional CSS frameworks or component libraries)
- Self-hosted fonts only (no Google Fonts CDN)
- Maintain accessibility (WCAG AA contrast ratios, visible focus indicators)
- No performance regression (fonts loaded with `font-display: swap`, no layout shift)

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,27 +0,0 @@
---
# nuzlocke-tracker-ee9s
title: Unit tests for frontend utilities and hooks
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:38Z
updated_at: 2026-02-21T12:47:19Z
parent: nuzlocke-tracker-yzpb
---
Write unit tests for the frontend utility functions and custom React hooks.
All API modules are mocked with `vi.mock`. Hooks are tested with `renderHook` from @testing-library/react, wrapped in `QueryClientProvider`. Mutation tests spy on `queryClient.invalidateQueries` to verify cache invalidation.
## Checklist
- [x] Test `utils/formatEvolution.ts` — done in smoke test
- [x] Test `utils/download.ts` — blob URL creation, filename, cleanup
- [x] Test `hooks/useGames.ts` — query hooks and disabled state
- [x] Test `hooks/useRuns.ts` — query hooks + mutations with cache invalidation
- [x] Test `hooks/useEncounters.ts` — mutations and conditional queries
- [x] Test `hooks/usePokemon.ts` — conditional queries
- [x] Test `hooks/useGenlockes.ts` — queries and mutations
- [x] Test `hooks/useBosses.ts` — queries and mutations
- [x] Test `hooks/useStats.ts` — single query hook
- [x] Test `hooks/useAdmin.ts` — representative subset (usePokemonList, useCreateGame, useDeleteGame)

View File

@@ -1,10 +0,0 @@
---
# nuzlocke-tracker-fitk
title: Add egglocke, wonderlocke, and randomizer rules
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:05Z
updated_at: 2026-02-20T20:31:29Z
parent: nuzlocke-tracker-49xj
---

View File

@@ -1,29 +0,0 @@
---
# nuzlocke-tracker-fv7w
title: Add boss team match rule
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:22Z
updated_at: 2026-02-20T21:03:20Z
parent: nuzlocke-tracker-49xj
---
When enabled, hint to the player that they should limit their active party to the same number of Pokemon as the next boss fight. This is a self-imposed difficulty rule — the tracker cannot enforce it since it doesn't track the active party, but it can surface the information.
## Design
**Rule:** Add `bossTeamMatch: boolean` to `NuzlockeRules` (default: `false`, category: `playstyle`).
**Display:** When enabled and the sticky boss banner is shown, add a hint next to the boss name showing their team size, e.g. "Next: Brock (2 Pokemon — match their team)". This reuses the existing `nextBoss` and its `pokemon` array.
**Variant bosses:** Some bosses have conditional teams (e.g. rival starter choice). Use the same logic as `BossTeamPreview`: count pokemon without a `conditionLabel` plus those matching the auto-detected variant (via `matchVariant`). Falls back to first variant if no match is detected.
**Scope:** Frontend-only. No backend or data model changes needed.
## Checklist
- [x] Add `bossTeamMatch: boolean` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
- [x] Add `RuleDefinition` entry (category: `playstyle`)
- [x] Show boss team size hint in the sticky level cap banner when the rule is enabled
- [x] Handle variant boss teams (use auto-matched variant count when available)

View File

@@ -1,29 +0,0 @@
---
# nuzlocke-tracker-hjkk
title: Unit tests for Pydantic schemas and model validation
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:03Z
updated_at: 2026-02-21T11:39:58Z
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
- [x] Test `CamelModel` base class (snake_case → camelCase alias generation)
- [x] Test run schemas — creation validation, required fields, optional fields, serialization
- [x] Test game schemas — validation rules, field constraints
- [x] Test encounter schemas — status enum validation, field dependencies
- [x] Test boss schemas — nested model validation
- [x] Test genlocke schemas — complex nested structures
- [x] Test evolution schemas — validation of evolution chain data
- [x] Test Pokemon create schema (types list, required fields)
## 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,28 +0,0 @@
---
# nuzlocke-tracker-iam7
title: Unit tests for services layer
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:08Z
updated_at: 2026-02-21T12:01:23Z
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
- [x] Test family resolution with simple linear evolution chains (e.g. A → B → C)
- [x] Test family resolution with branching evolutions (e.g. Eevee / Shedinja)
- [x] Test disjoint chains remain separate families
- [x] Test edge cases: empty list, single-stage Pokemon, base form, middle form
- [x] Test resolve_base_form: linear, branching, Shedinja, not-in-any-evolution
- [x] Test to_roman: parametrized 1100, genlocke sequence IV
- [x] Test strip_roman_suffix: II/III/IV/X, no suffix, round-trip with to_roman
## 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

@@ -1,38 +0,0 @@
---
# nuzlocke-tracker-knnc
title: Add static encounter filter rule
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:27Z
updated_at: 2026-02-21T11:04:45Z
parent: nuzlocke-tracker-49xj
---
Control whether static encounters are available in the encounter selector. Static encounters already exist in the route encounter tables (e.g., Zapdos in Power Plant, Snorlax on Route 7 in X/Y). This rule acts as a display filter, not a route-lock bypass like gift clause.
## Motivation
Static encounters can feel unfair in nuzlockes because they are deterministic — the player is forced to pick a specific Pokemon rather than getting the randomness that makes nuzlockes fun. Example: Snorlax blocks Route 7 in X/Y. By definition it is the first encounter, but being forced to take it reduces variety.
Some static encounters are also overpowered (legendaries), which some players want to avoid.
## Design
**Rule:** `staticClause: boolean` (default: true — static encounters enabled by default). When disabled, encounters with a `static` encounter method are hidden or grayed out in the encounter selector, so the player skips them and gets a different first encounter.
**This is NOT like gift clause.** There is no dual-encounter per route. Disabling static encounters simply filters them out of the available encounter pool for a location. The player still gets one encounter per area — just not the static one.
**Encounter method:** The existing encounter tables already include static encounters (e.g., Zapdos in Power Plant). The `static` encounter method may already exist in seed data — verify before adding. If not present, add it to seed data and `METHOD_CONFIG` / `METHOD_ORDER`.
**Frontend behavior:**
- When `staticClause` is **enabled** (default): static encounters appear normally in the encounter selector
- When `staticClause` is **disabled**: static encounters are hidden or visually grayed out in the encounter selector, preventing the player from selecting them
## Checklist
- [x] Verify `static` encounter method exists in seed data; add to `METHOD_CONFIG` / `METHOD_ORDER` if missing
- [x] Add `staticClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: true)
- [x] Add `RuleDefinition` entry under `core` category
- [x] Frontend: filter or gray out static encounters in encounter selector when `staticClause` is disabled
- [x] Backend seed data: add `staticClause` to `DEFAULT_RULES` in `inject_test_data.py`

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

@@ -1,26 +0,0 @@
---
# nuzlocke-tracker-o7r8
title: Remove unused nuzlocke rules
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:55:59Z
updated_at: 2026-02-20T20:04:33Z
parent: nuzlocke-tracker-49xj
---
Remove 5 rules that either define what a nuzlocke is (always true) or don't affect tracker behavior:
- `firstEncounterOnly` — implicit; it's a nuzlocke tracker
- `permadeath` — implicit; it's a nuzlocke tracker
- `nicknameRequired` — not enforced or tracked
- `setModeOnly` — not enforced or tracked
- `postGameCompletion` — not enforced or tracked
## Checklist
- [x] Remove from `NuzlockeRules` interface and `DEFAULT_RULES`
- [x] Remove their entries from `RULE_DEFINITIONS`
- [x] Update `RulesConfiguration`, `RuleToggle`, and `RuleBadges` components as needed
- [x] Update `NewRun.tsx` and `NewGenlocke.tsx` if they reference removed rules
- [x] Verify backend encounter logic still works (uses `.get()` with defaults)
- [x] Update backend test seed data if it references removed rules

View File

@@ -1,35 +0,0 @@
---
# nuzlocke-tracker-rrcf
title: Set up backend test infrastructure
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:32:57Z
updated_at: 2026-02-21T11:33:32Z
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.
## Approach
- Session-scoped async engine: creates all tables once via `Base.metadata.create_all()`, drops them after all tests finish
- Function-scoped `db_session` fixture: provides a fresh `AsyncSession`, overrides the `get_session` FastAPI dependency, and truncates all tables after each test for isolation
- Function-scoped `client` fixture: `httpx.AsyncClient` with `ASGITransport` — hits the real app stack including middleware and routing
- `asyncio_default_fixture_loop_scope = "session"` and `asyncio_default_test_loop_scope = "session"` added to pyproject.toml so all fixtures and tests share the same session event loop (required to avoid asyncpg "Future attached to different loop" errors)
- Test database URL read from `TEST_DATABASE_URL` env var (default: `postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test`)
- The test DB is provided by `docker-compose.test.yml` (postgres on port 5433, `nuzlocke_test` DB created automatically)
## Checklist
- [x] Add `asyncio_default_fixture_loop_scope = "session"` and `asyncio_default_test_loop_scope = "session"` to `pyproject.toml`
- [x] Create `backend/tests/conftest.py` with `engine`, `db_session`, and `client` fixtures
- [x] Write a smoke test in `backend/tests/test_smoke.py` to verify the setup
- [x] Verify all tests pass (`pytest` from backend dir)

View File

@@ -1,26 +0,0 @@
---
# nuzlocke-tracker-sij8
title: Add gift clause rule
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:10Z
updated_at: 2026-02-20T20:55:23Z
parent: nuzlocke-tracker-49xj
---
Add a new giftClause boolean rule: in-game gift Pokemon are free and do not count against the area's encounter limit. When enabled, a location with a gift allows both the gift encounter and a regular encounter, in any order.
## Checklist
- [x] Add giftClause to NuzlockeRules interface and DEFAULT_RULES (default: false)
- [x] Add RuleDefinition entry with core category
- [x] Add origin column to Encounter model + alembic migration
- [x] Add origin to EncounterResponse schema and frontend Encounter type
- [x] Persist origin when creating encounters (frontend sends, backend stores)
- [x] Backend: gift-origin encounters bypass route-lock check (skip_route_lock)
- [x] Backend: existing gift encounters excluded from route-lock query
- [x] Frontend: split encounterByRoute into regular and gift maps
- [x] Frontend: routes with only gift encounters remain clickable for new encounters
- [x] Frontend: gift encounters displayed on route cards with (gift) label
- [x] Frontend: route filtering accounts for gift encounters

View File

@@ -1,26 +0,0 @@
---
# nuzlocke-tracker-ugb7
title: Integration tests for Pokemon & Evolutions API
status: completed
type: task
priority: normal
created_at: 2026-02-10T09:33:16Z
updated_at: 2026-02-21T12:14:39Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the Pokemon and evolutions API endpoints.
## Checklist
- [x] Test Pokemon CRUD operations (create, list, search, update, delete)
- [x] Test Pokemon filtering and search
- [x] Test evolution chain CRUD (create, list, get, update, delete)
- [x] Test evolution family resolution endpoint
- [x] 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,21 +0,0 @@
---
# nuzlocke-tracker-wtbk
title: Fix CI workflow failures for backend and e2e tests
status: completed
type: bug
priority: normal
created_at: 2026-02-21T15:26:22Z
updated_at: 2026-02-21T15:29:08Z
---
Two failures in CI:
1. **backend-tests**: `astral-sh/setup-uv@v6.8.0` requires Node.js 20+ but the act runner has Node.js 18. The `File` global doesn't exist in Node 18, causing a ReferenceError. Fix: install uv directly via curl instead of using the GitHub Action.
2. **e2e-tests**: Port 8000 is already allocated on the runner host. The docker-compose.test.yml binds test-api to host port 8000 which conflicts with whatever else runs on the CI machine. Fix: use port 8100 for the test API container.
## Checklist
- [x] Replace `astral-sh/setup-uv` action with direct curl install of uv + `uv python install 3.14`
- [x] Change e2e test API host port from 8000 to 8100 in docker-compose.test.yml
- [x] Update global-setup.ts to use port 8100
- [x] Verify no other references to the test API port

View File

@@ -1,28 +0,0 @@
---
# nuzlocke-tracker-yzpb
title: Implement Unit & Integration Tests
status: completed
type: epic
priority: high
created_at: 2026-02-10T09:32:47Z
updated_at: 2026-02-21T13:00:44Z
---
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
- [x] Backend test infrastructure is set up (conftest, fixtures, test DB)
- [x] Backend schemas and services have unit test coverage
- [x] Backend API endpoints have integration test coverage
- [x] Frontend test infrastructure is set up (Vitest, RTL)
- [x] Frontend utilities and hooks have unit test coverage
- [x] Frontend components have basic render/interaction tests

View File

@@ -68,31 +68,4 @@ jobs:
working-directory: frontend working-directory: frontend
- name: Run tests - name: Run tests
run: npm test run: npm test
working-directory: frontend working-directory: frontend
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/

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

@@ -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

@@ -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")

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