Compare commits

..

17 Commits

Author SHA1 Message Date
61a7f57f1f Update beans and deployment docs
Update epic checklist, mark completed tasks, fix Gitea username/domain
references, and update DEPLOYMENT.md with correct registry paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:28:56 +01:00
03f07ebee5 Add deploy script and update prod compose
Deploy script builds and pushes images to Gitea registry, then triggers
Portainer stack redeployment via API. Includes preflight checks for
branch and uncommitted changes. Also renames prod DB volume to avoid
conflicts with dev and changes frontend port to 9080.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:28:17 +01:00
972137acfb Fix TypeScript errors in frontend build
Cast boss type select value to union type and remove unused
AdvanceLegInput import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:10:38 +01:00
fd23d89e71 Add production Dockerfiles and nginx config
Backend: installs non-editable, runs uvicorn without reload.
Frontend: multi-stage build, serves static files via nginx with
API proxy to the backend service and SPA fallback routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:00:28 +01:00
d9d547ef53 Add production docker-compose file
Uses pre-built images from the Gitea container registry, runs Alembic
migrations before API startup, and keeps the database password configurable
via environment variable. No source mounts or debug mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:58:55 +01:00
349a0cb821 Add DEPLOYMENT.md as living deployment documentation
Covers architecture overview, Gitea container registry setup, branching
strategy, and deployment workflow. Sections not yet implemented are marked
with TODO to be filled in as the deployment epic progresses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:56:06 +01:00
ad4ac6cf8c Update deployment strategy to use Gitea instead of plain Docker registry
Gitea provides source hosting, container registry, and CI/CD in one package.
Images are pushed as user-level packages to the Gitea registry over SSL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:45:43 +01:00
Julian Tabel
6a86c56e3b Add deployment strategy child task beans
Split the deployment strategy epic into 11 individual task beans
with blocking dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:32:04 +01:00
Julian Tabel
e6646fc3e0 Add deployment strategy epic bean
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:29:28 +01:00
Julian Tabel
f9f94e5e9c Add post-game completion rule option
Add postGameCompletion toggle to nuzlocke rules so players can indicate
whether a run ends after the Champion or continues into post-game. Adds
a new "Completion" category section in rules configuration with a green
badge color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:24:06 +01:00
Julian Tabel
6d955439eb Fix team sort: add to RunEncounters and fix hook ordering
Add sort dropdown to RunEncounters (the encounters page with the
expandable team section) and move all useMemo hooks before early
returns in both RunDashboard and RunEncounters to fix React hook
ordering violations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:21:07 +01:00
Julian Tabel
bc9bcf4c4b Add sort options to run team overview
Add a dropdown to sort Active Team and Graveyard by route order,
catch level, species name, or national dex number.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:11:54 +01:00
Julian Tabel
c67621295a Mark genlocke tracking epic and edge cases bean as completed
All 14 success criteria met, all 10 child tasks completed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:08:18 +01:00
Julian Tabel
924efa9073 Show error feedback when run deletion is blocked
Add optional error prop to DeleteConfirmModal and wire it into AdminRuns
so the backend's rejection message is displayed to the user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:07:09 +01:00
Julian Tabel
f0307f0625 Guard genlocke data integrity edge cases
Block deletion of runs linked to a genlocke leg, prevent reactivating
completed/failed genlocke-linked runs, and guard encounter deletion
against genlocke transfer references with clear 400 errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:03:58 +01:00
Julian Tabel
d3b65e3c79 Add genlocke lineage tracking with aligned timeline view
Implement read-only lineage view that traces Pokemon across genlocke legs
via existing transfer records. Backend walks transfer chains to build
lineage entries; frontend renders them as cards with a column-aligned
timeline grid so leg dots line up vertically across all lineages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:58:38 +01:00
Julian Tabel
4e00e3cad8 Fix HoF display for transfers/shinies and hook ordering
Move alive and hofTeam into useMemo hooks above early returns to fix
React hook ordering violation. Include transfer and shiny encounters
in alive so they appear in the team section and can be selected for
the Hall of Fame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:45:29 +01:00
42 changed files with 1456 additions and 77 deletions

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-25mh # nuzlocke-tracker-25mh
title: Genlocke tracking title: Genlocke tracking
status: todo status: completed
type: epic type: epic
priority: normal priority: normal
created_at: 2026-02-08T12:17:19Z created_at: 2026-02-08T12:17:19Z
updated_at: 2026-02-09T07:45:10Z updated_at: 2026-02-09T11:07:51Z
--- ---
Track a **genlocke** — a series of linked nuzlocke runs, typically one per generation or region. The player picks one game per generation/region and surviving Pokemon carry over between legs. Track a **genlocke** — a series of linked nuzlocke runs, typically one per generation or region. The player picks one game per generation/region and surviving Pokemon carry over between legs.
@@ -78,12 +78,12 @@ A dedicated page showing:
- [x] Nuzlocke rules are configured once and applied uniformly to all legs - [x] Nuzlocke rules are configured once and applied uniformly to all legs
- [x] Genlocke-specific rules (Keep HoF / Retire HoF) can be selected - [x] Genlocke-specific rules (Keep HoF / Retire HoF) can be selected
- [x] The first leg starts automatically upon genlocke creation - [x] The first leg starts automatically upon genlocke creation
- [ ] Each leg is a full nuzlocke run, tracked identically to standalone runs - [x] Each leg is a full nuzlocke run, tracked identically to standalone runs
- [ ] Completing a leg triggers a transfer step where surviving Pokemon can be carried forward - [x] Completing a leg triggers a transfer step where surviving Pokemon can be carried forward
- [x] Failing a leg marks the entire genlocke as failed - [x] Failing a leg marks the entire genlocke as failed
- [x] Completing the final leg marks the genlocke as completed - [x] Completing the final leg marks the genlocke as completed
- [ ] A genlocke overview page shows progress, configuration, cumulative stats, lineage, and graveyard - [x] A genlocke overview page shows progress, configuration, cumulative stats, lineage, and graveyard
- [ ] Transferred Pokemon appear as eggs (base form, level 1) in the next leg - [x] Transferred Pokemon appear as eggs (base form, level 1) in the next leg
- [ ] Pokemon lineage is trackable across multiple legs - [x] Pokemon lineage is trackable across multiple legs
- [ ] A cumulative graveyard shows all deaths across the entire genlocke - [x] A cumulative graveyard shows all deaths across the entire genlocke
- [ ] The Retire HoF / Gauntlet rule correctly retires HoF Pokemon and adds their families to the dupe list - [x] The Retire HoF / Gauntlet rule correctly retires HoF Pokemon and adds their families to the dupe list

View File

@@ -0,0 +1,15 @@
---
# nuzlocke-tracker-3c9l
title: Set up branching structure
status: todo
type: task
created_at: 2026-02-09T15:30:35Z
updated_at: 2026-02-09T15:30:35Z
parent: nuzlocke-tracker-ahza
---
Create the `develop` branch from `main` and establish the `main`/`develop`/`feature/*` branching workflow.
- Create `develop` branch from current `main`
- Push `develop` to remote
- Set `develop` as the default working branch

View File

@@ -0,0 +1,18 @@
---
# nuzlocke-tracker-3lfw
title: Configure Nginx Proxy Manager for nuzlocke-tracker
status: todo
type: task
priority: normal
created_at: 2026-02-09T15:30:50Z
updated_at: 2026-02-09T16:53:13Z
parent: nuzlocke-tracker-ahza
blocking:
- nuzlocke-tracker-vpn5
---
Add a proxy host entry in Nginx Proxy Manager on Unraid to route LAN traffic to the app.
- Add a proxy host (e.g., `nuzlocke.local`) pointing to the frontend/API containers
- Configure appropriate ports and forwarding rules
- Test access from other devices on the LAN

View File

@@ -0,0 +1,16 @@
---
# nuzlocke-tracker-48ds
title: Database backup strategy
status: todo
type: task
created_at: 2026-02-09T15:30:55Z
updated_at: 2026-02-09T15:30:55Z
parent: nuzlocke-tracker-ahza
---
Set up a simple scheduled backup for the production PostgreSQL database.
- Create a backup script using `pg_dump` that runs inside or against the PostgreSQL container
- Set up a cron job on Unraid to run the backup on a schedule (e.g., daily)
- Store backups in a designated location on Unraid with rotation (keep last N backups)
- Document the restore procedure

