Compare commits
17 Commits
c2e946f500
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 61a7f57f1f | |||
| 03f07ebee5 | |||
| 972137acfb | |||
| fd23d89e71 | |||
| d9d547ef53 | |||
| 349a0cb821 | |||
| ad4ac6cf8c | |||
|
|
6a86c56e3b | ||
|
|
e6646fc3e0 | ||
|
|
f9f94e5e9c | ||
|
|
6d955439eb | ||
|
|
bc9bcf4c4b | ||
|
|
c67621295a | ||
|
|
924efa9073 | ||
|
|
f0307f0625 | ||
|
|
d3b65e3c79 | ||
|
|
4e00e3cad8 |
@@ -1,11 +1,11 @@
|
||||
---
|
||||
# nuzlocke-tracker-25mh
|
||||
title: Genlocke tracking
|
||||
status: todo
|
||||
status: completed
|
||||
type: epic
|
||||
priority: normal
|
||||
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.
|
||||
@@ -78,12 +78,12 @@ A dedicated page showing:
|
||||
- [x] Nuzlocke rules are configured once and applied uniformly to all legs
|
||||
- [x] Genlocke-specific rules (Keep HoF / Retire HoF) can be selected
|
||||
- [x] The first leg starts automatically upon genlocke creation
|
||||
- [ ] 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] Each leg is a full nuzlocke run, tracked identically to standalone runs
|
||||
- [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] Completing the final leg marks the genlocke as completed
|
||||
- [ ] 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
|
||||
- [ ] Pokemon lineage is trackable across multiple legs
|
||||
- [ ] 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] A genlocke overview page shows progress, configuration, cumulative stats, lineage, and graveyard
|
||||
- [x] Transferred Pokemon appear as eggs (base form, level 1) in the next leg
|
||||
- [x] Pokemon lineage is trackable across multiple legs
|
||||
- [x] A cumulative graveyard shows all deaths across the entire genlocke
|
||||
- [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-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
|
||||
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: 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
|
||||
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: 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
|
||||
17
.beans/nuzlocke-tracker-hwyk--set-up-portainer-on-unraid.md
Normal file
17
.beans/nuzlocke-tracker-hwyk--set-up-portainer-on-unraid.md
Normal 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
# nuzlocke-tracker-lsc2
|
||||
title: Genlocke lineage tracking
|
||||
status: todo
|
||||
status: completed
|
||||
type: feature
|
||||
priority: normal
|
||||
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
|
||||
---
|
||||
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
---
|
||||
# nuzlocke-tracker-pm9f
|
||||
title: Genlocke edge cases
|
||||
status: draft
|
||||
status: completed
|
||||
type: task
|
||||
priority: normal
|
||||
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
|
||||
---
|
||||
|
||||
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.
|
||||
- [ ] 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?
|
||||
### Critical
|
||||
|
||||
- [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: 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)
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
# nuzlocke-tracker-tyvc
|
||||
title: Add end-of-run condition rule
|
||||
status: todo
|
||||
status: completed
|
||||
type: feature
|
||||
priority: normal
|
||||
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:
|
||||
@@ -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.
|
||||
|
||||
## Checklist
|
||||
- [ ] 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`
|
||||
- [ ] Verify the rule appears in the `RulesConfiguration` component (should work automatically via RULE_DEFINITIONS)
|
||||
- [ ] Verify the rule is stored correctly via the existing run/genlocke creation flows (JSONB, no backend schema change needed)
|
||||
- [x] Add `postGameCompletion` (boolean, default false) to the `NuzlockeRules` TypeScript interface and `DEFAULT_RULES`
|
||||
- [x] Add a rule definition to `RULE_DEFINITIONS` in `frontend/src/types/rules.ts`
|
||||
- [x] Verify the rule appears in the `RulesConfiguration` component (added new "Completion" section)
|
||||
- [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,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
|
||||
@@ -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
152
DEPLOYMENT.md
Normal 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
19
backend/Dockerfile.prod
Normal 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"]
|
||||
@@ -159,6 +159,19 @@ async def delete_encounter(
|
||||
if encounter is None:
|
||||
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.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -20,12 +20,15 @@ from app.schemas.genlocke import (
|
||||
GenlockeDetailResponse,
|
||||
GenlockeGraveyardResponse,
|
||||
GenlockeLegDetailResponse,
|
||||
GenlockeLineageResponse,
|
||||
GenlockeListItem,
|
||||
GenlockeResponse,
|
||||
GenlockeStatsResponse,
|
||||
GenlockeUpdate,
|
||||
GraveyardEntryResponse,
|
||||
GraveyardLegSummary,
|
||||
LineageEntry,
|
||||
LineageLegEntry,
|
||||
RetiredPokemonResponse,
|
||||
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)
|
||||
async def create_genlocke(
|
||||
data: GenlockeCreate, session: AsyncSession = Depends(get_session)
|
||||
|
||||
@@ -173,6 +173,17 @@ async def update_run(
|
||||
)
|
||||
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():
|
||||
setattr(run, field, value)
|
||||
|
||||
@@ -211,6 +222,16 @@ async def delete_run(
|
||||
if run is None:
|
||||
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
|
||||
boss_results = await session.execute(
|
||||
select(BossResult).where(BossResult.run_id == run_id)
|
||||
|
||||
@@ -132,3 +132,35 @@ class GenlockeGraveyardResponse(CamelModel):
|
||||
total_deaths: int
|
||||
deaths_per_leg: list[GraveyardLegSummary]
|
||||
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
81
deploy.sh
Executable 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
38
docker-compose.prod.yml
Normal 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
21
frontend/Dockerfile.prod
Normal 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
20
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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[]> {
|
||||
return api.get('/genlockes')
|
||||
@@ -21,6 +21,10 @@ export function getGenlockeGraveyard(id: number): Promise<GenlockeGraveyard> {
|
||||
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[]> {
|
||||
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 ${
|
||||
def.category === 'core'
|
||||
? '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}
|
||||
|
||||
@@ -22,6 +22,9 @@ export function RulesConfiguration({
|
||||
const difficultyRules = visibleRules.filter(
|
||||
(r) => r.category === 'difficulty'
|
||||
)
|
||||
const completionRules = visibleRules.filter(
|
||||
(r) => r.category === 'completion'
|
||||
)
|
||||
|
||||
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
|
||||
onChange({ ...rules, [key]: value })
|
||||
@@ -98,6 +101,30 @@ export function RulesConfiguration({
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export function BossBattleFormModal({
|
||||
<label className="block text-sm font-medium mb-1">Type</label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{BOSS_TYPES.map((t) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ interface DeleteConfirmModalProps {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
isDeleting?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({
|
||||
@@ -12,6 +13,7 @@ export function DeleteConfirmModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isDeleting,
|
||||
error,
|
||||
}: DeleteConfirmModalProps) {
|
||||
return (
|
||||
<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">
|
||||
{message}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ export { EncounterModal } from './EncounterModal'
|
||||
export { EndRunModal } from './EndRunModal'
|
||||
export { GameCard } from './GameCard'
|
||||
export { GenlockeGraveyard } from './GenlockeGraveyard'
|
||||
export { GenlockeLineage } from './GenlockeLineage'
|
||||
export { HofTeamModal } from './HofTeamModal'
|
||||
export { GameGrid } from './GameGrid'
|
||||
export { Layout } from './Layout'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getLegSurvivors } from '../api/genlockes'
|
||||
import type { AdvanceLegInput, CreateGenlockeInput } from '../types/game'
|
||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getGenlockeLineages, getLegSurvivors } from '../api/genlockes'
|
||||
import type { CreateGenlockeInput } from '../types/game'
|
||||
|
||||
export function useGenlockes() {
|
||||
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() {
|
||||
return useQuery({
|
||||
queryKey: ['games', 'by-region'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useGenlocke } from '../hooks/useGenlockes'
|
||||
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 { useMemo, useState } from 'react'
|
||||
|
||||
@@ -87,6 +87,7 @@ export function GenlockeDetail() {
|
||||
const { data: familiesData } = usePokemonFamilies()
|
||||
|
||||
const [showGraveyard, setShowGraveyard] = useState(false)
|
||||
const [showLineage, setShowLineage] = useState(false)
|
||||
|
||||
const activeLeg = useMemo(() => {
|
||||
if (!genlocke) return null
|
||||
@@ -297,9 +298,12 @@ export function GenlockeDetail() {
|
||||
Graveyard
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
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"
|
||||
title="Coming soon"
|
||||
onClick={() => setShowLineage((v) => !v)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
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
|
||||
</button>
|
||||
@@ -315,6 +319,16 @@ export function GenlockeDetail() {
|
||||
<GenlockeGraveyard genlockeId={id} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
||||
import { useGameRoutes } from '../hooks/useGames'
|
||||
@@ -6,6 +6,28 @@ import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
|
||||
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> = {
|
||||
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
completed:
|
||||
@@ -32,6 +54,17 @@ export function RunDashboard() {
|
||||
const [selectedEncounter, setSelectedEncounter] =
|
||||
useState<EncounterDetail | null>(null)
|
||||
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) {
|
||||
return (
|
||||
@@ -58,12 +91,6 @@ export function RunDashboard() {
|
||||
}
|
||||
|
||||
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 totalRoutes = routes?.length
|
||||
|
||||
@@ -172,9 +199,23 @@ export function RunDashboard() {
|
||||
|
||||
{/* Active Team */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
{isActive ? 'Active Team' : 'Final Team'}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{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 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
No pokemon caught yet — head to encounters to start building your
|
||||
|
||||
@@ -33,6 +33,28 @@ import type {
|
||||
BossPokemon,
|
||||
} 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> = {
|
||||
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
completed:
|
||||
@@ -421,6 +443,7 @@ export function RunEncounters() {
|
||||
const [showEggModal, setShowEggModal] = useState(false)
|
||||
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
|
||||
const [showTeam, setShowTeam] = useState(true)
|
||||
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
||||
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
||||
|
||||
const storageKey = `expandedGroups-${runId}`
|
||||
@@ -620,12 +643,30 @@ export function RunEncounters() {
|
||||
}
|
||||
}, [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
|
||||
const hofTeam = useMemo(() => {
|
||||
if (!run?.hofEncounterIds || run.hofEncounterIds.length === 0) return null
|
||||
const idSet = new Set(run.hofEncounterIds)
|
||||
return normalEncounters.filter((e) => idSet.has(e.id))
|
||||
}, [run?.hofEncounterIds, normalEncounters])
|
||||
return alive.filter((e) => idSet.has(e.id))
|
||||
}, [run?.hofEncounterIds, alive])
|
||||
|
||||
if (isLoading || loadingRoutes) {
|
||||
return (
|
||||
@@ -679,12 +720,6 @@ export function RunEncounters() {
|
||||
}
|
||||
|
||||
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) => {
|
||||
updateExpandedGroups((prev) => {
|
||||
@@ -1032,31 +1067,45 @@ export function RunEncounters() {
|
||||
{/* Team Section */}
|
||||
{(alive.length > 0 || dead.length > 0) && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTeam(!showTeam)}
|
||||
className="flex items-center gap-2 mb-3 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"
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTeam(!showTeam)}
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<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
|
||||
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 && (
|
||||
<>
|
||||
{alive.length > 0 && (
|
||||
|
||||
@@ -120,8 +120,9 @@ export function AdminRuns() {
|
||||
onSuccess: () => setDeleting(null),
|
||||
})
|
||||
}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onCancel={() => { setDeleting(null); deleteRun.reset() }}
|
||||
isDeleting={deleteRun.isPending}
|
||||
error={deleteRun.error?.message ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -302,6 +302,37 @@ export interface AdvanceLegInput {
|
||||
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
|
||||
|
||||
export interface GraveyardEntry {
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface NuzlockeRules {
|
||||
hardcoreMode: boolean
|
||||
levelCaps: boolean
|
||||
setModeOnly: boolean
|
||||
|
||||
// Completion
|
||||
postGameCompletion: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_RULES: NuzlockeRules = {
|
||||
@@ -26,13 +29,16 @@ export const DEFAULT_RULES: NuzlockeRules = {
|
||||
hardcoreMode: false,
|
||||
levelCaps: false,
|
||||
setModeOnly: false,
|
||||
|
||||
// Completion
|
||||
postGameCompletion: false,
|
||||
}
|
||||
|
||||
export interface RuleDefinition {
|
||||
key: keyof NuzlockeRules
|
||||
name: string
|
||||
description: string
|
||||
category: 'core' | 'difficulty'
|
||||
category: 'core' | 'difficulty' | 'completion'
|
||||
}
|
||||
|
||||
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.',
|
||||
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