11 Commits

Author SHA1 Message Date
d8fec0e5d7 fix:add debugging endpoint for auth issues
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 28s
2026-03-22 12:15:25 +01:00
c9b09b8250 fix: fix supabase auth url
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 30s
2026-03-22 12:10:03 +01:00
fde1867863 fix: add logging to debug auth issues
All checks were successful
CI / backend-tests (push) Successful in 29s
CI / frontend-tests (push) Successful in 28s
2026-03-22 12:01:28 +01:00
ce9d08963f Merge pull request 'Fix intermittent 401 errors and add ES256 JWT support' (#86) from feature/fix-intermittent-401-errors into develop
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 29s
Reviewed-on: #86
2026-03-22 11:53:48 +01:00
c5959cfd14 chore: mark ES256 JWT support bean as completed
All checks were successful
CI / backend-tests (pull_request) Successful in 33s
CI / frontend-tests (pull_request) Successful in 33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:53:13 +01:00
e935bc4d32 fix: accept ES256 (ECC P-256) JWT keys alongside RS256 in backend auth
Supabase JWT key was switched to ECC P-256, but the JWKS verification
only accepted RS256. Add ES256 to the accepted algorithms list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:52:42 +01:00
79cbb06ec9 Merge pull request 'feat: team sidebar as floating panel on desktop' (#85) from feature/team-sidebar-desktop into develop
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 28s
Reviewed-on: #85
2026-03-22 11:35:52 +01:00
d1ede63256 Merge pull request 'fix: proactively refresh Supabase JWT before API calls' (#84) from feature/fix-intermittent-401-errors into develop
Some checks failed
CI / frontend-tests (push) Has been cancelled
CI / backend-tests (push) Has been cancelled
Reviewed-on: #84
2026-03-22 11:35:26 +01:00
4d6e1dc5b2 feat: make level field optional in boss defeat modal
All checks were successful
CI / backend-tests (pull_request) Successful in 29s
CI / frontend-tests (pull_request) Successful in 39s
Remove the level input from the boss defeat modal since the app doesn't
track levels elsewhere. Team selection is now just checkboxes without
requiring level entry.

- Remove level input UI from BossDefeatModal.tsx
- Add alembic migration to make boss_result_team.level nullable
- Update model and schemas to make level optional (defaults to null)
- Conditionally render level in boss result display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:16:15 +01:00
aee28cd7a1 chore: mark bean nuzlocke-tracker-lkro as completed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:12:02 +01:00
3dbc3f35ba feat: make team section a floating sidebar on desktop
Add responsive 2-column layout for the encounters page:
- Desktop (lg, ≥1024px): Encounters on left, team in sticky right sidebar
- Mobile/tablet: Keep current stacked layout

The sidebar scrolls independently when team exceeds viewport height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:11:40 +01:00
16 changed files with 775 additions and 494 deletions

View File

@@ -5,7 +5,7 @@ status: completed
type: bug type: bug
priority: high priority: high
created_at: 2026-03-22T09:41:57Z created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:45:28Z updated_at: 2026-03-22T09:45:38Z
parent: nuzlocke-tracker-bw1m parent: nuzlocke-tracker-bw1m
blocking: blocking:
- nuzlocke-tracker-2fp1 - nuzlocke-tracker-2fp1

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-532i # nuzlocke-tracker-532i
title: 'UX: Make level field optional in boss defeat modal' title: 'UX: Make level field optional in boss defeat modal'
status: todo status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-03-21T21:50:48Z created_at: 2026-03-21T21:50:48Z
updated_at: 2026-03-21T22:04:08Z updated_at: 2026-03-22T09:16:12Z
--- ---
## Problem ## Problem
@@ -22,8 +22,17 @@ When recording which team members beat a boss, users must manually enter a level
Remove the level field entirely from the UI and make it optional in the backend: Remove the level field entirely from the UI and make it optional in the backend:
- [ ] Remove level input from `BossDefeatModal.tsx` - [x] Remove level input from `BossDefeatModal.tsx`
- [ ] Make `level` column nullable in the database (alembic migration) - [x] Make `level` column nullable in the database (alembic migration)
- [ ] Update the API schema to make level optional (default to null) - [x] Update the API schema to make level optional (default to null)
- [ ] Update any backend validation that requires level - [x] Update any backend validation that requires level
- [ ] Verify boss result display still works without level data - [x] Verify boss result display still works without level data
## Summary of Changes
- Removed level input field from BossDefeatModal.tsx, simplifying team selection to just checkboxes
- Created alembic migration to make boss_result_team.level column nullable
- Updated SQLAlchemy model and Pydantic schemas to make level optional (defaults to null)
- Updated RunEncounters.tsx to conditionally render level only when present
- Updated frontend TypeScript types for BossResultTeamMember and BossResultTeamMemberInput

View File

@@ -0,0 +1,28 @@
---
# nuzlocke-tracker-95g1
title: 'Crash: Hide edit controls for non-owners in frontend'
status: completed
type: bug
priority: high
created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:46:59Z
parent: nuzlocke-tracker-bw1m
blocking:
- nuzlocke-tracker-i2va
---
Bean was found in 'in-progress' status on startup but no agent was running.
This likely indicates a crash or unexpected termination.
Manual review required before retrying.
Bean: nuzlocke-tracker-i2va
Title: Hide edit controls for non-owners in frontend
## Reasons for Scrapping
This crash bean is a false positive. The original task (nuzlocke-tracker-i2va) was already completed and merged to `develop` before this crash bean was created:
- Commit `3bd24fc`: fix: hide edit controls for non-owners in frontend
- Commit `118dbca`: chore: mark bean nuzlocke-tracker-i2va as completed
No additional work required.

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-9rm8 # nuzlocke-tracker-9rm8
title: 'Crash: Optional TOTP MFA for email/password accounts' title: 'Crash: Optional TOTP MFA for email/password accounts'
status: scrapped status: completed
type: bug type: bug
priority: high priority: high
created_at: 2026-03-22T09:41:57Z created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:46:14Z updated_at: 2026-03-22T09:46:30Z
parent: nuzlocke-tracker-bw1m parent: nuzlocke-tracker-bw1m
blocking: blocking:
- nuzlocke-tracker-f2hs - nuzlocke-tracker-f2hs

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-lkro # nuzlocke-tracker-lkro
title: 'UX: Make team section a floating sidebar on desktop' title: 'UX: Make team section a floating sidebar on desktop'
status: todo status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-03-21T21:50:48Z created_at: 2026-03-21T21:50:48Z
updated_at: 2026-03-22T08:08:13Z updated_at: 2026-03-22T09:11:58Z
--- ---
## Problem ## Problem
@@ -28,9 +28,31 @@ Alternative: A floating action button (FAB) that opens the team in a slide-over
## Checklist ## Checklist
- [ ] Add responsive 2-column layout to RunEncounters page (desktop only) - [x] Add responsive 2-column layout to RunEncounters page (desktop only)
- [ ] Move team section into a sticky sidebar column - [x] Move team section into a sticky sidebar column
- [ ] Ensure sidebar scrolls independently if team is taller than viewport - [x] Ensure sidebar scrolls independently if team is taller than viewport
- [ ] Keep current stacked layout on mobile/tablet - [x] Keep current stacked layout on mobile/tablet
- [ ] Test with various team sizes (0-6 pokemon) - [x] Test with various team sizes (0-6 pokemon)
- [ ] Test evolution/nickname editing still works from sidebar - [x] Test evolution/nickname editing still works from sidebar
## Summary of Changes
Implemented a responsive 2-column layout for the RunEncounters page:
**Desktop (lg, ≥1024px):**
- Encounters list on the left in a flex column
- Team section in a 256px sticky sidebar on the right
- Sidebar stays visible while scrolling through routes and bosses
- Independent scrolling for sidebar when team is taller than viewport (max-h-[calc(100vh-6rem)] overflow-y-auto)
- 2-column grid for pokemon cards in sidebar
**Mobile/Tablet (<1024px):**
- Original stacked layout preserved (team above encounters)
- Collapsible team section with expand/collapse toggle
**Technical changes:**
- Page container widened from max-w-4xl to lg:max-w-6xl
- Added lg:flex lg:gap-6 wrapper for 2-column layout
- Mobile team section hidden on lg with lg:hidden
- Desktop sidebar hidden below lg with hidden lg:block
- Sidebar styled with bg-surface-1 border and rounded corners

View File

@@ -0,0 +1,15 @@
---
# nuzlocke-tracker-snft
title: Support ES256 (ECC P-256) JWT keys in backend auth
status: completed
type: bug
priority: normal
created_at: 2026-03-22T10:51:30Z
updated_at: 2026-03-22T10:59:46Z
---
Backend JWKS verification only accepts RS256 algorithm, but Supabase JWT key was switched to ECC P-256 (ES256). This causes 401 errors on all authenticated requests. Fix: accept both RS256 and ES256 in the algorithms list, and update tests accordingly.
## Summary of Changes\n\nAdded ES256 to the accepted JWT algorithms in `_verify_jwt()` so ECC P-256 keys from Supabase are verified correctly alongside RSA keys. Added corresponding test with EC key fixtures.
Deployed to production via PR #86 merge on 2026-03-22.

View File

@@ -5,7 +5,7 @@ status: completed
type: bug type: bug
priority: high priority: high
created_at: 2026-03-21T21:50:48Z created_at: 2026-03-21T21:50:48Z
updated_at: 2026-03-22T09:01:42Z updated_at: 2026-03-22T09:44:54Z
--- ---
## Problem ## Problem

View File

@@ -0,0 +1,37 @@
"""make_boss_result_team_level_nullable
Revision ID: 903e0cdbfe5a
Revises: p7e8f9a0b1c2
Create Date: 2026-03-22 10:13:41.828406
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "903e0cdbfe5a"
down_revision: str | Sequence[str] | None = "p7e8f9a0b1c2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"boss_result_team",
"level",
existing_type=sa.SmallInteger(),
nullable=True,
)
def downgrade() -> None:
op.execute("UPDATE boss_result_team SET level = 1 WHERE level IS NULL")
op.alter_column(
"boss_result_team",
"level",
existing_type=sa.SmallInteger(),
nullable=False,
)

View File

@@ -1,6 +1,10 @@
from fastapi import APIRouter import urllib.request
from fastapi import APIRouter, Request
from sqlalchemy import text from sqlalchemy import text
from app.core.auth import _build_jwks_url, _extract_token, _get_jwks_client
from app.core.config import settings
from app.core.database import async_session from app.core.database import async_session
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
@@ -23,3 +27,45 @@ async def health_check():
async def root(): async def root():
"""Root endpoint.""" """Root endpoint."""
return {"message": "Nuzlocke Tracker API", "docs": "/docs"} return {"message": "Nuzlocke Tracker API", "docs": "/docs"}
@router.get("/auth-debug")
async def auth_debug(request: Request):
"""Temporary diagnostic endpoint for auth debugging."""
result: dict = {}
# Config
result["supabase_url"] = settings.supabase_url
result["has_jwt_secret"] = bool(settings.supabase_jwt_secret)
result["jwks_url"] = (
_build_jwks_url(settings.supabase_url) if settings.supabase_url else None
)
# JWKS fetch
jwks_url = result["jwks_url"]
if jwks_url:
try:
with urllib.request.urlopen(jwks_url, timeout=5) as resp:
result["jwks_status"] = resp.status
result["jwks_body"] = resp.read().decode()
except Exception as e:
result["jwks_fetch_error"] = str(e)
# JWKS client
client = _get_jwks_client()
result["jwks_client_exists"] = client is not None
# Token info (header only, no secrets)
token = _extract_token(request)
if token:
import jwt
try:
header = jwt.get_unverified_header(token)
result["token_header"] = header
except Exception as e:
result["token_header_error"] = str(e)
else:
result["token"] = "not provided"
return result

View File

@@ -1,3 +1,4 @@
import logging
from dataclasses import dataclass from dataclasses import dataclass
from uuid import UUID from uuid import UUID
@@ -12,6 +13,7 @@ from app.core.database import get_session
from app.models.nuzlocke_run import NuzlockeRun from app.models.nuzlocke_run import NuzlockeRun
from app.models.user import User from app.models.user import User
logger = logging.getLogger(__name__)
_jwks_client: PyJWKClient | None = None _jwks_client: PyJWKClient | None = None
@@ -24,11 +26,21 @@ class AuthUser:
role: str | None = None role: str | None = None
def _build_jwks_url(base_url: str) -> str:
"""Build the JWKS URL, adding /auth/v1 prefix for Supabase Cloud."""
base = base_url.rstrip("/")
if "/auth/v1" in base:
return f"{base}/.well-known/jwks.json"
# Supabase Cloud URLs need the /auth/v1 prefix;
# local GoTrue serves JWKS at root but uses HS256 fallback anyway.
return f"{base}/auth/v1/.well-known/jwks.json"
def _get_jwks_client() -> PyJWKClient | None: def _get_jwks_client() -> PyJWKClient | None:
"""Get or create a cached JWKS client.""" """Get or create a cached JWKS client."""
global _jwks_client global _jwks_client
if _jwks_client is None and settings.supabase_url: if _jwks_client is None and settings.supabase_url:
jwks_url = f"{settings.supabase_url.rstrip('/')}/.well-known/jwks.json" jwks_url = _build_jwks_url(settings.supabase_url)
_jwks_client = PyJWKClient(jwks_url, cache_jwk_set=True, lifespan=300) _jwks_client = PyJWKClient(jwks_url, cache_jwk_set=True, lifespan=300)
return _jwks_client return _jwks_client
@@ -60,7 +72,7 @@ def _verify_jwt_hs256(token: str) -> dict | None:
def _verify_jwt(token: str) -> dict | None: def _verify_jwt(token: str) -> dict | None:
"""Verify JWT using JWKS (RS256), falling back to HS256 shared secret.""" """Verify JWT using JWKS (RS256/ES256), falling back to HS256 shared secret."""
client = _get_jwks_client() client = _get_jwks_client()
if client: if client:
try: try:
@@ -68,15 +80,17 @@ def _verify_jwt(token: str) -> dict | None:
return jwt.decode( return jwt.decode(
token, token,
signing_key.key, signing_key.key,
algorithms=["RS256"], algorithms=["RS256", "ES256"],
audience="authenticated", audience="authenticated",
) )
except jwt.InvalidTokenError: except jwt.InvalidTokenError as e:
pass logger.warning("JWKS JWT validation failed: %s", e)
except PyJWKClientError: except PyJWKClientError as e:
pass logger.warning("JWKS client error: %s", e)
except PyJWKSetError: except PyJWKSetError as e:
pass logger.warning("JWKS set error: %s", e)
else:
logger.warning("No JWKS client available (SUPABASE_URL not set?)")
return _verify_jwt_hs256(token) return _verify_jwt_hs256(token)

View File

@@ -14,7 +14,7 @@ class BossResultTeam(Base):
encounter_id: Mapped[int] = mapped_column( encounter_id: Mapped[int] = mapped_column(
ForeignKey("encounters.id", ondelete="CASCADE"), index=True ForeignKey("encounters.id", ondelete="CASCADE"), index=True
) )
level: Mapped[int] = mapped_column(SmallInteger) level: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
boss_result: Mapped[BossResult] = relationship(back_populates="team") boss_result: Mapped[BossResult] = relationship(back_populates="team")
encounter: Mapped[Encounter] = relationship() encounter: Mapped[Encounter] = relationship()

View File

@@ -57,7 +57,7 @@ class BossBattleResponse(CamelModel):
class BossResultTeamMemberResponse(CamelModel): class BossResultTeamMemberResponse(CamelModel):
id: int id: int
encounter_id: int encounter_id: int
level: int level: int | None
class BossResultResponse(CamelModel): class BossResultResponse(CamelModel):
@@ -120,7 +120,7 @@ class BossPokemonInput(CamelModel):
class BossResultTeamMemberInput(CamelModel): class BossResultTeamMemberInput(CamelModel):
encounter_id: int encounter_id: int
level: int level: int | None = None
class BossResultCreate(CamelModel): class BossResultCreate(CamelModel):

View File

@@ -4,7 +4,7 @@ from uuid import UUID
import jwt import jwt
import pytest import pytest
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import ec, rsa
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.core.auth import AuthUser, get_current_user, require_admin, require_auth from app.core.auth import AuthUser, get_current_user, require_admin, require_auth
@@ -73,6 +73,55 @@ def mock_jwks_client(rsa_key_pair):
return mock_client return mock_client
@pytest.fixture(scope="module")
def ec_key_pair():
"""Generate EC P-256 key pair for testing."""
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
return private_key, public_key
@pytest.fixture
def valid_es256_token(ec_key_pair):
"""Generate a valid ES256 JWT token."""
private_key, _ = ec_key_pair
payload = {
"sub": "user-456",
"email": "ec-user@example.com",
"role": "authenticated",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
}
return jwt.encode(payload, private_key, algorithm="ES256")
@pytest.fixture
def mock_jwks_client_ec(ec_key_pair):
"""Create a mock JWKS client that returns our test EC public key."""
_, public_key = ec_key_pair
mock_client = MagicMock()
mock_signing_key = MagicMock()
mock_signing_key.key = public_key
mock_client.get_signing_key_from_jwt.return_value = mock_signing_key
return mock_client
async def test_get_current_user_valid_es256_token(
valid_es256_token, mock_jwks_client_ec
):
"""Test get_current_user works with ES256 (ECC P-256) tokens."""
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client_ec):
class MockRequest:
headers = {"Authorization": f"Bearer {valid_es256_token}"}
user = get_current_user(MockRequest())
assert user is not None
assert user.id == "user-456"
assert user.email == "ec-user@example.com"
assert user.role == "authenticated"
async def test_get_current_user_valid_token(valid_token, mock_jwks_client): async def test_get_current_user_valid_token(valid_token, mock_jwks_client):
"""Test get_current_user returns user for valid token.""" """Test get_current_user returns user for valid token."""
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):

View File

@@ -23,10 +23,7 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
return matches.length === 1 ? (matches[0] ?? null) : null return matches.length === 1 ? (matches[0] ?? null) : null
} }
interface TeamSelection { type TeamSelection = number
encounterId: number
level: number
}
export function BossDefeatModal({ export function BossDefeatModal({
boss, boss,
@@ -36,26 +33,15 @@ export function BossDefeatModal({
isPending, isPending,
starterName, starterName,
}: BossDefeatModalProps) { }: BossDefeatModalProps) {
const [selectedTeam, setSelectedTeam] = useState<Map<number, TeamSelection>>(new Map()) const [selectedTeam, setSelectedTeam] = useState<Set<TeamSelection>>(new Set())
const toggleTeamMember = (enc: EncounterDetail) => { const toggleTeamMember = (encounterId: number) => {
setSelectedTeam((prev) => { setSelectedTeam((prev) => {
const next = new Map(prev) const next = new Set(prev)
if (next.has(enc.id)) { if (next.has(encounterId)) {
next.delete(enc.id) next.delete(encounterId)
} else { } else {
next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 }) next.add(encounterId)
}
return next
})
}
const updateLevel = (encounterId: number, level: number) => {
setSelectedTeam((prev) => {
const next = new Map(prev)
const existing = next.get(encounterId)
if (existing) {
next.set(encounterId, { ...existing, level })
} }
return next return next
}) })
@@ -87,7 +73,9 @@ export function BossDefeatModal({
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault() e.preventDefault()
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values()) const team: BossResultTeamMemberInput[] = Array.from(selectedTeam).map((encounterId) => ({
encounterId,
}))
onSubmit({ onSubmit({
bossBattleId: boss.id, bossBattleId: boss.id,
result: 'won', result: 'won',
@@ -134,11 +122,17 @@ export function BossDefeatModal({
return ( return (
<div key={bp.id} className="flex flex-col items-center"> <div key={bp.id} className="flex flex-col items-center">
{bp.pokemon.spriteUrl ? ( {bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" /> <img
src={bp.pokemon.spriteUrl}
alt={bp.pokemon.name}
className="w-10 h-10"
/>
) : ( ) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" /> <div className="w-10 h-10 bg-surface-3 rounded-full" />
)} )}
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span> <span className="text-xs text-text-tertiary capitalize">
{bp.pokemon.name}
</span>
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span> <span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" /> <ConditionBadge condition={bp.conditionLabel} size="xs" />
{bp.ability && ( {bp.ability && (
@@ -166,7 +160,6 @@ export function BossDefeatModal({
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{aliveEncounters.map((enc) => { {aliveEncounters.map((enc) => {
const isSelected = selectedTeam.has(enc.id) const isSelected = selectedTeam.has(enc.id)
const selection = selectedTeam.get(enc.id)
const displayPokemon = enc.currentPokemon ?? enc.pokemon const displayPokemon = enc.currentPokemon ?? enc.pokemon
return ( return (
<div <div
@@ -176,12 +169,12 @@ export function BossDefeatModal({
? 'border-accent-500 bg-accent-500/10' ? 'border-accent-500 bg-accent-500/10'
: 'border-border-default hover:bg-surface-2' : 'border-border-default hover:bg-surface-2'
}`} }`}
onClick={() => toggleTeamMember(enc)} onClick={() => toggleTeamMember(enc.id)}
> >
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => toggleTeamMember(enc)} onChange={() => toggleTeamMember(enc.id)}
className="sr-only" className="sr-only"
/> />
{displayPokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
@@ -193,26 +186,9 @@ export function BossDefeatModal({
) : ( ) : (
<div className="w-8 h-8 bg-surface-3 rounded-full" /> <div className="w-8 h-8 bg-surface-3 rounded-full" />
)} )}
<div className="flex-1 min-w-0"> <p className="flex-1 min-w-0 text-xs font-medium truncate">
<p className="text-xs font-medium truncate"> {enc.nickname ?? displayPokemon.name}
{enc.nickname ?? displayPokemon.name} </p>
</p>
{isSelected && (
<input
type="number"
min={1}
max={100}
value={selection?.level ?? enc.catchLevel ?? 1}
onChange={(e) => {
e.stopPropagation()
updateLevel(enc.id, Number.parseInt(e.target.value, 10) || 1)
}}
onClick={(e) => e.stopPropagation()}
className="w-14 text-xs px-1 py-0.5 mt-1 rounded border border-border-default bg-surface-1"
placeholder="Lv"
/>
)}
</div>
</div> </div>
) )
})} })}

View File

@@ -922,7 +922,7 @@ export function RunEncounters() {
}) })
return ( return (
<div className="max-w-4xl mx-auto p-8"> <div className="max-w-4xl lg:max-w-6xl mx-auto p-8">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<Link <Link
@@ -1246,250 +1246,279 @@ export function RunEncounters() {
{/* Encounters Tab */} {/* Encounters Tab */}
{activeTab === 'encounters' && ( {activeTab === 'encounters' && (
<> <>
{/* Team Section */} <div className="lg:flex lg:gap-6">
{(alive.length > 0 || dead.length > 0) && ( {/* Main content column */}
<div className="mb-6"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-3"> {/* Team Section - Mobile/Tablet only */}
<button {(alive.length > 0 || dead.length > 0) && (
type="button" <div className="mb-6 lg:hidden">
onClick={() => setShowTeam(!showTeam)} <div className="flex items-center justify-between mb-3">
className="flex items-center gap-2 group" <button
> type="button"
<h2 className="text-lg font-semibold text-text-primary"> onClick={() => setShowTeam(!showTeam)}
{isActive ? 'Team' : 'Final Team'} className="flex items-center gap-2 group"
</h2> >
<span className="text-xs text-text-muted"> <h2 className="text-lg font-semibold text-text-primary">
{alive.length} alive {isActive ? 'Team' : 'Final Team'}
{dead.length > 0 ? `, ${dead.length} dead` : ''} </h2>
</span> <span className="text-xs text-text-muted">
<svg {alive.length} alive
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`} {dead.length > 0 ? `, ${dead.length} dead` : ''}
fill="none" </span>
stroke="currentColor" <svg
viewBox="0 0 24 24" className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
> fill="none"
<path stroke="currentColor"
strokeLinecap="round" viewBox="0 0 24 24"
strokeLinejoin="round" >
strokeWidth={2} <path
d="M19 9l-7 7-7-7" strokeLinecap="round"
/> strokeLinejoin="round"
</svg> strokeWidth={2}
</button> d="M19 9l-7 7-7-7"
{showTeam && alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<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 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/> />
))} </svg>
</div> </button>
)} {showTeam && alive.length > 1 && (
{dead.length > 0 && ( <select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<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 && (
<> <>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3> {alive.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2"> <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{dead.map((enc) => ( {alive.map((enc) => (
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
showFaintLevel onClick={
onClick={ isActive && canEdit
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined ? () => setSelectedTeamEncounter(enc)
} : undefined
/> }
))} />
</div> ))}
</div>
)}
{dead.length > 0 && (
<>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={
isActive && canEdit
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
</div>
</>
)}
</> </>
)} )}
</> </div>
)} )}
</div>
)}
{/* Shiny Box */} {/* Shiny Box */}
{run.rules?.shinyClause && shinyEncounters.length > 0 && ( {run.rules?.shinyClause && shinyEncounters.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<ShinyBox <ShinyBox
encounters={shinyEncounters} encounters={shinyEncounters}
onEncounterClick={ onEncounterClick={
isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined
} }
/>
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined}
/> />
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && canEdit && completedCount < totalLocations && (
<button
type="button"
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
)}
</div>
<span className="text-sm text-text-tertiary">
{completedCount} / {totalLocations} locations
</span>
</div>
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-4 flex-wrap">
{(
[
{ key: 'all', label: 'All' },
{ key: 'none', label: 'Unvisited' },
{ key: 'caught', label: 'Caught' },
{ key: 'fainted', label: 'Fainted' },
{ key: 'missed', label: 'Missed' },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))} ))}
</div> </div>
</div>
)}
{/* Progress bar */} {/* Route list */}
<div className="mb-4"> <div className="space-y-1">
<div className="flex items-center justify-between mb-1"> {filteredRoutes.length === 0 && (
<div className="flex items-center gap-3"> <p className="text-text-tertiary text-sm py-4 text-center">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2> {filter === 'all'
{isActive && canEdit && completedCount < totalLocations && ( ? 'Click a route to log your first encounter'
<button : 'No routes match this filter — try a different one'}
type="button" </p>
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
)} )}
</div> {filteredRoutes.map((route) => {
<span className="text-sm text-text-tertiary"> // Collect all route IDs to check for boss cards after
{completedCount} / {totalLocations} locations const routeIds: number[] =
</span> route.children.length > 0
</div> ? [route.id, ...route.children.map((c) => c.id)]
<div className="h-2 bg-surface-3 rounded-full overflow-hidden"> : [route.id]
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{/* Filter tabs */} // Find boss battles positioned after this route (or any of its children)
<div className="flex gap-2 mb-4 flex-wrap"> const bossesHere: BossBattle[] = []
{( for (const rid of routeIds) {
[ const b = bossesAfterRoute.get(rid)
{ key: 'all', label: 'All' }, if (b) bossesHere.push(...b)
{ key: 'none', label: 'Unvisited' }, }
{ key: 'caught', label: 'Caught' },
{ key: 'fainted', label: 'Fainted' },
{ key: 'missed', label: 'Missed' },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))}
</div>
{/* Route list */} const routeElement =
<div className="space-y-1"> route.children.length > 0 ? (
{filteredRoutes.length === 0 && ( <RouteGroup
<p className="text-text-tertiary text-sm py-4 text-center">
{filter === 'all'
? 'Click a route to log your first encounter'
: 'No routes match this filter — try a different one'}
</p>
)}
{filteredRoutes.map((route) => {
// Collect all route IDs to check for boss cards after
const routeIds: number[] =
route.children.length > 0
? [route.id, ...route.children.map((c) => c.id)]
: [route.id]
// Find boss battles positioned after this route (or any of its children)
const bossesHere: BossBattle[] = []
for (const rid of routeIds) {
const b = bossesAfterRoute.get(rid)
if (b) bossesHere.push(...b)
}
const routeElement =
route.children.length > 0 ? (
<RouteGroup
key={route.id}
group={route}
encounterByRoute={encounterByRoute}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={canEdit ? handleRouteClick : undefined}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const giftEncounter = giftEncounterByRoute.get(route.id)
const displayEncounter = encounter ?? giftEncounter
const rs = getRouteStatus(displayEncounter)
const si = statusIndicator[rs]
return (
<button
key={route.id} key={route.id}
type="button" group={route}
onClick={canEdit ? () => handleRouteClick(route) : undefined} encounterByRoute={encounterByRoute}
disabled={!canEdit} giftEncounterByRoute={giftEncounterByRoute}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${!canEdit ? 'cursor-default' : 'hover:bg-surface-2/50'} ${si.bg}`} isExpanded={expandedGroups.has(route.id)}
> onToggleExpand={() => toggleGroup(route.id)}
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} /> onRouteClick={canEdit ? handleRouteClick : undefined}
<div className="flex-1 min-w-0"> filter={filter}
<div className="text-sm font-medium text-text-primary">{route.name}</div> pinwheelClause={pinwheelClause}
{encounter ? ( />
<div className="flex items-center gap-2 mt-0.5"> ) : (
{encounter.pokemon.spriteUrl && ( (() => {
<img const encounter = encounterByRoute.get(route.id)
src={encounter.pokemon.spriteUrl} const giftEncounter = giftEncounterByRoute.get(route.id)
alt={encounter.pokemon.name} const displayEncounter = encounter ?? giftEncounter
className="w-10 h-10" const rs = getRouteStatus(displayEncounter)
/> const si = statusIndicator[rs]
)}
<span className="text-xs text-text-tertiary capitalize"> return (
{encounter.nickname ?? encounter.pokemon.name} <button
{encounter.status === 'caught' && key={route.id}
encounter.faintLevel !== null && type="button"
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')} onClick={canEdit ? () => handleRouteClick(route) : undefined}
</span> disabled={!canEdit}
{giftEncounter && ( className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${!canEdit ? 'cursor-default' : 'hover:bg-surface-2/50'} ${si.bg}`}
<> >
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">
{route.name}
</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause
? `${encounter.deathCause}`
: ' (dead)')}
</span>
{giftEncounter && (
<>
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && ( {giftEncounter.pokemon.spriteUrl && (
<img <img
src={giftEncounter.pokemon.spriteUrl} src={giftEncounter.pokemon.spriteUrl}
@@ -1501,194 +1530,250 @@ export function RunEncounters() {
{giftEncounter.nickname ?? giftEncounter.pokemon.name} {giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span> <span className="text-text-muted ml-1">(gift)</span>
</span> </span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</div>
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
)}
</div>
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
</button>
)
})()
)
return (
<div key={route.id}>
{routeElement}
{/* Boss battle cards after this route */}
{bossesHere.map((boss) => {
const isDefeated = defeatedBossIds.has(boss.id)
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
const bossTypeLabel: Record<string, string> = {
gym_leader: 'Gym Leader',
elite_four: 'Elite Four',
champion: 'Champion',
rival: 'Rival',
evil_team: 'Evil Team',
kahuna: 'Kahuna',
totem: 'Totem',
other: 'Boss',
}
const bossTypeColors: Record<string, string> = {
gym_leader: 'border-yellow-600',
elite_four: 'border-purple-600',
champion: 'border-red-600',
rival: 'border-blue-600',
evil_team: 'border-gray-400',
kahuna: 'border-orange-600',
totem: 'border-teal-600',
other: 'border-gray-500',
}
const isBossExpanded = expandedBosses.has(boss.id)
const toggleBoss = () => {
setExpandedBosses((prev) => {
const next = new Set(prev)
if (next.has(boss.id)) next.delete(boss.id)
else next.add(boss.id)
return next
})
}
return (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
{boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</div> </div>
<p className="text-xs text-text-tertiary"> ) : (
{boss.location} &middot; Level Cap: {boss.levelCap} route.encounterMethods.length > 0 && (
</p> <div className="flex flex-wrap gap-1 mt-0.5">
</div> {route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
)}
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <span className="text-xs text-text-muted shrink-0">{si.label}</span>
{isDefeated ? ( </button>
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800"> )
Defeated &#10003; })()
</span> )
) : isActive && canEdit ? (
<button return (
onClick={() => setSelectedBoss(boss)} <div key={route.id}>
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors" {routeElement}
> {/* Boss battle cards after this route */}
Battle {bossesHere.map((boss) => {
</button> const isDefeated = defeatedBossIds.has(boss.id)
) : null} const sectionAfter = sectionDividerAfterBoss.get(boss.id)
</div> const bossTypeLabel: Record<string, string> = {
</div> gym_leader: 'Gym Leader',
{/* Boss pokemon team */} elite_four: 'Elite Four',
{isBossExpanded && boss.pokemon.length > 0 && ( champion: 'Champion',
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} /> rival: 'Rival',
)} evil_team: 'Evil Team',
{/* Player team snapshot */} kahuna: 'Kahuna',
{isDefeated && totem: 'Totem',
(() => { other: 'Boss',
const result = bossResultByBattleId.get(boss.id) }
if (!result || result.team.length === 0) return null const bossTypeColors: Record<string, string> = {
return ( gym_leader: 'border-yellow-600',
<div className="mt-3 pt-3 border-t border-border-default"> elite_four: 'border-purple-600',
<p className="text-xs font-medium text-text-secondary mb-2"> champion: 'border-red-600',
Your Team rival: 'border-blue-600',
</p> evil_team: 'border-gray-400',
<div className="flex gap-2 flex-wrap"> kahuna: 'border-orange-600',
{result.team.map((tm: BossResultTeamMember) => { totem: 'border-teal-600',
const enc = encounterById.get(tm.encounterId) other: 'border-gray-500',
if (!enc) return null }
const dp = enc.currentPokemon ?? enc.pokemon
return ( const isBossExpanded = expandedBosses.has(boss.id)
<div key={tm.id} className="flex flex-col items-center"> const toggleBoss = () => {
{dp.spriteUrl ? ( setExpandedBosses((prev) => {
<img const next = new Set(prev)
src={dp.spriteUrl} if (next.has(boss.id)) next.delete(boss.id)
alt={dp.name} else next.add(boss.id)
className="w-10 h-10" return next
/> })
) : ( }
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)} return (
<span className="text-[10px] text-text-tertiary capitalize"> <div key={`boss-${boss.id}`}>
{enc.nickname ?? dp.name} <div
</span> className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
<span className="text-[10px] text-text-muted"> isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
Lv.{tm.level} } px-4 py-3`}
</span> >
</div> <div
) className="flex items-start justify-between cursor-pointer select-none"
})} onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
{boss.spriteUrl && (
<img
src={boss.spriteUrl}
alt={boss.name}
className="h-10 w-auto"
/>
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
</div>
<p className="text-xs text-text-tertiary">
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div> </div>
</div> </div>
) <div onClick={(e) => e.stopPropagation()}>
})()} {isDefeated ? (
</div> <span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
{sectionAfter && ( Defeated &#10003;
<div className="flex items-center gap-3 my-4"> </span>
<div className="flex-1 h-px bg-surface-3" /> ) : isActive && canEdit ? (
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider"> <button
{sectionAfter} onClick={() => setSelectedBoss(boss)}
</span> className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
<div className="flex-1 h-px bg-surface-3" /> >
Battle
</button>
) : null}
</div>
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
{/* Player team snapshot */}
{isDefeated &&
(() => {
const result = bossResultByBattleId.get(boss.id)
if (!result || result.team.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border-default">
<p className="text-xs font-medium text-text-secondary mb-2">
Your Team
</p>
<div className="flex gap-2 flex-wrap">
{result.team.map((tm: BossResultTeamMember) => {
const enc = encounterById.get(tm.encounterId)
if (!enc) return null
const dp = enc.currentPokemon ?? enc.pokemon
return (
<div key={tm.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img
src={dp.spriteUrl}
alt={dp.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)}
<span className="text-[10px] text-text-tertiary capitalize">
{enc.nickname ?? dp.name}
</span>
{tm.level != null && (
<span className="text-[10px] text-text-muted">
Lv.{tm.level}
</span>
)}
</div>
)
})}
</div>
</div>
)
})()}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-surface-3" />
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
{sectionAfter}
</span>
<div className="flex-1 h-px bg-surface-3" />
</div>
)}
</div> </div>
)} )
})}
</div>
)
})}
</div>
</div>
{/* Team Sidebar - Desktop only */}
{(alive.length > 0 || dead.length > 0) && (
<div className="hidden lg:block w-64 shrink-0">
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
<div className="bg-surface-1 border border-border-default rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-text-muted">
{alive.length}/{alive.length + dead.length}
</span>
</div>
{alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="w-full text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-0 text-text-primary mb-3"
>
<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>
)}
{alive.length > 0 && (
<div className="grid grid-cols-2 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div> </div>
) )}
})} {dead.length > 0 && (
<>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
<div className="grid grid-cols-2 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={
isActive && canEdit
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
</div>
</>
)}
</div>
</div> </div>
) </div>
})} )}
</div> </div>
{/* Encounter Modal */} {/* Encounter Modal */}

View File

@@ -238,7 +238,7 @@ export interface BossBattle {
export interface BossResultTeamMember { export interface BossResultTeamMember {
id: number id: number
encounterId: number encounterId: number
level: number level: number | null
} }
export interface BossResult { export interface BossResult {
@@ -253,7 +253,7 @@ export interface BossResult {
export interface BossResultTeamMemberInput { export interface BossResultTeamMemberInput {
encounterId: number encounterId: number
level: number level?: number | null
} }
export interface CreateBossResultInput { export interface CreateBossResultInput {