View File

@@ -0,0 +1,20 @@
---
# nuzlocke-tracker-765i
title: Update CLAUDE.md with branching rules
status: todo
type: task
priority: normal
created_at: 2026-02-09T15:30:38Z
updated_at: 2026-02-09T15:31:15Z
parent: nuzlocke-tracker-ahza
blocking:
- nuzlocke-tracker-3c9l
---
Once the branching structure is in place, add instructions to CLAUDE.md enforcing the branching strategy:
- Never commit directly to `main`
- Day-to-day work happens on `develop`
- New work is done on `feature/*` branches off `develop`
- Merge flow: `feature/*``develop``main`
- `main` is always production-ready

View File

@@ -0,0 +1,29 @@
---
# nuzlocke-tracker-94hx
title: Add sort options to run team overview
status: completed
type: feature
priority: normal
created_at: 2026-02-09T10:03:49Z
updated_at: 2026-02-09T11:09:33Z
---
The Active Team / Final Team section on the run dashboard (`RunDashboard.tsx`) currently displays Pokemon in whatever order encounters arrive from the backend — there is no explicit sorting. Add a sort dropdown so the user can choose how their team is ordered.
## Sort options
- **Route order** (default) — sort by the route's `order` field, matching game progression
- **Catch level** — sort by `catchLevel`, ascending
- **Species name** — sort alphabetically by the display Pokemon's name (accounting for evolutions via `currentPokemon`)
- **National Dex** — sort by the display Pokemon's `nationalDex` number
## Scope
- Frontend-only change — all data is already available in the `EncounterDetail` objects
- Add a small sort control (dropdown or segmented buttons) above the team grid
- Persist the selected sort in component state (no need for localStorage)
- Apply the same sort options to both the Active Team and Graveyard sections
## Checklist
- [x] Add sort state and sort logic to `RunDashboard.tsx`
- [x] Add sort dropdown/control above the team grid
- [x] Apply sorting to both `alive` and `dead` encounter arrays
- [x] Verify sort works correctly with evolved Pokemon (use `currentPokemon ?? pokemon` for name/dex)

View File

@@ -0,0 +1,60 @@
---
# nuzlocke-tracker-ahza
title: Deployment Strategy
status: in-progress
type: epic
priority: normal
created_at: 2026-02-09T14:03:53Z
updated_at: 2026-02-09T16:53:58Z
---
Define and implement a deployment strategy for running the nuzlocke-tracker in production on a local Unraid server while keeping laptop/PC as the development environment.
## Context
- **Components:** API (Python/FastAPI), Frontend (Vite/React), PostgreSQL database
- **Dev environment:** Laptop/PC — continue using the existing `docker-compose.yml` for local development
- **Production host:** Unraid server running Docker containers
- **Networking:** LAN-only access, Nginx Proxy Manager already in place on Unraid
- **Orchestration:** Docker Compose for production (matching dev workflow). Install Portainer for container management and semi-automated deployments.
## Decided Approach
**Docker Compose + Portainer + Gitea (source hosting, container registry, CI/CD)**
1. **Gitea** runs on Unraid behind Nginx Proxy Manager with SSL (e.g., `gitea.nerdboden.de`). It serves as the self-hosted Git remote, container registry, and (optionally) CI/CD via Gitea Actions.
2. **Images are built on the dev machine** and pushed to Gitea's container registry as **user-level packages** (e.g., `gitea.nerdboden.de/thefurya/nuzlocke-tracker-api:latest`, `gitea.nerdboden.de/thefurya/nuzlocke-tracker-frontend:latest`).
3. **Production runs docker-compose** on Unraid, pulling images from the Gitea container registry instead of mounting source.
4. **Portainer** is installed on Unraid to manage stacks, provide a web UI, and enable webhook-triggered redeployments.
5. **A deploy script** on the dev machine automates the full flow: build images → push to Gitea registry → trigger Portainer webhook to redeploy.
6. **Nginx Proxy Manager** handles routing on the LAN (e.g., `nuzlocke.nerdboden.de` → frontend container, `gitea.nerdboden.de` → Gitea).
7. **Database** uses a named Docker volume for persistence; migrations run automatically on API container startup.
## Branching Strategy
**`main` + `develop` + feature branches**
- **`main`** — always production-ready. Only receives merges from `develop` when ready to deploy. The deploy script builds from `main`.
- **`develop`** — integration branch for day-to-day work. Features are merged here and tested before promoting to `main`.
- **`feature/*`** — short-lived branches off `develop` for individual features/fixes. Merged back into `develop` via PR or direct merge when complete.
**Workflow:**
1. Create `feature/xyz` from `develop`
2. Work on the feature, commit, merge into `develop`
3. When ready to deploy: merge `develop``main`
4. Run `./deploy.sh` (builds from `main`, pushes to Gitea registry, triggers Portainer webhook)
## Checklist
- [ ] **Set up branching structure** — create `develop` branch from `main`, establish the `main`/`develop`/`feature/*` workflow
- [ ] **Update CLAUDE.md with branching rules** — once the branching structure is in place, add instructions to CLAUDE.md that the branching strategy must be adhered to (always work on feature branches, never commit directly to `main`, merge flow is `feature/*``develop``main`)
- [ ] **Configure Gitea container registry** — create an access token with `read:package` and `write:package` scopes, verify `docker login gitea.nerdboden.de` works, test pushing and pulling an image as a user-level package
- [x] **Create production docker-compose file** (`docker-compose.prod.yml`) — uses images from the Gitea container registry, production env vars, no source volume mounts, proper restart policies
- [x] **Create production Dockerfiles (or multi-stage builds)** — ensure frontend is built and served statically (e.g., via the API or a lightweight nginx container), API runs without debug mode
- [x] **Set up Portainer on Unraid** — install Portainer CE as a Docker container, configure the stack from the production compose file
- [x] **Configure Portainer API for automated redeployment** — deploy script uses Portainer CE REST API to pull latest images and restart the stack
- [x] **Create deploy script**`./deploy.sh` builds images, pushes to Gitea registry, triggers Portainer API redeployment
- [ ] **Configure Nginx Proxy Manager** — add proxy host entries for Gitea and the nuzlocke-tracker frontend/API on the appropriate ports
- [ ] **Environment & secrets management** — create a `.env.prod` template, document required variables, decide on secret handling (`.env` file on Unraid, Portainer env vars, etc.)
- [ ] **Database backup strategy** — set up a simple scheduled backup for the PostgreSQL volume/data (e.g., cron + `pg_dump` script on Unraid)
- [ ] **Document the deployment workflow** — README or docs covering how to deploy, redeploy, rollback, and manage the production instance

View File

@@ -0,0 +1,24 @@
---
# nuzlocke-tracker-aiw6
title: Create deploy script
status: completed
type: task
priority: normal
created_at: 2026-02-09T15:30:48Z
updated_at: 2026-02-09T17:28:22Z
parent: nuzlocke-tracker-ahza
blocking:
- nuzlocke-tracker-izf6
- nuzlocke-tracker-vpn5
- nuzlocke-tracker-xmyh
- nuzlocke-tracker-jzqz
---
Create a `./deploy.sh` script for the dev machine that automates the full deployment flow.
- Ensure the script runs from `main` branch (or warns if not)
- Build Docker images for API and frontend
- Tag images for the local registry (e.g., `unraid:5000/nuzlocke-api:latest`)
- Push images to the local registry
- Trigger the Portainer webhook to redeploy the stack
- Print status/confirmation of each step

View File

@@ -0,0 +1,17 @@
---
# nuzlocke-tracker-hwyk
title: Set up Portainer on Unraid
status: completed
type: task
priority: normal
created_at: 2026-02-09T15:30:44Z
updated_at: 2026-02-09T16:53:41Z
parent: nuzlocke-tracker-ahza
---
Install Portainer CE on the Unraid server for container/stack management.
- Run Portainer CE as a Docker container on Unraid
- Configure it to manage the local Docker environment
- Import/create a stack from the production docker-compose file
- Verify the stack can be deployed and managed through the Portainer UI

