diff --git a/.beans/nuzlocke-tracker-xa5k--display-encounter-less-locations-for-egg-hatching.md b/.beans/nuzlocke-tracker-xa5k--display-encounter-less-locations-for-egg-hatching.md index 0d84a85..802602d 100644 --- a/.beans/nuzlocke-tracker-xa5k--display-encounter-less-locations-for-egg-hatching.md +++ b/.beans/nuzlocke-tracker-xa5k--display-encounter-less-locations-for-egg-hatching.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-xa5k title: Add egg encounter logging -status: todo +status: in-progress type: feature priority: normal created_at: 2026-02-08T14:49:50Z -updated_at: 2026-02-08T20:55:33Z +updated_at: 2026-02-08T21:17:56Z --- Allow players to log egg hatches at any location, similar to how shiny encounters work. A "Log Egg" button (next to "Log Shiny") opens a modal that shows all locations — including those without wild encounters — so the player can record where an egg hatched. @@ -19,7 +19,7 @@ Egg encounters should: ## Checklist - [ ] Data: Seed locations without wild encounters into the routes table via PokeAPI (so they appear in the route list) -- [ ] Backend: Bypass route-lock for egg encounters (extend `skip_route_lock` with an egg origin) -- [ ] Frontend: Add "Log Egg" button to the run encounters page header (next to "Log Shiny") -- [ ] Frontend: Create `EggEncounterModal` — similar to `ShinyEncounterModal` but shows all routes (including encounter-less ones) and uses `origin: "egg"` -- [ ] Frontend: Ensure egg encounters display normally in team view and route list \ No newline at end of file +- [x] Backend: Bypass route-lock for egg encounters (extend `skip_route_lock` with an egg origin) +- [x] Frontend: Add "Log Egg" button to the run encounters page header (next to "Log Shiny") +- [x] Frontend: Create `EggEncounterModal` — similar to `ShinyEncounterModal` but shows all routes (including encounter-less ones) and uses `origin: "egg"` +- [x] Frontend: Ensure egg encounters display normally in team view and route list \ No newline at end of file diff --git a/backend/src/app/api/encounters.py b/backend/src/app/api/encounters.py index 92e97f6..485ca6c 100644 --- a/backend/src/app/api/encounters.py +++ b/backend/src/app/api/encounters.py @@ -58,7 +58,7 @@ async def create_encounter( # Shiny clause: shiny encounters bypass the route-lock check shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True - skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin == "shed_evolution" + skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in ("shed_evolution", "egg") # If this route has a parent, check if sibling already has an encounter if route.parent_route_id is not None and not skip_route_lock: diff --git a/frontend/src/components/EggEncounterModal.tsx b/frontend/src/components/EggEncounterModal.tsx new file mode 100644 index 0000000..1a2ffe8 --- /dev/null +++ b/frontend/src/components/EggEncounterModal.tsx @@ -0,0 +1,271 @@ +import { useState, useEffect } from 'react' +import { api } from '../api/client' +import type { Route, Pokemon } from '../types' + +interface EggEncounterModalProps { + routes: Route[] + onSubmit: (data: { + routeId: number + pokemonId: number + nickname?: string + status: 'caught' + catchLevel?: number + origin: 'egg' + }) => void + onClose: () => void + isPending: boolean +} + +export function EggEncounterModal({ + routes, + onSubmit, + onClose, + isPending, +}: EggEncounterModalProps) { + const [selectedRouteId, setSelectedRouteId] = useState(null) + const [selectedPokemon, setSelectedPokemon] = useState(null) + const [nickname, setNickname] = useState('') + const [catchLevel, setCatchLevel] = useState('') + const [search, setSearch] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + + // Only show leaf routes (no children) + const parentIds = new Set(routes.filter(r => r.parentRouteId !== null).map(r => r.parentRouteId)) + const leafRoutes = routes.filter(r => !parentIds.has(r.id)) + + // Debounced pokemon search + useEffect(() => { + if (search.length < 2) { + setSearchResults([]) + return + } + + const timer = setTimeout(async () => { + setIsSearching(true) + try { + const data = await api.get<{ items: Pokemon[] }>(`/pokemon?search=${encodeURIComponent(search)}&limit=20`) + setSearchResults(data.items) + } catch { + setSearchResults([]) + } finally { + setIsSearching(false) + } + }, 300) + + return () => clearTimeout(timer) + }, [search]) + + const handleSubmit = () => { + if (selectedPokemon && selectedRouteId) { + onSubmit({ + routeId: selectedRouteId, + pokemonId: selectedPokemon.id, + nickname: nickname || undefined, + status: 'caught', + catchLevel: catchLevel ? Number(catchLevel) : undefined, + origin: 'egg', + }) + } + } + + return ( +
+
+
+
+
+

+ 🥚 + Log Egg Hatch +

+ +
+

+ Egg hatches bypass the one-per-route rule +

+
+ +
+ {/* Route selector */} +
+ + +
+ + {/* Pokemon search */} +
+ + {selectedPokemon ? ( +
+ {selectedPokemon.spriteUrl ? ( + {selectedPokemon.name} + ) : ( +
+ {selectedPokemon.name[0].toUpperCase()} +
+ )} + + {selectedPokemon.name} + + +
+ ) : ( + <> + setSearch(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500" + /> + {isSearching && ( +
+
+
+ )} + {searchResults.length > 0 && ( +
+ {searchResults.map((p) => ( + + ))} +
+ )} + {search.length >= 2 && !isSearching && searchResults.length === 0 && ( +

+ No pokemon found +

+ )} + + )} +
+ + {/* Nickname */} + {selectedPokemon && ( +
+ + setNickname(e.target.value)} + placeholder="Give it a name..." + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+ )} + + {/* Hatch Level */} + {selectedPokemon && ( +
+ + setCatchLevel(e.target.value)} + placeholder="1" + className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 056c4af..192067c 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,3 +1,4 @@ +export { EggEncounterModal } from './EggEncounterModal' export { EncounterMethodBadge } from './EncounterMethodBadge' export { EncounterModal } from './EncounterModal' export { EndRunModal } from './EndRunModal' diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 7e27590..9811f0c 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -6,6 +6,7 @@ import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hoo import { usePokemonFamilies } from '../hooks/usePokemon' import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses' import { + EggEncounterModal, EncounterModal, EncounterMethodBadge, StatCard, @@ -410,6 +411,7 @@ export function RunEncounters() { useState(null) const [showEndRun, setShowEndRun] = useState(false) const [showShinyModal, setShowShinyModal] = useState(false) + const [showEggModal, setShowEggModal] = useState(false) const [expandedBosses, setExpandedBosses] = useState>(new Set()) const [showTeam, setShowTeam] = useState(true) const [filter, setFilter] = useState<'all' | RouteStatus>('all') @@ -675,6 +677,7 @@ export function RunEncounters() { setSelectedRoute(null) setEditingEncounter(null) setShowShinyModal(false) + setShowEggModal(false) }, }) } @@ -752,6 +755,14 @@ export function RunEncounters() { ✦ Log Shiny )} + {isActive && ( + + )} {isActive && (