Compare commits
10 Commits
c2e946f500
...
6a86c56e3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a86c56e3b | ||
|
|
e6646fc3e0 | ||
|
|
f9f94e5e9c | ||
|
|
6d955439eb | ||
|
|
bc9bcf4c4b | ||
|
|
c67621295a | ||
|
|
924efa9073 | ||
|
|
f0307f0625 | ||
|
|
d3b65e3c79 | ||
|
|
4e00e3cad8 |
@@ -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
|
||||||
15
.beans/nuzlocke-tracker-3c9l--set-up-branching-structure.md
Normal file
15
.beans/nuzlocke-tracker-3c9l--set-up-branching-structure.md
Normal 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
|
||||||
@@ -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-09T15:31:15Z
|
||||||
|
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
|
||||||
16
.beans/nuzlocke-tracker-48ds--database-backup-strategy.md
Normal file
16
.beans/nuzlocke-tracker-48ds--database-backup-strategy.md
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
60
.beans/nuzlocke-tracker-ahza--deployment-strategy.md
Normal file
60
.beans/nuzlocke-tracker-ahza--deployment-strategy.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-ahza
|
||||||
|
title: Deployment Strategy
|
||||||
|
status: todo
|
||||||
|
type: epic
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-09T14:03:53Z
|
||||||
|
updated_at: 2026-02-09T15:08:29Z
|
||||||
|
---
|
||||||
|
|
||||||
|
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 + Local Docker Registry**
|
||||||
|
|
||||||
|
1. **A local Docker registry** runs on Unraid as a container, accessible on the LAN (e.g., `unraid:5000` or behind Nginx Proxy Manager).
|
||||||
|
2. **Images are built on the dev machine** and pushed to the local registry.
|
||||||
|
3. **Production runs docker-compose** on Unraid, pulling images from the local 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 local registry → trigger Portainer webhook to redeploy.
|
||||||
|
6. **Nginx Proxy Manager** handles routing on the LAN (e.g., `nuzlocke.local` → frontend container).
|
||||||
|
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 local 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`)
|
||||||
|
- [ ] **Set up local Docker registry on Unraid** — run the `registry:2` container, configure storage volume, optionally put it behind Nginx Proxy Manager with a hostname (e.g., `registry.local`)
|
||||||
|
- [ ] **Create production docker-compose file** (`docker-compose.prod.yml`) — uses images from the local registry, production env vars, no source volume mounts, proper restart policies
|
||||||
|
- [ ] **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
|
||||||
|
- [ ] **Set up Portainer on Unraid** — install Portainer CE as a Docker container, configure the stack from the production compose file
|
||||||
|
- [ ] **Configure Portainer webhook for automated redeployment** — add a webhook trigger in Portainer that pulls latest images and restarts the stack
|
||||||
|
- [ ] **Create deploy script** — a script (e.g., `./deploy.sh`) that builds images from `main`, tags them for the local registry, pushes them, and triggers the Portainer webhook to redeploy
|
||||||
|
- [ ] **Configure Nginx Proxy Manager** — add proxy host entry pointing to the frontend/API containers 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
|
||||||
24
.beans/nuzlocke-tracker-aiw6--create-deploy-script.md
Normal file
24
.beans/nuzlocke-tracker-aiw6--create-deploy-script.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-aiw6
|
||||||
|
title: Create deploy script
|
||||||
|
status: todo
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-09T15:30:48Z
|
||||||
|
updated_at: 2026-02-09T15:31:15Z
|
||||||
|
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
|
||||||
16
.beans/nuzlocke-tracker-hwyk--set-up-portainer-on-unraid.md
Normal file
16
.beans/nuzlocke-tracker-hwyk--set-up-portainer-on-unraid.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-hwyk
|
||||||
|
title: Set up Portainer on Unraid
|
||||||
|
status: todo
|
||||||
|
type: task
|
||||||
|
created_at: 2026-02-09T15:30:44Z
|
||||||
|
updated_at: 2026-02-09T15:30:44Z
|
||||||
|
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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-izf6
|
||||||
|
title: Set up local Docker registry on Unraid
|
||||||
|
status: todo
|
||||||
|
type: task
|
||||||
|
created_at: 2026-02-09T15:30:40Z
|
||||||
|
updated_at: 2026-02-09T15:30:40Z
|
||||||
|
parent: nuzlocke-tracker-ahza
|
||||||
|
---
|
||||||
|
|
||||||
|
Run a `registry:2` container on the Unraid server for storing production Docker images locally.
|
||||||
|
|
||||||
|
- Run `registry:2` as a Docker container on Unraid
|
||||||
|
- Configure a persistent storage volume for the registry data
|
||||||
|
- Optionally put it behind Nginx Proxy Manager with a hostname (e.g., `registry.local`)
|
||||||
|
- Verify pushing/pulling images from the dev machine works
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-jzqz
|
||||||
|
title: Configure Portainer webhook for automated redeployment
|
||||||
|
status: todo
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-09T15:30:45Z
|
||||||
|
updated_at: 2026-02-09T15:31:15Z
|
||||||
|
parent: nuzlocke-tracker-ahza
|
||||||
|
blocking:
|
||||||
|
- nuzlocke-tracker-hwyk
|
||||||
|
---
|
||||||
|
|
||||||
|
Set up a webhook in Portainer that triggers a stack redeployment when called.
|
||||||
|
|
||||||
|
- Create a webhook trigger in Portainer for the nuzlocke-tracker stack
|
||||||
|
- The webhook should pull the latest images from the local registry and restart the stack
|
||||||
|
- Note the webhook URL for use in the deploy script
|
||||||
@@ -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
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-re0m
|
||||||
|
title: Document the deployment workflow
|
||||||
|
status: todo
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-09T15:30:57Z
|
||||||
|
updated_at: 2026-02-09T15:31:15Z
|
||||||
|
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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-vpn5
|
||||||
|
title: Create production docker-compose file
|
||||||
|
status: todo
|
||||||
|
type: task
|
||||||
|
created_at: 2026-02-09T15:30:41Z
|
||||||
|
updated_at: 2026-02-09T15:30:41Z
|
||||||
|
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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-xmyh
|
||||||
|
title: Create production Dockerfiles
|
||||||
|
status: todo
|
||||||
|
type: task
|
||||||
|
created_at: 2026-02-09T15:30:42Z
|
||||||
|
updated_at: 2026-02-09T15:30:42Z
|
||||||
|
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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`)
|
||||||
}
|
}
|
||||||
|
|||||||
276
frontend/src/components/GenlockeLineage.tsx
Normal file
276
frontend/src/components/GenlockeLineage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 { AdvanceLegInput, CreateGenlockeInput } from '../types/game'
|
||||||
|
|
||||||
export function useGenlockes() {
|
export function useGenlockes() {
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user