View File

@@ -0,0 +1,20 @@
---
# nuzlocke-tracker-izf6
title: Configure Gitea container registry
status: completed
type: task
priority: normal
created_at: 2026-02-09T15:30:40Z
updated_at: 2026-02-09T16:53:09Z
parent: nuzlocke-tracker-ahza
---
Set up and verify the Gitea container registry for hosting Docker images as user-level packages.
## Checklist
- [ ] Create a Gitea access token with `read:package` and `write:package` scopes
- [ ] Verify `docker login gitea.nerdboden.de` works from the dev machine
- [ ] Test pushing a Docker image as a user-level package (e.g., `gitea.nerdboden.de/thefurya/nuzlocke-tracker-api:latest`)
- [ ] Verify the image appears under the user's Packages tab in Gitea
- [ ] Test pulling the image back (from Unraid or dev machine)

View File

@@ -0,0 +1,28 @@
---
# nuzlocke-tracker-jzqz
title: Configure Portainer API for automated redeployment
status: completed
type: task
priority: normal
created_at: 2026-02-09T15:30:45Z
updated_at: 2026-02-09T17:28:22Z
parent: nuzlocke-tracker-ahza
blocking:
- nuzlocke-tracker-hwyk
---
Use the Portainer CE REST API to trigger stack redeployments from the deploy script.
Portainer webhooks are a Business-only feature, so we use the API directly instead.
## Approach
1. Authenticate with the Portainer API to get a JWT token
2. Call the stack update endpoint with `pullImage: true` to pull latest images and recreate containers
## Checklist
- [ ] Identify the stack ID in Portainer (via API or UI)
- [ ] Test API authentication (`POST /api/auth`)
- [ ] Test triggering a stack redeploy via API
- [ ] Integrate into the deploy script

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-lsc2 # nuzlocke-tracker-lsc2
title: Genlocke lineage tracking title: Genlocke lineage tracking
status: todo status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-02-09T07:42:41Z created_at: 2026-02-09T07:42:41Z
updated_at: 2026-02-09T07:46:15Z updated_at: 2026-02-09T10:58:46Z
parent: nuzlocke-tracker-25mh parent: nuzlocke-tracker-25mh
--- ---

View File

@@ -1,17 +1,33 @@
--- ---
# nuzlocke-tracker-pm9f # nuzlocke-tracker-pm9f
title: Genlocke edge cases title: Genlocke edge cases
status: draft status: completed
type: task type: task
priority: normal
created_at: 2026-02-09T08:48:46Z created_at: 2026-02-09T08:48:46Z
updated_at: 2026-02-09T08:48:46Z updated_at: 2026-02-09T11:04:01Z
parent: nuzlocke-tracker-25mh parent: nuzlocke-tracker-25mh
--- ---
Collect and evaluate edge cases for genlocke tracking. Review periodically to decide if any need dedicated handling. Guard genlocke-related data integrity edge cases. Audited existing endpoints for gaps in validation when runs, encounters, or legs interact with genlocke tracking.
## Edge Cases ## Checklist
- [ ] Prevent run deletion if the run is linked to a genlocke leg. The `DELETE /runs/{id}` endpoint should check for a `GenlockeLeg` with matching `run_id` and return 400 if found, telling the user to remove the run from the genlocke first. ### Critical
- [ ] What happens if a user tries to advance a leg twice? (Currently guarded by "next leg already has a run" check)
- [ ] What if the user edits a completed run back to active after the genlocke has already been marked completed/failed? - [x] **Block deletion of genlocke-linked runs.** `DELETE /runs/{id}` now returns 400 if the run belongs to a genlocke leg. (`runs.py`)
- [x] **Block reactivation of completed/failed genlocke-linked runs.** `PATCH /runs/{id}` now returns 400 when trying to set status back to `active` on a genlocke-linked run. (`runs.py`)
### High
- [x] **Guard encounter deletion against transfer references.** `DELETE /encounters/{id}` now checks for GenlockeTransfer references and returns 400 instead of a raw FK constraint violation. (`encounters.py`)
### Already handled (verified during audit)
- [x] Advance leg twice — guarded by "next leg already has a run" check in `advance_leg`
- [x] Transfer eggs blocking starter route — transfer-target encounters are excluded from route-lock checks (`encounters.py:90-96`)
- [x] Shiny flag preservation during transfers — `is_shiny` is copied to egg encounter (`genlockes.py`)
- [x] Genlocke deletion cascading properly — CASCADE on FK for both `GenlockeLeg` and `GenlockeTransfer`, runs properly unlinked
- [x] Duplicate source transfers — prevented in practice by the "next leg already has a run" guard; `target_encounter_id` has a UNIQUE constraint
- [x] Empty transfer list — valid behavior, advances leg without transfers

View File

@@ -0,0 +1,23 @@
---
# nuzlocke-tracker-re0m
title: Document the deployment workflow
status: in-progress
type: task
priority: normal
created_at: 2026-02-09T15:30:57Z
updated_at: 2026-02-09T16:55:02Z
parent: nuzlocke-tracker-ahza
blocking:
- nuzlocke-tracker-aiw6
- nuzlocke-tracker-3lfw
- nuzlocke-tracker-up0b
- nuzlocke-tracker-48ds
---
Write documentation covering the full deployment setup and workflows.
- How to deploy (run `./deploy.sh`)
- How to redeploy after changes
- How to rollback to a previous version
- How to manage the production instance (Portainer UI, logs, etc.)
- How to set up the production environment from scratch (registry, Portainer, NPM, secrets)

View File

@@ -1,10 +1,11 @@
--- ---
# nuzlocke-tracker-tyvc # nuzlocke-tracker-tyvc
title: Add end-of-run condition rule title: Add end-of-run condition rule
status: todo status: completed
type: feature type: feature
priority: normal
created_at: 2026-02-09T08:35:39Z created_at: 2026-02-09T08:35:39Z
updated_at: 2026-02-09T08:35:39Z updated_at: 2026-02-09T11:23:11Z
--- ---
Add a new nuzlocke rule: **End of Run Condition**. This is a toggle/switch that lets the player decide when a run is considered complete: Add a new nuzlocke rule: **End of Run Condition**. This is a toggle/switch that lets the player decide when a run is considered complete:
@@ -15,7 +16,7 @@ Add a new nuzlocke rule: **End of Run Condition**. This is a toggle/switch that
This affects when the player is expected to mark a run as "completed" and could influence level caps and boss tracking in the future. This affects when the player is expected to mark a run as "completed" and could influence level caps and boss tracking in the future.
## Checklist ## Checklist
- [ ] Add `postGameCompletion` (boolean, default false) to the `NuzlockeRules` TypeScript interface and `DEFAULT_RULES` - [x] Add `postGameCompletion` (boolean, default false) to the `NuzlockeRules` TypeScript interface and `DEFAULT_RULES`
- [ ] Add a rule definition to `RULE_DEFINITIONS` in `frontend/src/types/rules.ts` - [x] Add a rule definition to `RULE_DEFINITIONS` in `frontend/src/types/rules.ts`
- [ ] Verify the rule appears in the `RulesConfiguration` component (should work automatically via RULE_DEFINITIONS) - [x] Verify the rule appears in the `RulesConfiguration` component (added new "Completion" section)
- [ ] Verify the rule is stored correctly via the existing run/genlocke creation flows (JSONB, no backend schema change needed) - [x] Verify the rule is stored correctly via the existing run/genlocke creation flows (JSONB, no backend schema change needed)

View File

@@ -0,0 +1,16 @@
---
# nuzlocke-tracker-up0b
title: Environment and secrets management
status: todo
type: task
created_at: 2026-02-09T15:30:52Z
updated_at: 2026-02-09T15:30:52Z
parent: nuzlocke-tracker-ahza
---
Set up production environment variable and secrets management.
- Create a `.env.prod.example` template documenting all required variables
- Decide on secret handling approach (`.env` file on Unraid, Portainer environment variables, etc.)
- Ensure secrets are not committed to the repository (update `.gitignore` if needed)
- Document how to set up secrets on a fresh Unraid deployment

View File

@@ -0,0 +1,19 @@
---
# nuzlocke-tracker-vpn5
title: Create production docker-compose file
status: completed
type: task
priority: normal
created_at: 2026-02-09T15:30:41Z
updated_at: 2026-02-09T16:59:00Z
parent: nuzlocke-tracker-ahza
---
Create `docker-compose.prod.yml` for the Unraid production deployment.
- Reference pre-built images from the local Docker registry (not source volume mounts)
- Set production environment variables (DEBUG=false, proper DATABASE_URL, etc.)
- Proper restart policies (`unless-stopped` or `always`)
- No source code volume mounts — only data volumes (e.g., postgres_data)
- PostgreSQL with persistent named volume
- Appropriate port mappings for the production environment

View File

@@ -0,0 +1,17 @@
---
# nuzlocke-tracker-xmyh
title: Create production Dockerfiles
status: completed
type: task
priority: normal
created_at: 2026-02-09T15:30:42Z
updated_at: 2026-02-09T17:00:32Z
parent: nuzlocke-tracker-ahza
---
Create or adapt Dockerfiles for production builds (multi-stage where appropriate).
- **Frontend:** Build the Vite app and serve statically (e.g., via a lightweight nginx container or bundled into the API)
- **API:** Run without debug mode, ensure Alembic migrations run on startup
- Consider multi-stage builds to keep image sizes small
- Ensure the images work when pulled from the local registry

152
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,152 @@
# Deployment
This document describes the deployment architecture and workflows for the nuzlocke-tracker. It is a living document — sections marked with **TODO** are planned but not yet implemented.
## Architecture Overview
| Component | Dev (Laptop/PC) | Production (Unraid) |
|---|---|---|
| API | `docker-compose.yml` (hot reload) | `docker-compose.prod.yml` (built image) |
| Frontend | `docker-compose.yml` (Vite dev server) | `docker-compose.prod.yml` (built image) |
| Database | PostgreSQL 16 (Docker volume) | PostgreSQL 16 (Docker volume) |
| Container Registry | — | Gitea (user-level packages) |
| Container Management | — | Portainer CE |
| Reverse Proxy | — | Nginx Proxy Manager |
### Services
- **Gitea** — self-hosted Git server, container registry, and (future) CI/CD. Accessible at `gitea.nerdboden.de` via SSL.
- **Portainer** — Docker management UI. Accessible at `portainer.nerdboden.de` via SSL. Manages the production stack and provides webhook-triggered redeployments.
- **Nginx Proxy Manager** — reverse proxy with SSL termination for all services on the Unraid server.
## Container Registry
Docker images are hosted on Gitea's built-in container registry as **user-level packages**.
### Image naming
Images use the format `gitea.nerdboden.de/<user>/<image>:<tag>`:
```
gitea.nerdboden.de/thefurya/nuzlocke-tracker-api:latest
gitea.nerdboden.de/thefurya/nuzlocke-tracker-frontend:latest
```
### Authentication
1. Create a Gitea access token at **Settings > Applications** with `read:package` and `write:package` scopes.
2. Log in from the dev machine:
```bash
docker login gitea.nerdboden.de
```
Use your Gitea username and the access token as password.
### Pushing images
```bash
# Build and tag
docker build -t gitea.nerdboden.de/thefurya/nuzlocke-tracker-api:latest ./backend
docker build -t gitea.nerdboden.de/thefurya/nuzlocke-tracker-frontend:latest ./frontend
# Push
docker push gitea.nerdboden.de/thefurya/nuzlocke-tracker-api:latest
docker push gitea.nerdboden.de/thefurya/nuzlocke-tracker-frontend:latest
```
Pushed images are visible under the **Packages** tab on your Gitea user profile.
## Branching Strategy
The project uses a `main` / `develop` / `feature/*` branching model.
| Branch | Purpose |
|---|---|
| `main` | Always production-ready. Deploy script builds from here. |
| `develop` | Integration branch for day-to-day work. |
| `feature/*` | Short-lived branches off `develop` for individual features/fixes. |
### Workflow
1. Create `feature/xyz` from `develop`
2. Work on the feature, commit, merge into `develop`
3. When ready to deploy: merge `develop` into `main`
4. Run the deploy script (see below)
## Deploying
> **TODO** — deploy script (`./deploy.sh`) not yet created.
The deploy script will automate:
1. Build Docker images from `main`
2. Tag and push to the Gitea container registry
3. Trigger the Portainer webhook to pull new images and restart the stack
### Manual deployment
Until the deploy script is in place, deploy manually:
```bash
# 1. Ensure you're on main with latest changes
git checkout main
# 2. Build and push images
docker build -t gitea.nerdboden.de/thefurya/nuzlocke-tracker-api:latest ./backend
docker build -t gitea.nerdboden.de/thefurya/nuzlocke-tracker-frontend:latest ./frontend
docker push gitea.nerdboden.de/thefurya/nuzlocke-tracker-api:latest
docker push gitea.nerdboden.de/thefurya/nuzlocke-tracker-frontend:latest
# 3. On Unraid (or via Portainer): pull and restart
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
## Production Compose
> **TODO** — `docker-compose.prod.yml` not yet created.
The production compose file will differ from the dev compose in:
- Uses pre-built images from the Gitea registry (no source volume mounts)
- No hot reload / debug mode
- Production environment variables
- Proper restart policies
- Frontend served as a static build (not Vite dev server)
## Portainer
Portainer CE is running on Unraid at `portainer.nerdboden.de`.
- Manages the production Docker stack
- **TODO**: Configure a webhook for automated redeployment (pull latest images + restart on trigger)
## Nginx Proxy Manager
NPM runs on Unraid and handles SSL termination and routing for:
- `gitea.nerdboden.de` → Gitea
- `portainer.nerdboden.de` → Portainer
- **TODO**: `nuzlocke.nerdboden.de` (or similar) → nuzlocke-tracker frontend/API
## Environment & Secrets
> **TODO** — `.env.prod` template not yet created.
Production environment variables to configure:
- `DATABASE_URL` — PostgreSQL connection string
- `DEBUG` — must be `false` in production
- Additional secrets TBD
## Database
PostgreSQL 16 with data stored in a named Docker volume.
- Migrations run automatically on API container startup (Alembic)
- **TODO**: Set up scheduled backups (`pg_dump` cron job on Unraid)
## Rollback
> **TODO** — rollback procedure to be documented once image tagging strategy is finalized.
General approach: tag images with version/commit hash in addition to `latest`, so rolling back means redeploying a previous tag.

19
backend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,19 @@
# Production Dockerfile for the backend API
FROM python:3.14-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY pyproject.toml README.md alembic.ini ./
COPY src/ ./src/
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "src"]

View File

@@ -159,6 +159,19 @@ async def delete_encounter(
if encounter is None: if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found") raise HTTPException(status_code=404, detail="Encounter not found")
# Block deletion if encounter is referenced by a genlocke transfer
transfer_result = await session.execute(
select(GenlockeTransfer.id).where(
(GenlockeTransfer.source_encounter_id == encounter_id)
| (GenlockeTransfer.target_encounter_id == encounter_id)
)
)
if transfer_result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=400,
detail="Cannot delete an encounter that is part of a genlocke transfer.",
)
await session.delete(encounter) await session.delete(encounter)
await session.commit() await session.commit()
return Response(status_code=204) return Response(status_code=204)

View File

@@ -20,12 +20,15 @@ from app.schemas.genlocke import (
GenlockeDetailResponse, GenlockeDetailResponse,
GenlockeGraveyardResponse, GenlockeGraveyardResponse,
GenlockeLegDetailResponse, GenlockeLegDetailResponse,
GenlockeLineageResponse,
GenlockeListItem, GenlockeListItem,
GenlockeResponse, GenlockeResponse,
GenlockeStatsResponse, GenlockeStatsResponse,
GenlockeUpdate, GenlockeUpdate,
GraveyardEntryResponse, GraveyardEntryResponse,
GraveyardLegSummary, GraveyardLegSummary,
LineageEntry,
LineageLegEntry,
RetiredPokemonResponse, RetiredPokemonResponse,
SurvivorResponse, SurvivorResponse,
) )
@@ -261,6 +264,171 @@ async def get_genlocke_graveyard(
) )
@router.get(
"/{genlocke_id}/lineages",
response_model=GenlockeLineageResponse,
)
async def get_genlocke_lineages(
genlocke_id: int, session: AsyncSession = Depends(get_session)
):
# Load genlocke with legs + game
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
)
)
genlocke = result.scalar_one_or_none()
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
# Query all transfers for this genlocke
transfer_result = await session.execute(
select(GenlockeTransfer).where(
GenlockeTransfer.genlocke_id == genlocke_id
)
)
transfers = transfer_result.scalars().all()
if not transfers:
return GenlockeLineageResponse(lineages=[], total_lineages=0)
# Build forward/backward maps
forward: dict[int, GenlockeTransfer] = {} # source_encounter_id -> transfer
backward: set[int] = set() # target_encounter_ids
for t in transfers:
forward[t.source_encounter_id] = t
backward.add(t.target_encounter_id)
# Find roots: sources that are NOT targets
roots = [t.source_encounter_id for t in transfers if t.source_encounter_id not in backward]
# Deduplicate while preserving order
seen_roots: set[int] = set()
unique_roots: list[int] = []
for r in roots:
if r not in seen_roots:
seen_roots.add(r)
unique_roots.append(r)
# Walk forward from each root to build chains
chains: list[list[int]] = []
for root in unique_roots:
chain = [root]
current = root
while current in forward:
target = forward[current].target_encounter_id
chain.append(target)
current = target
chains.append(chain)
# Batch-load all encounters in the chains
all_encounter_ids: set[int] = set()
for chain in chains:
all_encounter_ids.update(chain)
enc_result = await session.execute(
select(Encounter)
.where(Encounter.id.in_(all_encounter_ids))
.options(
selectinload(Encounter.pokemon),
selectinload(Encounter.current_pokemon),
selectinload(Encounter.route),
)
)
encounter_map: dict[int, Encounter] = {
enc.id: enc for enc in enc_result.scalars().all()
}
# Build run_id -> (leg_order, game_name) lookup
run_lookup: dict[int, tuple[int, str]] = {}
for leg in genlocke.legs:
if leg.run_id is not None:
run_lookup[leg.run_id] = (leg.leg_order, leg.game.name)
# Load HoF encounter IDs for all runs
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
hof_encounter_ids: set[int] = set()
if run_ids:
run_result = await session.execute(
select(NuzlockeRun.id, NuzlockeRun.hof_encounter_ids).where(
NuzlockeRun.id.in_(run_ids)
)
)
for row in run_result:
if row.hof_encounter_ids:
hof_encounter_ids.update(row.hof_encounter_ids)
# Build lineage entries
lineages: list[LineageEntry] = []
for chain in chains:
legs: list[LineageLegEntry] = []
first_pokemon = None
for enc_id in chain:
enc = encounter_map.get(enc_id)
if enc is None:
continue
leg_order, game_name = run_lookup.get(enc.run_id, (0, "Unknown"))
is_alive = enc.faint_level is None and enc.status == "caught"
entered_hof = enc.id in hof_encounter_ids
was_transferred = enc.id in forward
pokemon_resp = PokemonResponse.model_validate(enc.pokemon)
if first_pokemon is None:
first_pokemon = pokemon_resp
current_pokemon_resp = (
PokemonResponse.model_validate(enc.current_pokemon)
if enc.current_pokemon
else None
)
legs.append(
LineageLegEntry(
leg_order=leg_order,
game_name=game_name,
encounter_id=enc.id,
pokemon=pokemon_resp,
current_pokemon=current_pokemon_resp,
nickname=enc.nickname,
catch_level=enc.catch_level,
faint_level=enc.faint_level,
death_cause=enc.death_cause,
is_shiny=enc.is_shiny,
route_name=enc.route.name if enc.route else "Unknown",
is_alive=is_alive,
entered_hof=entered_hof,
was_transferred=was_transferred,
)
)
if not legs or first_pokemon is None:
continue
# Status based on last encounter in the chain
last_leg = legs[-1]
status = "alive" if last_leg.is_alive else "dead"
lineages.append(
LineageEntry(
nickname=legs[0].nickname,
pokemon=first_pokemon,
legs=legs,
status=status,
)
)
# Sort by first leg order, then by encounter ID
lineages.sort(key=lambda l: (l.legs[0].leg_order, l.legs[0].encounter_id))
return GenlockeLineageResponse(
lineages=lineages,
total_lineages=len(lineages),
)
@router.post("", response_model=GenlockeResponse, status_code=201) @router.post("", response_model=GenlockeResponse, status_code=201)
async def create_genlocke( async def create_genlocke(
data: GenlockeCreate, session: AsyncSession = Depends(get_session) data: GenlockeCreate, session: AsyncSession = Depends(get_session)

View File

@@ -173,6 +173,17 @@ async def update_run(
) )
update_data["completed_at"] = datetime.now(timezone.utc) update_data["completed_at"] = datetime.now(timezone.utc)
# Block reactivating a completed/failed run that belongs to a genlocke
if "status" in update_data and update_data["status"] == "active" and run.status != "active":
leg_result = await session.execute(
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)
)
if leg_result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=400,
detail="Cannot reactivate a genlocke-linked run. The genlocke controls leg progression.",
)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(run, field, value) setattr(run, field, value)
@@ -211,6 +222,16 @@ async def delete_run(
if run is None: if run is None:
raise HTTPException(status_code=404, detail="Run not found") raise HTTPException(status_code=404, detail="Run not found")
# Block deletion if run is linked to a genlocke leg
leg_result = await session.execute(
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)
)
if leg_result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=400,
detail="Cannot delete a run that belongs to a genlocke. Remove the leg or delete the genlocke first.",
)
# Delete associated boss results first # Delete associated boss results first
boss_results = await session.execute( boss_results = await session.execute(
select(BossResult).where(BossResult.run_id == run_id) select(BossResult).where(BossResult.run_id == run_id)

View File

@@ -132,3 +132,35 @@ class GenlockeGraveyardResponse(CamelModel):
total_deaths: int total_deaths: int
deaths_per_leg: list[GraveyardLegSummary] deaths_per_leg: list[GraveyardLegSummary]
deadliest_leg: GraveyardLegSummary | None = None deadliest_leg: GraveyardLegSummary | None = None
# --- Lineage schemas ---
class LineageLegEntry(CamelModel):
leg_order: int
game_name: str
encounter_id: int
pokemon: PokemonResponse
current_pokemon: PokemonResponse | None = None
nickname: str | None = None
catch_level: int | None = None
faint_level: int | None = None
death_cause: str | None = None
is_shiny: bool = False
route_name: str
is_alive: bool
entered_hof: bool
was_transferred: bool
class LineageEntry(CamelModel):
nickname: str | None
pokemon: PokemonResponse # base form from first leg
legs: list[LineageLegEntry]
status: str # "alive" | "dead"
class GenlockeLineageResponse(CamelModel):
lineages: list[LineageEntry]
total_lineages: int

81
deploy.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Configuration ──────────────────────────────────────────────
REGISTRY="gitea.nerdboden.de"
OWNER="thefurya"
IMAGES=("nuzlocke-tracker-api" "nuzlocke-tracker-frontend")
DOCKERFILES=("backend/Dockerfile.prod" "frontend/Dockerfile.prod")
CONTEXTS=("./backend" "./frontend")
PORTAINER_URL="${PORTAINER_URL:-https://portainer.nerdboden.de}"
PORTAINER_API_KEY="${PORTAINER_API_KEY:-}"
PORTAINER_STACK_ID="${PORTAINER_STACK_ID:-}"
PORTAINER_ENDPOINT_ID="${PORTAINER_ENDPOINT_ID:-1}"
# ── Helpers ────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
error() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
# ── Preflight checks ──────────────────────────────────────────
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" != "main" ]]; then
warn "You are on branch '$BRANCH', not 'main'."
read -rp "Continue anyway? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || exit 0
fi
if ! git diff --quiet || ! git diff --cached --quiet; then
warn "You have uncommitted changes."
read -rp "Continue anyway? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || exit 0
fi
# ── Build and push images ─────────────────────────────────────
for i in "${!IMAGES[@]}"; do
IMAGE="${REGISTRY}/${OWNER}/${IMAGES[$i]}:latest"
info "Building ${IMAGES[$i]}..."
docker build -t "$IMAGE" -f "${DOCKERFILES[$i]}" "${CONTEXTS[$i]}"
info "Pushing ${IMAGES[$i]}..."
docker push "$IMAGE"
done
info "All images built and pushed."
# ── Trigger Portainer redeployment ─────────────────────────────
if [[ -z "$PORTAINER_API_KEY" ]]; then
warn "PORTAINER_API_KEY not set — skipping Portainer redeployment."
warn "Set it in your environment or .env.deploy file to enable auto-redeploy."
exit 0
fi
if [[ -z "$PORTAINER_STACK_ID" ]]; then
warn "PORTAINER_STACK_ID not set — skipping Portainer redeployment."
warn "Find your stack ID in Portainer and set it in your environment."
exit 0
fi
info "Fetching stack file from Portainer..."
STACK_FILE=$(curl -sf \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}/file") \
|| error "Failed to fetch stack file from Portainer."
STACK_CONTENT=$(echo "$STACK_FILE" | jq -r '.StackFileContent')
info "Triggering stack redeployment..."
curl -sf -X PUT \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg content "$STACK_CONTENT" '{"pullImage": true, "stackFileContent": $content}')" \
"${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}?endpointId=${PORTAINER_ENDPOINT_ID}" \
> /dev/null \
|| error "Failed to trigger Portainer redeployment."
info "Stack redeployment triggered successfully!"

38
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,38 @@
services:
api:
image: gitea.nerdboden.de/thefurya/nuzlocke-tracker-api:latest
command: >
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --app-dir src"
environment:
- DEBUG=false
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/nuzlocke
depends_on:
db:
condition: service_healthy
restart: unless-stopped
frontend:
image: gitea.nerdboden.de/thefurya/nuzlocke-tracker-frontend:latest
ports:
- "9080:80"
depends_on:
- api
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=nuzlocke
volumes:
- prod_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
prod_postgres_data:

21
frontend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,21 @@
# Production Dockerfile for the frontend
# Stage 1: Build
FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy API requests to the backend service
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Serve static files, fall back to index.html for SPA routing
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,5 +1,5 @@
import { api } from './client' import { api } from './client'
import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, CreateGenlockeInput, Region, SurvivorEncounter, AdvanceLegInput } from '../types/game' import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, GenlockeLineage, CreateGenlockeInput, Region, SurvivorEncounter, AdvanceLegInput } from '../types/game'
export function getGenlockes(): Promise<GenlockeListItem[]> { export function getGenlockes(): Promise<GenlockeListItem[]> {
return api.get('/genlockes') return api.get('/genlockes')
@@ -21,6 +21,10 @@ export function getGenlockeGraveyard(id: number): Promise<GenlockeGraveyard> {
return api.get(`/genlockes/${id}/graveyard`) return api.get(`/genlockes/${id}/graveyard`)
} }
export function getGenlockeLineages(id: number): Promise<GenlockeLineage> {
return api.get(`/genlockes/${id}/lineages`)
}
export function getLegSurvivors(genlockeId: number, legOrder: number): Promise<SurvivorEncounter[]> { export function getLegSurvivors(genlockeId: number, legOrder: number): Promise<SurvivorEncounter[]> {
return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`) return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`)
} }

View File

@@ -0,0 +1,276 @@
import { useMemo } from 'react'
import { useGenlockeLineages } from '../hooks/useGenlockes'
import type { LineageEntry, LineageLegEntry } from '../types'
interface GenlockeLineageProps {
genlockeId: number
}
function LegDot({ leg }: { leg: LineageLegEntry }) {
let color: string
let label: string
if (leg.faintLevel !== null) {
color = 'bg-red-500'
label = 'Dead'
} else if (leg.wasTransferred) {
color = 'bg-blue-500'
label = 'Transferred'
} else if (leg.enteredHof) {
color = 'bg-yellow-500'
label = 'Hall of Fame'
} else {
color = 'bg-green-500'
label = 'Alive'
}
const displayPokemon = leg.currentPokemon ?? leg.pokemon
return (
<div className="group relative flex flex-col items-center">
<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`} />
{/* Tooltip */}
<div className="absolute bottom-full mb-2 hidden group-hover:flex flex-col items-center z-10">
<div className="bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg px-3 py-2 whitespace-nowrap shadow-lg space-y-1">
<div className="font-semibold">{leg.gameName}</div>
<div className="flex items-center gap-1.5">
{displayPokemon.spriteUrl && (
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-6 h-6" />
)}
<span>{displayPokemon.name}</span>
</div>
{leg.catchLevel !== null && (
<div>Caught Lv. {leg.catchLevel}</div>
)}
{leg.faintLevel !== null && (
<div className="text-red-300">Died Lv. {leg.faintLevel}</div>
)}
{leg.deathCause && (
<div className="text-red-300 italic">{leg.deathCause}</div>
)}
<div className={`font-medium ${
leg.faintLevel !== null ? 'text-red-300' :
leg.wasTransferred ? 'text-blue-300' :
leg.enteredHof ? 'text-yellow-300' :
'text-green-300'
}`}>
{label}
</div>
{leg.enteredHof && leg.faintLevel === null && (
<div className="text-yellow-300">Hall of Fame</div>
)}
</div>
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
</div>
</div>
)
}
function TimelineGrid({
lineage,
allLegOrders,
}: {
lineage: LineageEntry
allLegOrders: number[]
}) {
const legMap = new Map(lineage.legs.map((l) => [l.legOrder, l]))
const minLeg = lineage.legs[0].legOrder
const maxLeg = lineage.legs[lineage.legs.length - 1].legOrder
return (
<div
className="grid"
style={{
gridTemplateColumns: `repeat(${allLegOrders.length}, minmax(48px, 1fr))`,
}}
>
{allLegOrders.map((legOrder, i) => {
const leg = legMap.get(legOrder)
const inRange = legOrder >= minLeg && legOrder <= maxLeg
const showLeftLine = inRange && i > 0 && allLegOrders[i - 1] >= minLeg
const showRightLine =
inRange &&
i < allLegOrders.length - 1 &&
allLegOrders[i + 1] <= maxLeg
return (
<div
key={legOrder}
className="flex justify-center relative"
style={{ height: '20px' }}
>
{/* Left half connector */}
{showLeftLine && (
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" />
)}
{/* Right half connector */}
{showRightLine && (
<div className="absolute top-[9px] left-1/2 right-0 h-0.5 bg-gray-300 dark:bg-gray-600" />
)}
{/* Dot or empty */}
{leg ? (
<div className="relative z-10">
<LegDot leg={leg} />
</div>
) : (
<div className="h-4" />
)}
</div>
)
})}
</div>
)
}
function LineageCard({
lineage,
allLegOrders,
}: {
lineage: LineageEntry
allLegOrders: number[]
}) {
const firstLeg = lineage.legs[0]
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center gap-4">
{/* Left: Pokemon sprite + nickname */}
<div className="flex flex-col items-center min-w-[80px]">
{displayPokemon.spriteUrl ? (
<img
src={displayPokemon.spriteUrl}
alt={displayPokemon.name}
className="w-16 h-16"
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">
{displayPokemon.name[0].toUpperCase()}
</div>
)}
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center">
{lineage.nickname || lineage.pokemon.name}
</span>
{lineage.nickname && (
<span className="text-[10px] text-gray-500 dark:text-gray-400">
{lineage.pokemon.name}
</span>
)}
</div>
{/* Center: Timeline */}
<div className="flex-1 overflow-x-auto py-2">
<TimelineGrid lineage={lineage} allLegOrders={allLegOrders} />
</div>
{/* Right: Status badge */}
<div className="shrink-0">
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
lineage.status === 'alive'
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
}`}
>
{lineage.status === 'alive' ? 'Alive' : 'Dead'}
</span>
</div>
</div>
)
}
export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
const { data, isLoading, error } = useGenlockeLineages(genlockeId)
const allLegOrders = useMemo(() => {
if (!data) return []
return [...new Set(data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder)))].sort(
(a, b) => a - b
)
}, [data])
const legGameNames = useMemo(() => {
if (!data) return new Map<number, string>()
const map = new Map<number, string>()
for (const lineage of data.lineages) {
for (const leg of lineage.legs) {
map.set(leg.legOrder, leg.gameName)
}
}
return map
}, [data])
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
return (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
Failed to load lineage data.
</div>
)
}
if (!data || data.totalLineages === 0) {
return (
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-6 text-center text-gray-500 dark:text-gray-400">
No Pokemon have been transferred between legs yet.
</div>
)
}
return (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="font-semibold text-gray-900 dark:text-gray-100">
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
</span>
</div>
{/* Column header row */}
<div className="flex items-center gap-4 px-4">
{/* Spacer matching pokemon info column */}
<div className="min-w-[80px]" />
{/* Leg headers */}
<div
className="flex-1 grid"
style={{
gridTemplateColumns: `repeat(${allLegOrders.length}, minmax(48px, 1fr))`,
}}
>
{allLegOrders.map((legOrder) => (
<div key={legOrder} className="flex flex-col items-center">
<span className="text-[10px] font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">
Leg {legOrder}
</span>
<span className="text-[9px] text-gray-400 dark:text-gray-500 whitespace-nowrap truncate max-w-[48px]">
{legGameNames.get(legOrder)}
</span>
</div>
))}
</div>
{/* Spacer matching status badge */}
<div className="shrink-0 w-[52px]" />
</div>
{/* Lineage cards */}
<div className="space-y-3">
{data.lineages.map((lineage) => (
<LineageCard
key={lineage.legs[0].encounterId}
lineage={lineage}
allLegOrders={allLegOrders}
/>
))}
</div>
</div>
)
}

View File

@@ -25,7 +25,9 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
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-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300' : def.category === 'completion'
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
}`} }`}
> >
{def.name} {def.name}

View File

@@ -22,6 +22,9 @@ export function RulesConfiguration({
const difficultyRules = visibleRules.filter( const difficultyRules = visibleRules.filter(
(r) => r.category === 'difficulty' (r) => r.category === 'difficulty'
) )
const completionRules = visibleRules.filter(
(r) => r.category === 'completion'
)
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => { const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
onChange({ ...rules, [key]: value }) onChange({ ...rules, [key]: value })
@@ -98,6 +101,30 @@ export function RulesConfiguration({
))} ))}
</div> </div>
</div> </div>
{completionRules.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Completion
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
When is the run considered complete
</p>
</div>
<div className="px-4">
{completionRules.map((rule) => (
<RuleToggle
key={rule.key}
name={rule.name}
description={rule.description}
enabled={rules[rule.key]}
onChange={(value) => handleRuleChange(rule.key, value)}
/>
))}
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -107,7 +107,7 @@ export function BossBattleFormModal({
<label className="block text-sm font-medium mb-1">Type</label> <label className="block text-sm font-medium mb-1">Type</label>
<select <select
value={bossType} value={bossType}
onChange={(e) => setBossType(e.target.value)} 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 dark:bg-gray-700 dark:border-gray-600"
> >
{BOSS_TYPES.map((t) => ( {BOSS_TYPES.map((t) => (

View File

@@ -4,6 +4,7 @@ interface DeleteConfirmModalProps {
onConfirm: () => void onConfirm: () => void
onCancel: () => void onCancel: () => void
isDeleting?: boolean isDeleting?: boolean
error?: string | null
} }
export function DeleteConfirmModal({ export function DeleteConfirmModal({
@@ -12,6 +13,7 @@ export function DeleteConfirmModal({
onConfirm, onConfirm,
onCancel, onCancel,
isDeleting, isDeleting,
error,
}: DeleteConfirmModalProps) { }: DeleteConfirmModalProps) {
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">
@@ -24,6 +26,11 @@ export function DeleteConfirmModal({
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300"> <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{message} {message}
</p> </p>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</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-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button <button

View File

@@ -4,6 +4,7 @@ export { EncounterModal } from './EncounterModal'
export { EndRunModal } from './EndRunModal' export { EndRunModal } from './EndRunModal'
export { GameCard } from './GameCard' export { GameCard } from './GameCard'
export { GenlockeGraveyard } from './GenlockeGraveyard' export { GenlockeGraveyard } from './GenlockeGraveyard'
export { GenlockeLineage } from './GenlockeLineage'
export { HofTeamModal } from './HofTeamModal' export { HofTeamModal } from './HofTeamModal'
export { GameGrid } from './GameGrid' export { GameGrid } from './GameGrid'
export { Layout } from './Layout' export { Layout } from './Layout'

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getLegSurvivors } from '../api/genlockes' import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getGenlockeLineages, getLegSurvivors } from '../api/genlockes'
import type { AdvanceLegInput, CreateGenlockeInput } from '../types/game' import type { CreateGenlockeInput } from '../types/game'
export function useGenlockes() { export function useGenlockes() {
return useQuery({ return useQuery({
@@ -23,6 +23,13 @@ export function useGenlockeGraveyard(id: number) {
}) })
} }
export function useGenlockeLineages(id: number) {
return useQuery({
queryKey: ['genlockes', id, 'lineages'],
queryFn: () => getGenlockeLineages(id),
})
}
export function useRegions() { export function useRegions() {
return useQuery({ return useQuery({
queryKey: ['games', 'by-region'], queryKey: ['games', 'by-region'],

View File

@@ -1,7 +1,7 @@
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useGenlocke } from '../hooks/useGenlockes' import { useGenlocke } from '../hooks/useGenlockes'
import { usePokemonFamilies } from '../hooks/usePokemon' import { usePokemonFamilies } from '../hooks/usePokemon'
import { GenlockeGraveyard, StatCard, RuleBadges } from '../components' import { GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types' import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@@ -87,6 +87,7 @@ export function GenlockeDetail() {
const { data: familiesData } = usePokemonFamilies() const { data: familiesData } = usePokemonFamilies()
const [showGraveyard, setShowGraveyard] = useState(false) const [showGraveyard, setShowGraveyard] = useState(false)
const [showLineage, setShowLineage] = useState(false)
const activeLeg = useMemo(() => { const activeLeg = useMemo(() => {
if (!genlocke) return null if (!genlocke) return null
@@ -297,9 +298,12 @@ export function GenlockeDetail() {
Graveyard Graveyard
</button> </button>
<button <button
disabled onClick={() => setShowLineage((v) => !v)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-lg font-medium cursor-not-allowed" className={`px-4 py-2 rounded-lg font-medium transition-colors ${
title="Coming soon" showLineage
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
> >
Lineage Lineage
</button> </button>
@@ -315,6 +319,16 @@ export function GenlockeDetail() {
<GenlockeGraveyard genlockeId={id} /> <GenlockeGraveyard genlockeId={id} />
</section> </section>
)} )}
{/* Lineage */}
{showLineage && (
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Pokemon Lineages
</h2>
<GenlockeLineage genlockeId={id} />
</section>
)}
</div> </div>
) )
} }

View File

@@ -1,4 +1,4 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns' import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames' import { useGameRoutes } from '../hooks/useGames'
@@ -6,6 +6,28 @@ import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components' import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
import type { RunStatus, EncounterDetail } from '../types' import type { RunStatus, EncounterDetail } from '../types'
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
return [...encounters].sort((a, b) => {
switch (key) {
case 'route':
return a.route.order - b.route.order
case 'level':
return (a.catchLevel ?? 0) - (b.catchLevel ?? 0)
case 'species': {
const nameA = (a.currentPokemon ?? a.pokemon).name
const nameB = (b.currentPokemon ?? b.pokemon).name
return nameA.localeCompare(nameB)
}
case 'dex':
return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
default:
return 0
}
})
}
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: completed:
@@ -32,6 +54,17 @@ export function RunDashboard() {
const [selectedEncounter, setSelectedEncounter] = const [selectedEncounter, setSelectedEncounter] =
useState<EncounterDetail | null>(null) useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false) const [showEndRun, setShowEndRun] = useState(false)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const encounters = run?.encounters ?? []
const alive = useMemo(
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel === null), teamSort),
[encounters, teamSort],
)
const dead = useMemo(
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null), teamSort),
[encounters, teamSort],
)
if (isLoading) { if (isLoading) {
return ( return (
@@ -58,12 +91,6 @@ export function RunDashboard() {
} }
const isActive = run.status === 'active' const isActive = run.status === 'active'
const alive = run.encounters.filter(
(e) => e.status === 'caught' && e.faintLevel === null,
)
const dead = run.encounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null,
)
const visitedRoutes = new Set(run.encounters.map((e) => e.routeId)).size const visitedRoutes = new Set(run.encounters.map((e) => e.routeId)).size
const totalRoutes = routes?.length const totalRoutes = routes?.length
@@ -172,9 +199,23 @@ export function RunDashboard() {
{/* Active Team */} {/* Active Team */}
<div className="mb-6"> <div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3"> <div className="flex items-center justify-between mb-3">
{isActive ? 'Active Team' : 'Final Team'} <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
</h2> {isActive ? 'Active Team' : 'Final Team'}
</h2>
{alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
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"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{alive.length === 0 ? ( {alive.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm"> <p className="text-gray-500 dark:text-gray-400 text-sm">
No pokemon caught yet head to encounters to start building your No pokemon caught yet head to encounters to start building your

View File

@@ -33,6 +33,28 @@ import type {
BossPokemon, BossPokemon,
} from '../types' } from '../types'
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
return [...encounters].sort((a, b) => {
switch (key) {
case 'route':
return a.route.order - b.route.order
case 'level':
return (a.catchLevel ?? 0) - (b.catchLevel ?? 0)
case 'species': {
const nameA = (a.currentPokemon ?? a.pokemon).name
const nameB = (b.currentPokemon ?? b.pokemon).name
return nameA.localeCompare(nameB)
}
case 'dex':
return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
default:
return 0
}
})
}
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: completed:
@@ -421,6 +443,7 @@ export function RunEncounters() {
const [showEggModal, setShowEggModal] = useState(false) const [showEggModal, setShowEggModal] = useState(false)
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set()) const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
const [showTeam, setShowTeam] = useState(true) const [showTeam, setShowTeam] = useState(true)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const [filter, setFilter] = useState<'all' | RouteStatus>('all') const [filter, setFilter] = useState<'all' | RouteStatus>('all')
const storageKey = `expandedGroups-${runId}` const storageKey = `expandedGroups-${runId}`
@@ -620,12 +643,30 @@ export function RunEncounters() {
} }
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps }, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
const alive = useMemo(
() => sortEncounters(
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
(e) => e.status === 'caught' && e.faintLevel === null,
),
teamSort,
),
[normalEncounters, transferEncounters, shinyEncounters, teamSort],
)
const dead = useMemo(
() => sortEncounters(
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
teamSort,
),
[normalEncounters, teamSort],
)
// Resolve HoF team encounters from IDs // Resolve HoF team encounters from IDs
const hofTeam = useMemo(() => { const hofTeam = useMemo(() => {
if (!run?.hofEncounterIds || run.hofEncounterIds.length === 0) return null if (!run?.hofEncounterIds || run.hofEncounterIds.length === 0) return null
const idSet = new Set(run.hofEncounterIds) const idSet = new Set(run.hofEncounterIds)
return normalEncounters.filter((e) => idSet.has(e.id)) return alive.filter((e) => idSet.has(e.id))
}, [run?.hofEncounterIds, normalEncounters]) }, [run?.hofEncounterIds, alive])
if (isLoading || loadingRoutes) { if (isLoading || loadingRoutes) {
return ( return (
@@ -679,12 +720,6 @@ export function RunEncounters() {
} }
const isActive = run.status === 'active' const isActive = run.status === 'active'
const alive = normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel === null,
)
const dead = normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null,
)
const toggleGroup = (groupId: number) => { const toggleGroup = (groupId: number) => {
updateExpandedGroups((prev) => { updateExpandedGroups((prev) => {
@@ -1032,31 +1067,45 @@ export function RunEncounters() {
{/* Team Section */} {/* Team Section */}
{(alive.length > 0 || dead.length > 0) && ( {(alive.length > 0 || dead.length > 0) && (
<div className="mb-6"> <div className="mb-6">
<button <div className="flex items-center justify-between mb-3">
type="button" <button
onClick={() => setShowTeam(!showTeam)} type="button"
className="flex items-center gap-2 mb-3 group" onClick={() => setShowTeam(!showTeam)}
> className="flex items-center gap-2 group"
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-gray-400 dark:text-gray-500">
{alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''}
</span>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
strokeLinecap="round" {isActive ? 'Team' : 'Final Team'}
strokeLinejoin="round" </h2>
strokeWidth={2} <span className="text-xs text-gray-400 dark:text-gray-500">
d="M19 9l-7 7-7-7" {alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''}
/> </span>
</svg> <svg
</button> className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showTeam && alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
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"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{showTeam && ( {showTeam && (
<> <>
{alive.length > 0 && ( {alive.length > 0 && (

View File

@@ -120,8 +120,9 @@ export function AdminRuns() {
onSuccess: () => setDeleting(null), onSuccess: () => setDeleting(null),
}) })
} }
onCancel={() => setDeleting(null)} onCancel={() => { setDeleting(null); deleteRun.reset() }}
isDeleting={deleteRun.isPending} isDeleting={deleteRun.isPending}
error={deleteRun.error?.message ?? null}
/> />
)} )}
</div> </div>

View File

@@ -302,6 +302,37 @@ export interface AdvanceLegInput {
transferEncounterIds: number[] transferEncounterIds: number[]
} }
// Lineage types
export interface LineageLegEntry {
legOrder: number
gameName: string
encounterId: number
pokemon: Pokemon
currentPokemon: Pokemon | null
nickname: string | null
catchLevel: number | null
faintLevel: number | null
deathCause: string | null
isShiny: boolean
routeName: string
isAlive: boolean
enteredHof: boolean
wasTransferred: boolean
}
export interface LineageEntry {
nickname: string | null
pokemon: Pokemon
legs: LineageLegEntry[]
status: 'alive' | 'dead'
}
export interface GenlockeLineage {
lineages: LineageEntry[]
totalLineages: number
}
// Graveyard types // Graveyard types
export interface GraveyardEntry { export interface GraveyardEntry {

View File

@@ -11,6 +11,9 @@ export interface NuzlockeRules {
hardcoreMode: boolean hardcoreMode: boolean
levelCaps: boolean levelCaps: boolean
setModeOnly: boolean setModeOnly: boolean
// Completion
postGameCompletion: boolean
} }
export const DEFAULT_RULES: NuzlockeRules = { export const DEFAULT_RULES: NuzlockeRules = {
@@ -26,13 +29,16 @@ export const DEFAULT_RULES: NuzlockeRules = {
hardcoreMode: false, hardcoreMode: false,
levelCaps: false, levelCaps: false,
setModeOnly: false, setModeOnly: false,
// Completion
postGameCompletion: false,
} }
export interface RuleDefinition { export interface RuleDefinition {
key: keyof NuzlockeRules key: keyof NuzlockeRules
name: string name: string
description: string description: string
category: 'core' | 'difficulty' category: 'core' | 'difficulty' | 'completion'
} }
export const RULE_DEFINITIONS: RuleDefinition[] = [ export const RULE_DEFINITIONS: RuleDefinition[] = [
@@ -102,4 +108,13 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.', 'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.',
category: 'difficulty', category: 'difficulty',
}, },
// Completion
{
key: 'postGameCompletion',
name: 'Post-Game Completion',
description:
'The run continues into post-game content instead of ending after the Champion is defeated.',
category: 'completion',
},
] ]