From 9c72c9d6be1e56ee2b04791050126b680cf562ae Mon Sep 17 00:00:00 2001 From: charmingruby Date: Mon, 2 Feb 2026 09:32:31 -0300 Subject: [PATCH 1/4] wip: base replacement with pending stuff --- .../plans/2026-02-01-team-variant-editing.md | 96 +++ ...-02-01-team-variant-editing-requirement.md | 71 +++ ...26-02-02-team-replace-reset-requirement.md | 70 +++ src/components/team/PokemonDetails.svelte | 58 +- src/components/team/PokemonSearch.svelte | 342 +++++++++- src/components/team/PokemonSlot.svelte | 10 +- .../team/ReplaceConfirmationPrompt.svelte | 129 ++++ src/components/team/TeamBuilder.svelte | 189 ++++-- src/components/team/types.ts | 44 ++ src/lib/server/services/team-pokemon.ts | 585 ++++++++++++++++++ src/lib/stores/team-replace-context.ts | 77 +++ src/routes/api/teams/[id]/pokemon/+server.ts | 206 +++--- 12 files changed, 1691 insertions(+), 186 deletions(-) create mode 100644 memory/plans/2026-02-01-team-variant-editing.md create mode 100644 memory/requirements/2026-02-01-team-variant-editing-requirement.md create mode 100644 memory/requirements/2026-02-02-team-replace-reset-requirement.md create mode 100644 src/components/team/ReplaceConfirmationPrompt.svelte create mode 100644 src/lib/server/services/team-pokemon.ts create mode 100644 src/lib/stores/team-replace-context.ts diff --git a/memory/plans/2026-02-01-team-variant-editing.md b/memory/plans/2026-02-01-team-variant-editing.md new file mode 100644 index 0000000..1bd667d --- /dev/null +++ b/memory/plans/2026-02-01-team-variant-editing.md @@ -0,0 +1,96 @@ +# Plan: Team Variant Pokemon Editing + +Requirement: `memory/requirements/2026-02-01-team-variant-editing-requirement.md` +Status: READY_TO_EXECUTE +Owner: Planner + +> **How to use**: Author tasks so they remain stack-agnostic. Mark placeholders like `{FRAMEWORK_CMD}` or `{API_ENDPOINT}` where the project must inject specifics. + +## Pre-flight Checklist + +- Confirm requirement assumptions resolved? `{TODO:CONFIRM_REPLACEMENT_RULES}` +- Repo/branch prepared? (`git status`, dependencies installed) +- Skills required: [ui-system, api, data] +- Tooling commands verified or tagged as `{TODO:COMMAND}` (default `npm run check` and `npm run lint`) + +## Tasks + +### Task 1: Harden Pokemon replacement API contract + +- **Goal**: Ensure `{REPLACE_ENDPOINT}` accepts slot-targeted payloads that overwrite species data while keeping server-side stats consistent. +- **Files/Areas**: `@/routes/api/teams/{TEAM_VARIANT_ID}/pokemon/+server.ts`, `@/lib/server/{TEAM_SERVICE}`, `{DB_ACCESS_LAYER}` +- **Implementation Notes**: + - Audit existing `PUT`/`POST` logic to confirm it can update `{SLOT_IDENTIFIER}` without requiring prior delete; add `{UPSERT_BEHAVIOR}` if missing. + - Validate payload schema (`{TEAM_VARIANT_ID, SLOT, POKEMON_ID, FORM}` plus optional EV/IV/move arrays) and normalize data before persisting. + - Preserve or reset ancillary fields (items, ability, nature) according to {TODO:CONFIRM_REPLACEMENT_RULES}; add TODO comment until product decides. + - Emit updated record so UI consumers (TeamBuilder loaders, `{TEAM_ANALYSIS}`) refresh without manual poll. +- **Verification**: `npm run check && npm run lint`, `curl -X PUT {REPLACE_ENDPOINT} -d '{"slot":1,"pokemonId":"{POKEMON_ID}"}'` +- **Specialization Needed**: `{DECIDE_DATA_RETENTION}`, `{API_AUTH_CONFIG}`, `{ERROR_COPY}` + +### Task 2: Track replace context within team builder state + +- **Goal**: Maintain `{REPLACE_CONTEXT}` (slot id, previous config) centrally so any entrypoint can open `{POKEMON_SEARCH_FLOW}` with the right bindings. +- **Files/Areas**: `@/components/team/{TEAM_BUILDER_STATE}`, `@/lib/stores/{TEAM_STORE}`, `{POKEMON_STATE_UTILS}` +- **Implementation Notes**: + - Extend TeamBuilder state with `$state` or store to hold `{ACTIVE_SLOT}`, `{PENDING_REPLACEMENT_DATA}`, and view mode (details vs search). + - Provide helpers/props so child components (Slots, Details, Search) can read/update the context without prop-drilling beyond established patterns. + - Ensure reverting/closing the flow clears context to avoid leaking EV/move data to other slots. +- **Verification**: `npm run check && npm run lint` +- **Specialization Needed**: `{STATE_MANAGEMENT_CONVENTION}`, `{DEFAULT_ACTIVE_SLOT}` + +### Task 3: Enhance `{POKEMON_SEARCH_FLOW}` to emit replacement events + +- **Goal**: Allow the search component to accept `{REPLACE_CONTEXT}` and emit a `replace` event carrying selected species plus preserved config. +- **Files/Areas**: `@/components/team/PokemonSearch.svelte`, `{SEARCH_EVENT_TYPES}`, `{TEAM_BUILDER_COMM_CHANNEL}` +- **Implementation Notes**: + - Add props/runes for `{ACTIVE_SLOT}` and existing loadout so suggestions show the current species and highlight changes. + - Wire confirmation CTA to call `{onReplace}` callback with both `slot` and `pokemon` payload, bypassing the delete-then-add pattern. + - Guard against selecting the same species by short-circuiting or showing neutral toast message. + - Expose new event typings so parent components integrate without TypeScript suppression. +- **Verification**: `npm run check && npm run lint`, manual QA `{TODO:QA_SEARCH_REPLACE}` +- **Specialization Needed**: `{SEARCH_FILTER_RULES}`, `{UI_TOAST_PATTERN}` + +### Task 4: Surface replace CTA inside `{POKEMON_DETAILS_PANEL}` + +- **Goal**: Provide a clearly labeled action that launches `{POKEMON_SEARCH_FLOW}` without dismissing the existing details drawer/modal. +- **Files/Areas**: `@/components/team/PokemonDetails.svelte`, `{DETAILS_ACTION_ROW}`, `{BUTTON_PRIMITIVE}` +- **Implementation Notes**: + - Insert a button or action chip honoring ui-system spacing/typography; ensure it respects accessibility (keyboard, aria-label). + - When invoked, trigger the shared state action from Task 2 rather than local state to prevent divergence between components. + - Optionally show contextual copy about EV/move retention referencing `{DECIDE_DATA_RETENTION}` once resolved. +- **Verification**: `npm run check && npm run lint`, manual QA `{TODO:QA_DETAILS_ACTION}` +- **Specialization Needed**: `{CTA_COPY}`, `{ICONography_SELECTION}` + +### Task 5: Add inline slot affordance for replacement + +- **Goal**: Let users initiate replacement straight from `PokemonSlot`/`TeamBuilder` with minimal clicks while keeping hover/touch interactions consistent. +- **Files/Areas**: `@/components/team/TeamBuilder.svelte`, `@/components/team/PokemonSlot.svelte`, `{SLOT_ACTION_MENU}` +- **Implementation Notes**: + - Define `{INLINE_REPLACE_ENTRYPOINT}` (e.g., double-click, kebab menu, long-press) matching product decision; fall back to an icon button with tooltip if undecided. + - Hook the action into Task 2's context setter so the same search flow opens pre-bound to the clicked slot. + - Maintain discoverability by updating focus order and ensuring pointer/touch cues align with existing CSS tokens. +- **Verification**: `npm run check && npm run lint`, manual QA `{TODO:QA_SLOT_INTERACTION}` across desktop/mobile breakpoints +- **Specialization Needed**: `{GESTURE_CHOICE}`, `{TOOLTIP_COPY}`, `{ACCESSIBILITY_REQUIREMENTS}` + +### Task 6: Optional `{REPLACE_CONFIRMATION_SNIPPET}` integration + +- **Goal**: Introduce an opt-in confirmation or toast pattern that warns about overwriting configured data when replacement is destructive. +- **Files/Areas**: `{REPLACE_CONFIRMATION_SNIPPET}`, `@/components/ui/{MODAL_OR_TOAST}`, `{COPY_DOC}` +- **Implementation Notes**: + - Implement snippet as a reusable snippet/component that can be invoked from both Details CTA and slot affordance, gated behind `{FEATURE_FLAG}` if necessary. + - Ensure the snippet receives `{REPLACE_CONTEXT}` to show species names/items so users know what changes. + - Provide rollback hook (e.g., undo toast action) if backend/UX requires. +- **Verification**: `npm run check && npm run lint`, manual QA `{TODO:QA_CONFIRMATION}` +- **Specialization Needed**: `{FEATURE_FLAG_NAME}`, `{CONFIRMATION_COPY}`, `{UNDO_BEHAVIOR}` + +## Dependencies & Parallelism + +- Sequential tasks: Task 1 ➔ Task 2 ➔ Task 3 ➔ Task 4 ➔ Task 5; Task 6 depends on Task 3 (events) and Task 5 (entrypoints). +- Parallel groups: Parts of Task 4 and Task 5 UI wiring can proceed after Task 2 scaffolds state contracts. +- External blockers: `{DECIDE_DATA_RETENTION}` (impacts Tasks 1, 3, 4, 6), `{GESTURE_CHOICE}` (impacts Task 5), `{FEATURE_FLAG_NAME}` (impacts Task 6). + +## Rollback / Contingency + +- Guard new UI behind `{FEATURE_FLAG}` or slot-level setting so the previous delete-and-add workflow remains available during rollout. +- If backend changes regress, revert `{REPLACE_ENDPOINT}` handlers via git and redeploy; client UI detects missing capability by checking feature toggle. +- For migrations/config (if any), use `git revert` or roll back Drizzle migration scripts, ensuring no partial replacements remain by re-running `{DB_ROLLBACK_CMD}`. diff --git a/memory/requirements/2026-02-01-team-variant-editing-requirement.md b/memory/requirements/2026-02-01-team-variant-editing-requirement.md new file mode 100644 index 0000000..cdfc328 --- /dev/null +++ b/memory/requirements/2026-02-01-team-variant-editing-requirement.md @@ -0,0 +1,71 @@ +# Requirement: Team Variant Pokemon Editing + +Date: 2026-02-01 +Status: READY_FOR_PLAN +Owner: Researcher + +> **How to use**: Keep placeholders (e.g., `{FRAMEWORK}`, `{API_NAME}`) so this document can be cloned for any repo. Replace them only when you specialize for a concrete project. + +## Problem + +- User Story: "As a team builder iterating on a `{TEAM_VARIANT}`, I need to replace a Pokemon directly in its slot so I can test ideas without destroying the existing build." +- Desired Outcome: Editing a slot feels non-destructive, preserves EV/item/move context until the user commits, and requires minimal navigation. +- Success Metric: Reduce the average interactions to swap a Pokemon from 4+ actions to ≤2 actions while keeping variant stats in sync. + +## Solution Overview + +- Key Idea: Provide an inline `{INLINE_REPLACE_ENTRYPOINT}` (e.g., contextual action on `PokemonSlot` or within `PokemonDetails`) that launches `{POKEMON_SEARCH_FLOW}` pre-bound to the current slot, allowing confirmation of a replacement without a prior delete. +- System Boundary: `{TEAM_VARIANT_EDIT_VIEW}` spanning `TeamBuilder` UI, supporting selectors (moves, nature, EV/IV) and the `/api/teams/{variantId}/pokemon` API route. +- Assumptions to Validate: Whether replacing a Pokemon should retain previously configured EVs/moves automatically, and if backend routes already accept overwriting species data via `PUT`/`POST` or require a dedicated `{REPLACE_ENDPOINT}`. + +## Scope of Changes + +Files/modules to modify (use placeholders where needed): + +- `src/components/team/TeamBuilder.svelte` — Allow clicking a filled slot to choose between "view details" and "replace" or directly open `{POKEMON_SEARCH_FLOW}` with the slot context. +- `src/components/team/PokemonSlot.svelte` — Present UI affordance (long-press, kebab, double-click) for `{INLINE_REPLACE_ENTRYPOINT}` aligned with existing hover styles. +- `src/components/team/PokemonDetails.svelte` — Surface a "Replace Pokemon" CTA that routes into `{POKEMON_SEARCH_FLOW}` without closing the details panel. +- `src/components/team/PokemonSearch.svelte` — Accept `{REPLACE_CONTEXT}` (slot id, existing build data) and emit a replacement event when a different species is confirmed. +- `src/routes/api/teams/[id]/pokemon/+server.ts` — Ensure the handler supports replacing the species in a slot (may require extending the current `PUT` contract or adding `{UPSERT_BEHAVIOR}`). + +New files/modules: + +- `{REPLACE_CONFIRMATION_SNIPPET}` — Optional confirmation dialog or toast description (only if product decides a warning is needed before destructive overrides). + +## Existing References + +- Similar pattern: Inline slot selection inside `src/components/team/TeamBuilder.svelte` already differentiates between filled vs empty slots. +- Reusable utilities/components: `PokemonSearch`, `PokemonDetails`, `PokemonSlot`, shared selectors (Moves, EV/IV, Nature, Ability) to repopulate after replacement. +- External dependencies: `{POKEMON_API}` used via `src/lib/server/pokemon-api.ts` plus `/api/pokemon/{id}` lookup for stat hydration. + +## Data Flow / Contracts + +- Inputs: User clicks an occupied slot or "Replace" control; `{POKEMON_SEARCH_FLOW}` query string updates; backend receives `{TEAM_VARIANT_ID, SLOT, POKEMON_ID, FORM}` payload. +- Processing: `TeamBuilder` stores currently selected slot and orchestrates whether to show details or search; server route updates the slot entry and persists derived metadata (types, sprites) similar to creation. +- Outputs: Updated `TeamPokemon` record, refreshed state map (`pokemonBySlot`), potential UI notification; ensure `TeamAnalysis` recalculates with new data without full page reload. +- Schema impacts: No table changes expected, but confirm if additional columns (e.g., `updatedAt`) need to capture replacements; TODO verify migrations if new fields are introduced. + +## Compliance With `AGENTS.md` + +- Standards touched: Maintain Svelte 5 runes usage, leverage existing UI primitives (`Button`, `Modal`, `PokemonSlot`), and rerun `npm run check` + `npm run lint`; manual QA should cover editing on desktop/mobile. +- Deviations: None planned; TODO if new UX requires additional dependency or global style edits beyond Tailwind tokens. + +## Edge Cases & Risks + +- User has customized EVs/moves, then replaces species → confirm whether data resets or prompts to confirm destructive overwrite. +- Backend latency or failures during replacement could leave UI showing stale Pokemon; ensure optimistic UI handles rollback. +- Accessibility: Replacement trigger must be keyboard reachable; confirm focus management when opening `{POKEMON_SEARCH_FLOW}` from an occupied slot. + +## Out of Scope + +- Broader redesign of `TeamAnalysis`, matchup calculators, or global navigation. +- Bulk replacement/swapping between variants or across different teams. +- Adding new Pokemon metadata (abilities, type charts) beyond what replacement already needs. + +## Open Questions + +- Should replacement reuse existing EV/IV/moves by default until user edits, or reset to defaults? If reuse, which values stay valid across species changes? +- Do we need a confirmation prompt when overriding a slot that has manual configuration? +- Should the slot replacement be available directly from the slot card, or only via `PokemonDetails` modal for discoverability? +- Is there a requirement to audit replacements (activity log, undo)? +- What verification steps (e.g., `npm run check`, `npm run lint`, manual regression around `TeamAnalysis`) are mandatory before release? diff --git a/memory/requirements/2026-02-02-team-replace-reset-requirement.md b/memory/requirements/2026-02-02-team-replace-reset-requirement.md new file mode 100644 index 0000000..d487d9c --- /dev/null +++ b/memory/requirements/2026-02-02-team-replace-reset-requirement.md @@ -0,0 +1,70 @@ +# Requirement: Team Replace Reset Behavior + +Date: 2026-02-02 +Status: READY_FOR_PLAN +Owner: Researcher + +> **How to use**: Keep placeholders (e.g., `{FRAMEWORK}`, `{API_NAME}`) so this document can be cloned for any repo. Replace them only when you specialize for a concrete project. + +## Problem + +- User Story: "As a team builder iterating on a `{TEAM_VARIANT}`, I need replacements to fully reset their slot so outdated EV/IV/item choices never leak into a new Pokemon." +- Desired Outcome: Clicking an occupied slot is the single entrypoint for replacement, the slot always resets to defaults, and changes only apply after I accept an explicit confirmation. +- Success Metric: `{QA_SCENARIO}` verifies every replacement reverts to `{DEFAULT_EV_IV_STATE}`/no held item/blank moves and logs that `{CONFIRMATION_MODAL}` appeared 100% of the time before state mutation. + +## Solution Overview + +- Key Idea: When `{POKEMON_SEARCH_FLOW}` confirms a new species for a slot, both the client store and `{TEAM_POKEMON_SERVICE}` discard prior stats/items/abilities/nature, show destructive-only messaging, require the user to accept `{CONFIRMATION_MODAL}`, and omit undo affordances; the only CTA to trigger replacement lives on the slot card itself. +- System Boundary: `{TEAM_BUILDER_UI}`, `{POKEMON_DETAILS_PANEL}`, `{TEAM_REPLACE_CONTEXT_STORE}`, `{TEAM_POKEMON_REPLACE_ENDPOINT}`, `{POKEMON_SEARCH_COMPONENT}`, and `{REPLACE_CONFIRMATION_COMPONENT}`. +- Assumptions to Validate: `{DEFAULT_EV_IV_STATE}` and `{DEFAULT_MOVE_SET}` definitions already exist; removing the details-panel CTA will not harm accessibility; confirmation copy can warn about irreversible resets without promising undo; manual QA will cover desktop/mobile since `{TODO:QA}` tags are being removed. + +## Scope of Changes + +Files/modules to modify (use placeholders where needed): + +- `src/lib/server/services/team-pokemon.ts` — Update the replacement path so `replacePokemonInSlot` ignores the existing record for EVs/IVs/moves/items/ability/nature and instead seeds defaults/nulls, removing the `TODO:DECIDE_DATA_RETENTION` comment. +- `src/components/team/ReplaceConfirmationPrompt.svelte` — Remove the undo action/UI, adjust copy to clearly state that replacement wipes the slot, ensure the modal blocks apply until confirmed, and drop the `TODO:DECIDE_DATA_RETENTION` and `TODO:UNDO_BEHAVIOR` markers. +- `src/components/team/PokemonSearch.svelte` — After a new species is chosen, invoke `{CONFIRMATION_MODAL}` before dispatching replacement events so the API isn’t called without consent. +- `src/components/team/PokemonDetails.svelte` — Delete the "Replace Pokemon" button, keep only the delete control aligned right, and clean up the lingering retention TODO text. +- `src/components/team/TeamBuilder.svelte` & `src/components/team/PokemonSlot.svelte` — Ensure slot-click remains the sole replacement trigger, clears `{TEAM_REPLACE_CONTEXT}` derived fields before opening `{POKEMON_SEARCH_FLOW}`, and pipes confirmation responses back into state. +- `data/peding-steps.md` or related docs — Remove stale `{TODO:QA*}` reminders now that QA scope is defined in this requirement. + +New files/modules: + +- None; reuse existing primitives and stores. + +## Existing References + +- Similar pattern: `src/lib/stores/team-replace-context.ts` centralizes slot context and should be leveraged to clear `pendingReplacement` data. +- Reusable utilities/components: `DEFAULT_EVS`/`DEFAULT_IVS` helpers, `mergeStatBlocks`, `PokemonSearch`, `PokemonDetails`, and `TeamBuilder` slots. +- External dependencies: `{POKEMON_API}` for sprite/type data and `{TEAM_POKEMON_REPLACE_ENDPOINT}` for persistence. + +## Data Flow / Contracts + +- Inputs: User clicks an occupied slot in `{TEAM_BUILDER_UI}`, selects a new species via `{POKEMON_SEARCH_FLOW}`, and sees `{CONFIRMATION_MODAL}` that must be accepted before continuing. +- Processing: `{TEAM_REPLACE_CONTEXT_STORE}` clears cached EV/IV/move/item state before calling `{REPLACE_ENDPOINT}`; server handlers persist a sanitized record with default spreads/null inventory once confirmation resolves. +- Outputs: Updated slot data broadcast to the builder and downstream consumers (`{TEAM_ANALYSIS_PANEL}`, `{TEAM_SUMMARY}`) without any undo token; telemetry indicates confirmation acceptance. +- Schema impacts: None expected; ensure existing columns accept null/default values and annotate docs with `TODO` placeholders only if database changes become necessary. + +## Compliance With `AGENTS.md` + +- Standards touched: Preserve Svelte 5 runes style, continue to use shared `Button`/`Icon` primitives, and rerun `npm run check` + `npm run lint` plus manual desktop/mobile QA of the replace flow. +- Deviations: None planned; confirm no additional dependencies or context providers are added. + +## Edge Cases & Risks + +- Replacing with the same species must still reset spreads/items to defaults after confirmation to avoid subtle retention bugs. +- Existing teams with customized spreads lose data immediately upon replace; ensure confirmation copy communicates this irreversible change. +- Clearing undo functionality removes safety nets; verify slot replacements cannot partially apply if the network call fails after confirmation. + +## Out of Scope + +- Introducing new replace entrypoints (e.g., additional buttons, gestures) beyond the slot click. +- Implementing undo/rollback infrastructure or history tracking for replacements. +- Enhancing other panels (`{TEAM_ANALYSIS}`, matchup charts) beyond reflecting the already updated slot state. + +## Open Questions + +- Should `{DEFAULT_EV_IV_STATE}` be zeroed or mirror competitive defaults (e.g., 0 IVs in Attack for trick room) when resetting? +- What copy should `{CONFIRMATION_MODAL}` show now that undo is gone—do we mention `{MANUAL_QA_EXPECTATION}` or highlight loss of moves explicitly? +- Do we need telemetry or automated tests to prove no retained data, or is manual QA sufficient per release? diff --git a/src/components/team/PokemonDetails.svelte b/src/components/team/PokemonDetails.svelte index 7c4ed47..876771d 100644 --- a/src/components/team/PokemonDetails.svelte +++ b/src/components/team/PokemonDetails.svelte @@ -14,6 +14,7 @@ import Button from '@/components/ui/Button.svelte'; import TypeBadge from '@/components/ui/TypeBadge.svelte'; import Icon from '@/components/ui/Icon.svelte'; + import { useTeamReplaceContext } from '@/lib/stores/team-replace-context'; import type { Stat } from '@/lib/pokemon/stat'; let { pokemon, onClose, onRemove, onUpdate } = $props<{ @@ -23,6 +24,8 @@ onUpdate: (pokemon: TeamPokemon) => void; }>(); + const replaceContext = useTeamReplaceContext(); + interface PokemonData { stats: Array<{ base_stat: number; stat: { name: string } }>; abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; @@ -169,6 +172,19 @@ }); }; + const handleReplaceClick = () => { + replaceContext.open({ + slot: pokemon.slot, + previousPokemon: pokemon, + mode: 'search' + }); + }; + + const handleCloseClick = () => { + replaceContext.clear(); + onClose(); + }; + const handleNatureChange = (natureValue: string | null) => { onUpdate({ ...pokemon, @@ -227,7 +243,13 @@ - @@ -416,10 +438,34 @@ {/if} {/if} -
- +
+
+ + +
+

+ TODO:DECIDE_DATA_RETENTION – Confirm whether EVs, moves, and held items persist after opening + the replace flow from this panel. +

diff --git a/src/components/team/PokemonSearch.svelte b/src/components/team/PokemonSearch.svelte index 20f176f..c3524a9 100644 --- a/src/components/team/PokemonSearch.svelte +++ b/src/components/team/PokemonSearch.svelte @@ -1,14 +1,36 @@ @@ -139,24 +351,25 @@ {#if selectedPokemon} {/if} -

- {selectedPokemon ? 'Confirm Selection' : 'Add Pokemon'} -

+
+

+ {modalTitle} +

+ {#if activeSlot} +

Slot {activeSlot}

+ {/if} +
{/each} @@ -304,10 +586,16 @@ size="lg" fullWidth className="capitalize" + disabled={!canConfirm} onclick={handleConfirm} > - Add {selectedPokemon.name.replace(/-/g, ' ')} + {confirmLabel} + {#if isSameSelection} +

+ Choose a different species or form to replace this slot. +

+ {/if} {/if} diff --git a/src/components/team/PokemonSlot.svelte b/src/components/team/PokemonSlot.svelte index 22e35ab..f73bc1c 100644 --- a/src/components/team/PokemonSlot.svelte +++ b/src/components/team/PokemonSlot.svelte @@ -4,7 +4,7 @@ import { isMega } from '@/lib/pokemon/gimmick'; import TypeBadge from '@/components/ui/TypeBadge.svelte'; - let { + const { slot, pokemon = undefined, isSelected, @@ -16,6 +16,10 @@ onClick: () => void; }>(); + const pokemonLabel = $derived(pokemon ? pokemon.pokemonName.replace(/-/g, ' ') : null); + const actionLabel = $derived( + pokemonLabel ? `Replace ${pokemonLabel} in slot ${slot}` : `Add a Pokemon to slot ${slot}` + ); const typeColor = $derived( pokemon ? TYPE_BACKGROUND_COLORS[pokemon.primaryType as keyof typeof TYPE_BACKGROUND_COLORS] @@ -32,7 +36,8 @@ + {:else} +

+ TODO:UNDO_BEHAVIOR – Expose rollback action once backend support lands. +

+ {/if} + + {#snippet actions()} +
+ + +
+ {/snippet} + diff --git a/src/components/team/TeamBuilder.svelte b/src/components/team/TeamBuilder.svelte index f8a3d02..c4ad87a 100644 --- a/src/components/team/TeamBuilder.svelte +++ b/src/components/team/TeamBuilder.svelte @@ -3,7 +3,13 @@ import PokemonSearch from '@/components/team/PokemonSearch.svelte'; import PokemonDetails from '@/components/team/PokemonDetails.svelte'; import TeamAnalysis from '@/components/team/TeamAnalysis.svelte'; - import type { TeamPokemon } from '@/components/team/types'; + import ReplaceConfirmationPrompt from '@/components/team/ReplaceConfirmationPrompt.svelte'; + import type { TeamPokemon, PokemonReplaceEventPayload } from '@/components/team/types'; + import { + provideTeamReplaceContext, + type ReplaceContextState, + type ReplaceViewMode + } from '@/lib/stores/team-replace-context'; import Icon from '@/components/ui/Icon.svelte'; import Button from '@/components/ui/Button.svelte'; import Input from '@/components/ui/Input.svelte'; @@ -13,72 +19,148 @@ const TEAM_SLOTS = [1, 2, 3, 4, 5, 6]; - interface PokemonData { - id: number; - name: string; - types: Array<{ type: { name: string } }>; - sprites: { - other: { - 'official-artwork': { front_default: string }; - }; - }; - } + const props = $props<{ + teamName: string; + variantId: string; + variantName: string; + initialPokemon: TeamPokemon[]; + showReplaceConfirmation?: boolean; + }>(); + const teamName = $derived(props.teamName); + const variantId = $derived(props.variantId); + const getVariantName = () => props.variantName; + const getInitialPokemon = () => props.initialPokemon; + const showReplaceConfirmation = $derived(props.showReplaceConfirmation); - let { teamName, variantId, variantName, initialPokemon } = $props(); - - let pokemon = $state(initialPokemon); - let selectedSlot = $state(null); + let pokemon = $state(getInitialPokemon()); let selectedPokemon = $state(null); - let isSearchOpen = $state(false); - let name = $state(variantName); + let name = $state(getVariantName()); let isEditingName = $state(false); let nameInput = $state(null); + const replaceContext = provideTeamReplaceContext(); + let replaceState = $state({ + activeSlot: null, + pendingReplacement: null, + viewMode: 'details', + isSearchOpen: false + }); + let isReplaceConfirmationVisible = $state(false); + let queuedReplaceView = $state(null); + const isSearchOpen = $derived(replaceState.isSearchOpen); + const shouldRenderReplaceConfirmation = $derived( + Boolean(showReplaceConfirmation) && + isReplaceConfirmationVisible && + Boolean(replaceState.activeSlot && replaceState.pendingReplacement) + ); + + const baseOpen = replaceContext.open; + replaceContext.open = (payload) => { + const desiredMode: ReplaceViewMode = payload.mode ?? 'details'; + const shouldConfirm = + Boolean(showReplaceConfirmation) && + desiredMode === 'search' && + Boolean(payload.previousPokemon); + if (shouldConfirm) { + queuedReplaceView = desiredMode; + baseOpen({ + ...payload, + mode: 'details' + }); + isReplaceConfirmationVisible = true; + return; + } + queuedReplaceView = null; + baseOpen({ + ...payload, + mode: desiredMode + }); + }; + + $effect(() => { + const unsubscribe = replaceContext.subscribe((state) => { + replaceState = state; + }); + return () => unsubscribe(); + }); const pokemonBySlot = $derived( new Map(pokemon.map((member) => [member.slot, member])) ); + const openReplaceFlow = ( + slot: number, + previousPokemon: TeamPokemon | null, + mode: ReplaceViewMode + ) => { + replaceContext.open({ + slot, + previousPokemon, + mode + }); + }; + const handleSlotClick = (slot: number) => { const existingPokemon = pokemonBySlot.get(slot); if (existingPokemon) { selectedPokemon = existingPokemon; - } else { - selectedSlot = slot; - isSearchOpen = true; + openReplaceFlow(slot, existingPokemon, 'search'); + return; } + selectedPokemon = null; + openReplaceFlow(slot, null, 'search'); }; - const handlePokemonSelect = async (pokemonData: PokemonData, formName?: string | null) => { - if (selectedSlot === null) return; - - const newPokemon: Omit = { - teamVariantId: variantId, - slot: selectedSlot, - pokemonId: pokemonData.id, - pokemonName: pokemonData.name, - formName: formName || null, - primaryType: pokemonData.types[0].type.name, - secondaryType: pokemonData.types[1]?.type.name || null, - spriteUrl: pokemonData.sprites.other['official-artwork'].front_default - }; - + const executePokemonReplace = async (event: PokemonReplaceEventPayload) => { + const { payload } = event; try { const response = await fetch(`/api/teams/${variantId}/pokemon`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newPokemon) + body: JSON.stringify(payload) }); - if (response.ok) { - const saved = await response.json(); - pokemon = [...pokemon.filter((p) => p.slot !== selectedSlot), saved]; + const saved: TeamPokemon = await response.json(); + pokemon = [...pokemon.filter((p) => p.slot !== saved.slot), saved]; + if (selectedPokemon?.slot === saved.slot) { + selectedPokemon = saved; + } } } catch (error) { - console.error('Error adding Pokemon:', error); + console.error('Error replacing Pokemon:', error); } + handleSearchClose(); + }; + + const handlePokemonReplace = (event: PokemonReplaceEventPayload) => { + void executePokemonReplace(event); + }; - isSearchOpen = false; - selectedSlot = null; + const handleConfirmationConfirm = () => { + if (!isReplaceConfirmationVisible) return; + isReplaceConfirmationVisible = false; + const nextMode = queuedReplaceView ?? 'search'; + queuedReplaceView = null; + replaceContext.setViewMode(nextMode); + }; + + const handleConfirmationCancel = () => { + if (!isReplaceConfirmationVisible) return; + isReplaceConfirmationVisible = false; + queuedReplaceView = null; + replaceContext.clear(); + }; + + const handleDetailsClose = () => { + selectedPokemon = null; + replaceContext.clear(); + isReplaceConfirmationVisible = false; + queuedReplaceView = null; + }; + + const handleSearchClose = () => { + isReplaceConfirmationVisible = false; + queuedReplaceView = null; + replaceContext.clear(); }; const handleRemovePokemon = async (slot: number) => { @@ -91,6 +173,9 @@ pokemon = pokemon.filter((p) => p.slot !== slot); selectedPokemon = null; + replaceContext.clear(); + isReplaceConfirmationVisible = false; + queuedReplaceView = null; } catch (error) { console.error('Error removing Pokemon:', error); } @@ -129,7 +214,7 @@ handleNameSave(); } else if (event.key === 'Escape') { isEditingName = false; - name = variantName; + name = getVariantName(); } }; @@ -226,7 +311,7 @@ className="h-10 w-10 shrink-0" onclick={() => { isEditingName = false; - name = variantName; + name = getVariantName(); }} title="Cancel" > @@ -280,7 +365,7 @@ {#if selectedPokemon} (selectedPokemon = null)} + onClose={handleDetailsClose} onRemove={() => handleRemovePokemon(selectedPokemon?.slot ?? 0)} onUpdate={handleUpdatePokemon} /> @@ -291,12 +376,16 @@ {/if} {#if isSearchOpen} - { - isSearchOpen = false; - selectedSlot = null; - }} + + {/if} + + {#if shouldRenderReplaceConfirmation} + {/if} diff --git a/src/components/team/types.ts b/src/components/team/types.ts index 3ae1186..6f49065 100644 --- a/src/components/team/types.ts +++ b/src/components/team/types.ts @@ -39,3 +39,47 @@ export interface TeamPokemon { move4Name?: string | null; move4Type?: string | null; } + +export type PokemonStatKey = 'hp' | 'atk' | 'def' | 'spA' | 'spD' | 'spe'; + +export type PokemonStatSpread = Partial>; + +export type PokemonMoveSlot = { + id: number | null; + name: string | null; + type: string | null; +} | null; + +export interface PokemonReplacementPayload { + slot: number; + pokemonId: number; + formName: string | null; + ability?: string | null; + nature?: string | null; + teraType?: string | null; + evs?: PokemonStatSpread; + ivs?: PokemonStatSpread; + moves?: PokemonMoveSlot[]; + heldItem?: { + id: number | null; + name: string | null; + sprite: string | null; + } | null; +} + +export interface ReplacementCandidatePreview { + id: number; + name: string; + formName: string | null; + spriteUrl: string; + primaryType: string; + secondaryType?: string | null; +} + +export interface PokemonReplaceEventPayload { + payload: PokemonReplacementPayload; + previousPokemon: TeamPokemon | null; + candidatePokemon: ReplacementCandidatePreview; +} + +export type PokemonReplaceEvent = CustomEvent; diff --git a/src/lib/server/services/team-pokemon.ts b/src/lib/server/services/team-pokemon.ts new file mode 100644 index 0000000..631638f --- /dev/null +++ b/src/lib/server/services/team-pokemon.ts @@ -0,0 +1,585 @@ +import { and, eq } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { db } from '@/lib/server/db'; +import { getPokemon } from '@/lib/server/pokemon-api'; +import { team, teamPokemon, teamVariant } from '@/lib/server/schema'; + +type TeamRow = typeof team.$inferSelect; +type TeamVariantRow = typeof teamVariant.$inferSelect; +type TeamPokemonRow = typeof teamPokemon.$inferSelect; +type TeamPokemonInsert = typeof teamPokemon.$inferInsert; + +const TEAM_SLOT_MIN = 1; +const TEAM_SLOT_MAX = 6; +const STAT_KEYS = ['hp', 'atk', 'def', 'spA', 'spD', 'spe'] as const; +type StatKey = (typeof STAT_KEYS)[number]; +type StatBlock = Record; +type PartialStatBlock = Partial; + +type NormalizedMoveSlot = { + id: number | null; + name: string | null; + type: string | null; +} | null; + +interface HeldItemPayload { + id: number | null; + name: string | null; + sprite: string | null; +} + +export class TeamPokemonValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'TeamPokemonValidationError'; + } +} + +export interface ReplacementSlotPayload { + slot: number; + pokemonId: number; + formName?: string | null; + evs?: PartialStatBlock; + ivs?: PartialStatBlock; + moves?: NormalizedMoveSlot[]; + ability?: string | null; + nature?: string | null; + teraType?: string | null; + heldItem?: HeldItemPayload | null; +} + +export interface ReplacementPayloadNormalized extends ReplacementSlotPayload { + targetVariantId?: string; +} + +export interface MetadataUpdatePayload { + id: string; + slot: number; + updates: Partial; +} + +const DEFAULT_EVS: StatBlock = { + hp: 0, + atk: 0, + def: 0, + spA: 0, + spD: 0, + spe: 0 +}; + +const DEFAULT_IVS: StatBlock = { + hp: 31, + atk: 31, + def: 31, + spA: 31, + spD: 31, + spe: 31 +}; + +const NUMBER_EDIT_FIELDS = [ + 'heldItemId', + 'ivHp', + 'ivAtk', + 'ivDef', + 'ivSpA', + 'ivSpD', + 'ivSpe', + 'evHp', + 'evAtk', + 'evDef', + 'evSpA', + 'evSpD', + 'evSpe', + 'move1Id', + 'move2Id', + 'move3Id', + 'move4Id' +] satisfies Array; + +const STRING_EDIT_FIELDS = [ + 'teraType', + 'nature', + 'ability', + 'heldItemName', + 'heldItemSprite', + 'move1Name', + 'move1Type', + 'move2Name', + 'move2Type', + 'move3Name', + 'move3Type', + 'move4Name', + 'move4Type' +] satisfies Array; + +export async function getVariantWithTeam(variantId: string): Promise<{ + variant: TeamVariantRow | null; + team: TeamRow | null; +}> { + const variant = + (await db.select().from(teamVariant).where(eq(teamVariant.id, variantId)).get()) ?? null; + const teamRow = variant + ? ((await db.select().from(team).where(eq(team.id, variant.teamId)).get()) ?? null) + : null; + return { variant, team: teamRow }; +} + +export function normalizeReplacementPayload(payload: unknown): ReplacementPayloadNormalized { + if (!payload || typeof payload !== 'object') { + throw new TeamPokemonValidationError('Invalid payload body.'); + } + const raw = payload as Record; + const targetVariantId = parseOptionalVariantId(raw.teamVariantId ?? raw.variantId); + const slot = parseSlot(raw.slot); + const pokemonId = parsePokemonId(raw.pokemonId); + const formName = parseOptionalString(raw.form ?? raw.formName, 'form'); + const evs = normalizeStatBlock(raw.evs, 'ev'); + const ivs = normalizeStatBlock(raw.ivs, 'iv'); + const moves = normalizeMoves(raw.moves); + const ability = parseOptionalString(raw.ability, 'ability'); + const nature = parseOptionalString(raw.nature, 'nature'); + const teraType = parseOptionalString(raw.teraType, 'teraType'); + const heldItem = normalizeHeldItem(raw); + + return { + targetVariantId, + slot, + pokemonId, + formName, + evs, + ivs, + moves, + ability, + nature, + teraType, + heldItem + }; +} + +export function normalizeMetadataUpdatePayload(payload: unknown): MetadataUpdatePayload { + if (!payload || typeof payload !== 'object') { + throw new TeamPokemonValidationError('Invalid update payload.'); + } + const raw = payload as Record; + const idValue = typeof raw.id === 'string' ? raw.id.trim() : ''; + if (!idValue) { + throw new TeamPokemonValidationError('Pokemon id is required for updates.'); + } + const slot = parseSlot(raw.slot); + const updates = extractEditableFields(raw); + if (Object.keys(updates).length === 0) { + throw new TeamPokemonValidationError('No editable fields were provided.'); + } + return { id: idValue, slot, updates }; +} + +export async function replacePokemonSlot({ + variantId, + teamId, + payload +}: { + variantId: string; + teamId: string; + payload: ReplacementSlotPayload; +}): Promise<{ pokemon: TeamPokemonRow; wasNew: boolean }> { + const existing = await db + .select() + .from(teamPokemon) + .where(and(eq(teamPokemon.teamVariantId, variantId), eq(teamPokemon.slot, payload.slot))) + .get(); + + const pokemonData = await getPokemon(payload.pokemonId); + if (!pokemonData) { + throw new TeamPokemonValidationError('Pokemon not found.'); + } + + const primaryType = pokemonData.types?.[0]?.type?.name; + if (!primaryType) { + throw new TeamPokemonValidationError('Pokemon types are missing.'); + } + const secondaryType = pokemonData.types?.[1]?.type?.name ?? null; + const spriteUrl = + pokemonData.sprites?.other?.['official-artwork']?.front_default ?? + pokemonData.sprites?.front_default ?? + null; + if (!spriteUrl) { + throw new TeamPokemonValidationError('Pokemon sprite is unavailable.'); + } + + const resolvedFormName = + payload.formName !== undefined ? payload.formName : inferFormName(pokemonData.name); + const evSource = existing ? extractStatBlock(existing, 'ev') : DEFAULT_EVS; + const ivSource = existing ? extractStatBlock(existing, 'iv') : DEFAULT_IVS; + const mergedEvs = mergeStatBlocks(payload.evs, evSource); + const mergedIvs = mergeStatBlocks(payload.ivs, ivSource); + const mergedMoves = mergeMoves(payload.moves, existing ?? null); + const resolvedHeldItem = mergeHeldItem(payload.heldItem, existing ?? null); + const resolvedAbility = + payload.ability !== undefined ? payload.ability : (existing?.ability ?? null); + const resolvedNature = payload.nature !== undefined ? payload.nature : (existing?.nature ?? null); + const resolvedTeraType = + payload.teraType !== undefined ? payload.teraType : (existing?.teraType ?? null); + + // TODO:DECIDE_DATA_RETENTION confirm ability/nature/item retention on species replacements. + const nextRecord: Omit = { + teamVariantId: variantId, + slot: payload.slot, + pokemonId: pokemonData.id, + pokemonName: pokemonData.name, + formName: resolvedFormName, + primaryType, + secondaryType, + teraType: resolvedTeraType, + spriteUrl, + nature: resolvedNature, + ability: resolvedAbility, + heldItemId: resolvedHeldItem?.id ?? null, + heldItemName: resolvedHeldItem?.name ?? null, + heldItemSprite: resolvedHeldItem?.sprite ?? null, + ivHp: mergedIvs.hp, + ivAtk: mergedIvs.atk, + ivDef: mergedIvs.def, + ivSpA: mergedIvs.spA, + ivSpD: mergedIvs.spD, + ivSpe: mergedIvs.spe, + evHp: mergedEvs.hp, + evAtk: mergedEvs.atk, + evDef: mergedEvs.def, + evSpA: mergedEvs.spA, + evSpD: mergedEvs.spD, + evSpe: mergedEvs.spe, + ...convertMovesToRecord(mergedMoves) + }; + + const insertRecord: TeamPokemonInsert = { + id: existing?.id ?? nanoid(), + ...nextRecord + }; + + if (existing) { + await db.update(teamPokemon).set(nextRecord).where(eq(teamPokemon.id, existing.id)); + } else { + await db.insert(teamPokemon).values(insertRecord); + } + + await touchTeamTimestamps(teamId, variantId); + const response: TeamPokemonRow = existing + ? { ...existing, ...nextRecord } + : (insertRecord as TeamPokemonRow); + return { pokemon: response, wasNew: !existing }; +} + +export async function updatePokemonMetadata({ + variantId, + teamId, + update +}: { + variantId: string; + teamId: string; + update: MetadataUpdatePayload; +}): Promise { + const existing = await db + .select() + .from(teamPokemon) + .where( + and( + eq(teamPokemon.teamVariantId, variantId), + eq(teamPokemon.slot, update.slot), + eq(teamPokemon.id, update.id) + ) + ) + .get(); + + if (!existing) { + throw new TeamPokemonValidationError('Pokemon slot not found.'); + } + + await db.update(teamPokemon).set(update.updates).where(eq(teamPokemon.id, existing.id)); + await touchTeamTimestamps(teamId, variantId); + return { ...existing, ...update.updates }; +} + +export async function deletePokemonSlot({ + variantId, + teamId, + slot +}: { + variantId: string; + teamId: string; + slot: number; +}): Promise { + const existing = await db + .select() + .from(teamPokemon) + .where(and(eq(teamPokemon.teamVariantId, variantId), eq(teamPokemon.slot, slot))) + .get(); + + if (!existing) { + throw new TeamPokemonValidationError('Pokemon slot not found.'); + } + + await db.delete(teamPokemon).where(eq(teamPokemon.id, existing.id)); + await touchTeamTimestamps(teamId, variantId); +} + +function parseSlot(value: unknown): number { + const slot = Number(value); + if (!Number.isInteger(slot) || slot < TEAM_SLOT_MIN || slot > TEAM_SLOT_MAX) { + throw new TeamPokemonValidationError('Slot must be between 1 and 6.'); + } + return slot; +} + +function parsePokemonId(value: unknown): number { + const id = Number(value); + if (!Number.isInteger(id) || id <= 0) { + throw new TeamPokemonValidationError('pokemonId must be a positive integer.'); + } + return id; +} + +function parseOptionalString(value: unknown, field: string): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value !== 'string') { + throw new TeamPokemonValidationError(`Invalid value for ${field}.`); + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function parseOptionalNumber(value: unknown, field: string): number | null { + if (value === undefined || value === null) return null; + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + throw new TeamPokemonValidationError(`Invalid value for ${field}.`); + } + return numeric; +} + +function normalizeStatBlock(value: unknown, kind: 'ev' | 'iv'): PartialStatBlock | undefined { + if (value === undefined || value === null) return undefined; + const limits = kind === 'ev' ? { min: 0, max: 252 } : { min: 0, max: 31 }; + const resolved: PartialStatBlock = {}; + + const assignValue = (key: StatKey, raw: unknown) => { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + const clamped = clamp(Math.round(parsed), limits.min, limits.max); + resolved[key] = clamped; + }; + + if (Array.isArray(value)) { + value + .slice(0, STAT_KEYS.length) + .forEach((entry, index) => assignValue(STAT_KEYS[index], entry)); + } else if (typeof value === 'object') { + for (const key of STAT_KEYS) { + const candidate = (value as Record)[key]; + if (candidate !== undefined) assignValue(key, candidate); + } + } else { + throw new TeamPokemonValidationError(`Invalid ${kind.toUpperCase()} payload.`); + } + + return Object.keys(resolved).length ? resolved : undefined; +} + +function normalizeMoves(value: unknown): NormalizedMoveSlot[] | undefined { + if (value === undefined || value === null) return undefined; + if (!Array.isArray(value)) { + throw new TeamPokemonValidationError('moves must be an array.'); + } + const normalized: NormalizedMoveSlot[] = []; + for (let index = 0; index < Math.min(4, value.length); index += 1) { + const entry = value[index]; + if (entry === null) { + normalized.push({ id: null, name: null, type: null }); + continue; + } + if (!entry || typeof entry !== 'object') { + normalized.push(null); + continue; + } + const record = entry as Record; + const id = record.id ?? record.moveId; + const name = record.name ?? record.moveName; + const type = record.type ?? record.moveType; + const parsedId = id === undefined ? null : parseOptionalNumber(id, 'move id'); + const parsedName = parseOptionalString(name, 'move name') ?? null; + const parsedType = parseOptionalString(type, 'move type') ?? null; + normalized.push({ id: parsedId, name: parsedName, type: parsedType }); + } + while (normalized.length < 4) { + normalized.push(null); + } + return normalized.some((move) => move !== null) ? normalized : undefined; +} + +function normalizeHeldItem(raw: Record): HeldItemPayload | null | undefined { + if ( + !('heldItem' in raw || 'heldItemId' in raw || 'heldItemName' in raw || 'heldItemSprite' in raw) + ) { + return undefined; + } + const source = + raw.heldItem && typeof raw.heldItem === 'object' && raw.heldItem !== null + ? (raw.heldItem as Record) + : raw; + if ('heldItem' in raw && raw.heldItem === null) { + return null; + } + const id = parseOptionalNumber(source.id ?? raw.heldItemId, 'heldItemId'); + const nameValue = parseOptionalString(source.name ?? raw.heldItemName, 'heldItemName'); + const spriteValue = parseOptionalString(source.sprite ?? raw.heldItemSprite, 'heldItemSprite'); + const normalizedName = nameValue ?? null; + const normalizedSprite = spriteValue ?? null; + if (id === null && normalizedName === null && normalizedSprite === null) { + return null; + } + return { id, name: normalizedName, sprite: normalizedSprite }; +} + +function parseOptionalVariantId(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (value === null) { + throw new TeamPokemonValidationError('teamVariantId cannot be null.'); + } + if (typeof value !== 'string') { + throw new TeamPokemonValidationError('Invalid teamVariantId value.'); + } + const trimmed = value.trim(); + if (!trimmed) { + throw new TeamPokemonValidationError('teamVariantId cannot be empty.'); + } + return trimmed; +} + +function inferFormName(pokemonName: string): string | null { + const dashIndex = pokemonName.indexOf('-'); + return dashIndex === -1 ? null : pokemonName.slice(dashIndex + 1); +} + +function extractStatBlock(row: TeamPokemonRow, kind: 'ev' | 'iv'): StatBlock { + if (kind === 'ev') { + return { + hp: row.evHp ?? 0, + atk: row.evAtk ?? 0, + def: row.evDef ?? 0, + spA: row.evSpA ?? 0, + spD: row.evSpD ?? 0, + spe: row.evSpe ?? 0 + }; + } + return { + hp: row.ivHp ?? 31, + atk: row.ivAtk ?? 31, + def: row.ivDef ?? 31, + spA: row.ivSpA ?? 31, + spD: row.ivSpD ?? 31, + spe: row.ivSpe ?? 31 + }; +} + +function mergeStatBlocks(input: PartialStatBlock | undefined, base: StatBlock): StatBlock { + return STAT_KEYS.reduce( + (acc, key) => { + acc[key] = input?.[key] ?? base[key]; + return acc; + }, + { ...base } + ); +} + +function mergeMoves( + moves: NormalizedMoveSlot[] | undefined, + existing: TeamPokemonRow | null +): NormalizedMoveSlot[] { + const base = existing ? extractMoves(existing) : [null, null, null, null]; + if (!moves) { + return base; + } + return base.map((current, index) => { + if (moves[index] === undefined) { + return current; + } + return moves[index]; + }); +} + +function extractMoves(row: TeamPokemonRow): NormalizedMoveSlot[] { + return [ + convertMove(row.move1Id, row.move1Name, row.move1Type), + convertMove(row.move2Id, row.move2Name, row.move2Type), + convertMove(row.move3Id, row.move3Name, row.move3Type), + convertMove(row.move4Id, row.move4Name, row.move4Type) + ]; +} + +function convertMove( + id: number | null, + name: string | null, + type: string | null +): NormalizedMoveSlot { + if (id === null && name === null && type === null) { + return null; + } + return { id, name, type }; +} + +function convertMovesToRecord(moves: NormalizedMoveSlot[]): Partial { + const [move1, move2, move3, move4] = moves; + return { + move1Id: move1?.id ?? null, + move1Name: move1?.name ?? null, + move1Type: move1?.type ?? null, + move2Id: move2?.id ?? null, + move2Name: move2?.name ?? null, + move2Type: move2?.type ?? null, + move3Id: move3?.id ?? null, + move3Name: move3?.name ?? null, + move3Type: move3?.type ?? null, + move4Id: move4?.id ?? null, + move4Name: move4?.name ?? null, + move4Type: move4?.type ?? null + }; +} + +function mergeHeldItem( + incoming: HeldItemPayload | null | undefined, + existing: TeamPokemonRow | null +): HeldItemPayload | null { + if (incoming === undefined) { + return existing + ? { + id: existing.heldItemId ?? null, + name: existing.heldItemName ?? null, + sprite: existing.heldItemSprite ?? null + } + : null; + } + return incoming; +} + +function extractEditableFields(raw: Record): Partial { + const updates: Partial = {}; + for (const key of NUMBER_EDIT_FIELDS) { + if (!(key in raw)) continue; + updates[key] = parseOptionalNumber(raw[key], key) as TeamPokemonRow[typeof key]; + } + for (const key of STRING_EDIT_FIELDS) { + if (!(key in raw)) continue; + updates[key] = (parseOptionalString(raw[key], key) ?? null) as TeamPokemonRow[typeof key]; + } + return updates; +} + +async function touchTeamTimestamps(teamId: string, variantId: string): Promise { + const now = new Date(); + await db.update(teamVariant).set({ updatedAt: now }).where(eq(teamVariant.id, variantId)); + await db.update(team).set({ updatedAt: now }).where(eq(team.id, teamId)); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/lib/stores/team-replace-context.ts b/src/lib/stores/team-replace-context.ts new file mode 100644 index 0000000..c48cab8 --- /dev/null +++ b/src/lib/stores/team-replace-context.ts @@ -0,0 +1,77 @@ +import { getContext, setContext } from 'svelte'; +import { writable, type Readable } from 'svelte/store'; + +import type { TeamPokemon } from '@/components/team/types'; + +export type ReplaceViewMode = 'details' | 'search'; + +export interface ReplaceContextState { + activeSlot: number | null; + pendingReplacement: TeamPokemon | null; + viewMode: ReplaceViewMode; + isSearchOpen: boolean; +} + +export interface ReplaceContextPayload { + slot: number; + previousPokemon: TeamPokemon | null; + mode?: ReplaceViewMode; +} + +export type TeamReplaceContextStore = Readable & { + open: (payload: ReplaceContextPayload) => void; + setViewMode: (mode: ReplaceViewMode) => void; + clear: () => void; +}; + +const baseState: ReplaceContextState = { + activeSlot: null, + pendingReplacement: null, + viewMode: 'details', + isSearchOpen: false +}; + +export const createTeamReplaceContextStore = ( + initialState?: Partial +): TeamReplaceContextStore => { + const initial = { ...baseState, ...initialState } satisfies ReplaceContextState; + const { subscribe, set, update } = writable(initial); + + return { + subscribe, + open: ({ slot, previousPokemon, mode = 'details' }) => { + set({ + activeSlot: slot, + pendingReplacement: previousPokemon, + viewMode: mode, + isSearchOpen: mode === 'search' + }); + }, + setViewMode: (mode) => { + update((state) => ({ + ...state, + viewMode: mode, + isSearchOpen: mode === 'search' + })); + }, + clear: () => { + set({ ...initial }); + } + }; +}; + +const TEAM_REPLACE_CONTEXT_KEY = Symbol('TEAM_REPLACE_CONTEXT'); + +export const provideTeamReplaceContext = (initialState?: Partial) => { + const store = createTeamReplaceContextStore(initialState); + setContext(TEAM_REPLACE_CONTEXT_KEY, store); + return store; +}; + +export const useTeamReplaceContext = () => { + const context = getContext(TEAM_REPLACE_CONTEXT_KEY); + if (!context) { + throw new Error('Team replace context is not available'); + } + return context; +}; diff --git a/src/routes/api/teams/[id]/pokemon/+server.ts b/src/routes/api/teams/[id]/pokemon/+server.ts index 68d8b27..6fef0fd 100644 --- a/src/routes/api/teams/[id]/pokemon/+server.ts +++ b/src/routes/api/teams/[id]/pokemon/+server.ts @@ -1,132 +1,136 @@ import type { RequestHandler } from './$types'; -import { json } from '@sveltejs/kit'; -import { and, eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; +import type { Cookies } from '@sveltejs/kit'; +import { error, json } from '@sveltejs/kit'; import { getCurrentUser } from '@/lib/server/auth'; -import { db } from '@/lib/server/db'; -import { team, teamPokemon, teamVariant } from '@/lib/server/schema'; +import { + deletePokemonSlot, + getVariantWithTeam, + normalizeMetadataUpdatePayload, + normalizeReplacementPayload, + replacePokemonSlot, + TeamPokemonValidationError, + updatePokemonMetadata +} from '@/lib/server/services/team-pokemon'; export const POST: RequestHandler = async ({ params, request, cookies }) => { - const { id: variantId } = params; - const pokemonData = await request.json(); - - if (!variantId) { - return json({ error: 'Missing team variant id.' }, { status: 400 }); - } - - const user = await getCurrentUser(cookies); - if (!user) { - return json({ error: 'Authentication required.' }, { status: 401 }); - } - - const variant = await db.select().from(teamVariant).where(eq(teamVariant.id, variantId)).get(); - - if (!variant) { - return json({ error: 'Team variant not found.' }, { status: 404 }); - } - - const teamRow = await db.select().from(team).where(eq(team.id, variant.teamId)).get(); - - if (!teamRow) { - return json({ error: 'Team not found.' }, { status: 404 }); - } - - if (teamRow.userId !== user.id) { - return json({ error: 'Forbidden.' }, { status: 403 }); + const context = await requireVariantContext(params.id, cookies); + const body = await request.json(); + + try { + const payload = normalizeReplacementPayload(body); + const { targetVariantId, ...slotPayload } = payload; + assertVariantTarget(targetVariantId, context.variant.id); + const result = await replacePokemonSlot({ + variantId: context.variant.id, + teamId: context.team.id, + payload: slotPayload + }); + return json(result.pokemon, { status: result.wasNew ? 201 : 200 }); + } catch (err) { + handleServiceError(err); } - - const id = nanoid(); - - await db.insert(teamPokemon).values({ - id, - ...pokemonData, - teamVariantId: variantId - }); - - await db.update(teamVariant).set({ updatedAt: new Date() }).where(eq(teamVariant.id, variantId)); - - await db.update(team).set({ updatedAt: new Date() }).where(eq(team.id, variant.teamId)); - - return json({ id, ...pokemonData }, { status: 201 }); }; export const PUT: RequestHandler = async ({ params, request, cookies }) => { - const { id: variantId } = params; - const { slot, ...updates } = await request.json(); - - if (!variantId) { - return json({ error: 'Missing team variant id.' }, { status: 400 }); + const context = await requireVariantContext(params.id, cookies); + const body = await request.json(); + + try { + if (body && typeof body.id === 'string') { + const update = normalizeMetadataUpdatePayload(body); + const updated = await updatePokemonMetadata({ + variantId: context.variant.id, + teamId: context.team.id, + update + }); + return json(updated); + } + + const payload = normalizeReplacementPayload(body); + const { targetVariantId, ...slotPayload } = payload; + assertVariantTarget(targetVariantId, context.variant.id); + const result = await replacePokemonSlot({ + variantId: context.variant.id, + teamId: context.team.id, + payload: slotPayload + }); + return json(result.pokemon); + } catch (err) { + handleServiceError(err); } +}; - const user = await getCurrentUser(cookies); - if (!user) { - return json({ error: 'Authentication required.' }, { status: 401 }); +export const DELETE: RequestHandler = async ({ params, request, cookies }) => { + const context = await requireVariantContext(params.id, cookies); + let payload: unknown; + try { + payload = await request.json(); + } catch { + payload = null; } - const variant = await db.select().from(teamVariant).where(eq(teamVariant.id, variantId)).get(); - - if (!variant) { - return json({ error: 'Team variant not found.' }, { status: 404 }); + try { + const slot = parseSlotFromPayload(payload); + await deletePokemonSlot({ + variantId: context.variant.id, + teamId: context.team.id, + slot + }); + return json({ success: true }); + } catch (err) { + handleServiceError(err); } +}; - const teamRow = await db.select().from(team).where(eq(team.id, variant.teamId)).get(); - - if (!teamRow) { - return json({ error: 'Team not found.' }, { status: 404 }); +function handleServiceError(err: unknown): never { + if (err instanceof TeamPokemonValidationError) { + throw error(400, err.message); } + throw err; +} - if (teamRow.userId !== user.id) { - return json({ error: 'Forbidden.' }, { status: 403 }); +function assertVariantTarget(targetVariantId: string | undefined, expectedVariantId: string): void { + if (targetVariantId === undefined) return; + if (targetVariantId !== expectedVariantId) { + throw error(409, 'teamVariantId mismatch for replacement payload.'); } +} - await db - .update(teamPokemon) - .set(updates) - .where(and(eq(teamPokemon.teamVariantId, variantId), eq(teamPokemon.slot, slot))); - - await db.update(teamVariant).set({ updatedAt: new Date() }).where(eq(teamVariant.id, variantId)); - - await db.update(team).set({ updatedAt: new Date() }).where(eq(team.id, variant.teamId)); - - return json({ success: true }); -}; - -export const DELETE: RequestHandler = async ({ params, request, cookies }) => { - const { id: variantId } = params; - const { slot } = await request.json(); - +async function requireVariantContext(variantId: string | undefined, cookies: Cookies) { if (!variantId) { - return json({ error: 'Missing team variant id.' }, { status: 400 }); + throw error(400, 'Missing team variant id.'); } const user = await getCurrentUser(cookies); if (!user) { - return json({ error: 'Authentication required.' }, { status: 401 }); + throw error(401, 'Authentication required.'); } - const variant = await db.select().from(teamVariant).where(eq(teamVariant.id, variantId)).get(); - + const { variant, team } = await getVariantWithTeam(variantId); if (!variant) { - return json({ error: 'Team variant not found.' }, { status: 404 }); + throw error(404, 'Team variant not found.'); } - - const teamRow = await db.select().from(team).where(eq(team.id, variant.teamId)).get(); - - if (!teamRow) { - return json({ error: 'Team not found.' }, { status: 404 }); + if (!team) { + throw error(404, 'Team not found.'); } - - if (teamRow.userId !== user.id) { - return json({ error: 'Forbidden.' }, { status: 403 }); + if (team.userId !== user.id) { + throw error(403, 'Forbidden.'); } - await db - .delete(teamPokemon) - .where(and(eq(teamPokemon.teamVariantId, variantId), eq(teamPokemon.slot, slot))); - - await db.update(teamVariant).set({ updatedAt: new Date() }).where(eq(teamVariant.id, variantId)); + return { variant, team }; +} - await db.update(team).set({ updatedAt: new Date() }).where(eq(team.id, variant.teamId)); - - return json({ success: true }); -}; +function parseSlotFromPayload(payload: unknown): number { + if (!payload || typeof payload !== 'object') { + throw new TeamPokemonValidationError('Slot payload is required.'); + } + const slotValue = (payload as Record).slot; + if (slotValue === undefined) { + throw new TeamPokemonValidationError('Slot is required.'); + } + const slot = Number(slotValue); + if (!Number.isInteger(slot) || slot < 1 || slot > 6) { + throw new TeamPokemonValidationError('Slot must be between 1 and 6.'); + } + return slot; +} From 5f96a60a1fab6387d4e8dad23e1f81ef7779bd17 Mon Sep 17 00:00:00 2001 From: charmingruby Date: Mon, 2 Feb 2026 10:04:20 -0300 Subject: [PATCH 2/4] feat: plan generated --- .../2026-02-02-team-replace-reset-plan.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 memory/plans/2026-02-02-team-replace-reset-plan.md diff --git a/memory/plans/2026-02-02-team-replace-reset-plan.md b/memory/plans/2026-02-02-team-replace-reset-plan.md new file mode 100644 index 0000000..52523ce --- /dev/null +++ b/memory/plans/2026-02-02-team-replace-reset-plan.md @@ -0,0 +1,84 @@ +# Plan: Team Replace Reset Behavior + +Requirement: `memory/requirements/2026-02-02-team-replace-reset-requirement.md` +Status: READY_TO_EXECUTE +Owner: Planner + +> **How to use**: Author tasks so they remain stack-agnostic. Mark placeholders like `{FRAMEWORK_CMD}` or `{API_ENDPOINT}` where the project must inject specifics. + +## Pre-flight Checklist + +- Confirm requirement assumptions resolved? +- Repo/branch prepared? (`git status`, dependencies installed) +- Skills required: [ui-system] +- Tooling commands verified or tagged as `{TODO:COMMAND}` (default `npm run check` and `npm run lint`) + +## Tasks + +### Task 1: Normalize server-side replacement defaults + +- **Goal**: Ensure `{TEAM_POKEMON_SERVICE}` always writes `{DEFAULT_EV_IV_STATE}`, `{DEFAULT_MOVE_SET}`, null held item, and default ability/nature when `{TEAM_POKEMON_REPLACE_ENDPOINT}` runs, regardless of prior slot contents. +- **Touchpoints**: `@/lib/server/services/team-pokemon.ts`, `{TEAM_POKEMON_REPLACE_ENDPOINT}`, `{DEFAULT_STATS_HELPERS}`. +- **Implementation Notes**: Update `replacePokemonInSlot` to ignore the existing record when assembling payloads, pull default helpers (`DEFAULT_EVS`, `DEFAULT_IVS`, `{DEFAULT_MOVE_SET}`), remove `TODO:DECIDE_DATA_RETENTION`, and ensure the service response matches what the client expects after a reset. +- **Verification**: `npm run check && npm run lint`, plus manual API call through `{TEAM_REPLACE_CONTEXT}` to confirm new slots lack retained spreads/items. +- **Inputs**: Clarify `{DEFAULT_EV_IV_STATE}` and `{DEFAULT_MOVE_SET}` values if not already defined, confirm whether ability/nature defaults come from `{TEAM_VARIANT}` or global constants. + +### Task 2: Reset client-side replacement context on slot select + +- **Goal**: When a user clicks an occupied slot in `{TEAM_BUILDER_UI}`, `{TEAM_REPLACE_CONTEXT_STORE}` clears pending EV/IV/move/item data before any search modal opens. +- **Touchpoints**: `@/lib/stores/team-replace-context.ts`, `@/components/team/PokemonSlot.svelte` (store wiring only). +- **Implementation Notes**: Reuse existing helpers referenced in `src/lib/stores/team-replace-context.ts`, drop `pendingReplacement` residue, clear derived fields, and keep TODO placeholders that must stay unspecialized. +- **Verification**: `npm run check && npm run lint`, plus manual store inspection (click slot → verify context snapshot resets to defaults in devtools/logs). +- **Inputs**: Need slot identifier shape (`{TEAM_SLOT_ID}`) and default context blueprint if not codified. + +### Task 3: Make slot click the sole replacement entrypoint + +- **Goal**: Restrict replacement initiation to slot cards, ensuring `{POKEMON_SEARCH_FLOW}` launches from the slot click, clears context via Task 2, and routes confirmation decisions back into builder state. +- **Touchpoints**: `@/components/team/TeamBuilder.svelte`, `@/components/team/PokemonSlot.svelte`, `{TEAM_REPLACE_CONTEXT}` consumers. +- **Implementation Notes**: Wire slot `onclick` to open `{POKEMON_SEARCH_COMPONENT}`, remove alternative CTAs, ensure accepted confirmations pipe `slotId` + species into the store and loader, and keep delete controls unaffected. +- **Verification**: `npm run check && npm run lint`, manual QA (click different slots, confirm no other UI triggers replacement dialogs). +- **Inputs**: Determine event names/bus (`{TEAM_EVENT_BUS}`) and modal open method used for `{POKEMON_SEARCH_FLOW}`. + +### Task 4: Gate `PokemonSearch` replacement through confirmation + +- **Goal**: After a species gets chosen in `{POKEMON_SEARCH_COMPONENT}`, invoke `{CONFIRMATION_MODAL}` and block dispatching replacement events until the user accepts. +- **Touchpoints**: `@/components/team/PokemonSearch.svelte`, `{CONFIRMATION_MODAL}`, `{POKEMON_SEARCH_FLOW}` handlers. +- **Implementation Notes**: Insert a confirmation step before emitting `onReplace`, ensure modal resolves with explicit approve/cancel results, and maintain existing search UX while preventing API calls without consent. +- **Verification**: `npm run check && npm run lint`, manual QA (select species → ensure modal appears every time and cancellation leaves slot untouched). +- **Inputs**: Copy/strings for the modal, telemetry hooks for `{QA_SCENARIO}` logging, and any analytic IDs that need to fire on confirmation. + +### Task 5: Update ReplaceConfirmationPrompt to destructive-only messaging + +- **Goal**: Adjust `{REPLACE_CONFIRMATION_COMPONENT}` to remove undo affordances, clearly state irreversible resets, and block apply actions until confirmed. +- **Touchpoints**: `@/components/team/ReplaceConfirmationPrompt.svelte`, shared modal primitives, copy dictionaries (`{I18N_NAMESPACE}`). +- **Implementation Notes**: Remove undo buttons/UI, rewrite body text to mention EV/IV/item/move loss, keep modal focus trap until users confirm, and delete `TODO:DECIDE_DATA_RETENTION` plus `TODO:UNDO_BEHAVIOR` markers. +- **Verification**: `npm run check && npm run lint`, manual QA verifying modal focus + acceptance workflow. +- **Inputs**: Finalized destructive copy, localization keys, and accessibility guidance for warning tone. + +### Task 6: Simplify PokemonDetails controls + +- **Goal**: Remove the "Replace Pokemon" button from `{POKEMON_DETAILS_PANEL}`, leaving delete aligned right and expunging retention TODO copy. +- **Touchpoints**: `@/components/team/PokemonDetails.svelte`, shared button primitives. +- **Implementation Notes**: Delete the replace CTA, ensure layout spacing stays balanced, confirm delete button styling remains consistent, and drop any `TODO:DECIDE_DATA_RETENTION` notes. +- **Verification**: `npm run check && npm run lint`, manual QA (open details panel → confirm only delete control appears, no layout regressions on mobile). +- **Inputs**: Guidance on responsive alignment tokens or Tailwind classes for the remaining delete action. + +### Task 7: Clean up documentation TODOs about QA scope + +- **Goal**: Remove outdated `{TODO:QA*}` reminders now superseded by this requirement while keeping other placeholders untouched. +- **Touchpoints**: `data/peding-steps.md` (or equivalent doc noted in scope) and any linked release checklists. +- **Implementation Notes**: Locate `{TODO:QA*}` markers, replace them with references to `{QA_SCENARIO}` or delete if redundant, ensuring the doc matches the new manual QA expectations. +- **Verification**: `npm run check && npm run lint` (if docs included in lint), otherwise manual proofread. +- **Inputs**: Confirmation of the exact doc path (`data/peding-steps.md` vs `{DOC_PLACEHOLDER}`) and any stakeholders needing acknowledgment. + +## Dependencies & Parallelism + +- Sequential tasks: Task 2 → Task 3 → Task 4 → Task 5; Task 3 → Task 6; Task 1 → Task 4 (server defaults must exist before UI dispatches); Task 7 can run after primary UX tasks finalize copy references. +- Parallel groups: Task 1 can run alongside Task 2 once defaults/inputs are defined; Task 5 and Task 6 are mostly UI-copy changes but should await Task 3's slot-ownership plan before final polish. +- External blockers: Await decisions on `{DEFAULT_EV_IV_STATE}`, modal copy, and telemetry hooks before finalizing Tasks 1, 4, and 5. + +## Rollback / Contingency + +- Keep the work on a dedicated branch so `git revert {COMMIT_HASH}` can back out if replacement resets cause regressions. +- If available, guard the new flow behind `{TEAM_REPLACE_RESET_FLAG}` or equivalent feature toggle; disable the flag to restore prior mixed-behavior UI without redeploying. +- No schema changes expected; if `{TEAM_POKEMON_SERVICE}` introduces migrations later, prepare `npm run db:migrate -- --down` scripts or backups before rolling back. From ee0f6344bc9439ed21e8d404246edd289ce72865 Mon Sep 17 00:00:00 2001 From: charmingruby Date: Mon, 2 Feb 2026 12:22:39 -0300 Subject: [PATCH 3/4] feat: team replacement feature --- src/components/team/PokemonDetails.svelte | 27 +--- src/components/team/PokemonSearch.svelte | 150 ++++++------------ .../team/ReplaceConfirmationPrompt.svelte | 55 ++----- src/components/team/TeamBuilder.svelte | 70 +------- src/components/team/types.ts | 16 +- src/lib/server/services/team-pokemon.ts | 96 ++--------- src/lib/stores/team-replace-context.ts | 20 ++- 7 files changed, 119 insertions(+), 315 deletions(-) diff --git a/src/components/team/PokemonDetails.svelte b/src/components/team/PokemonDetails.svelte index 876771d..6b014fd 100644 --- a/src/components/team/PokemonDetails.svelte +++ b/src/components/team/PokemonDetails.svelte @@ -172,14 +172,6 @@ }); }; - const handleReplaceClick = () => { - replaceContext.open({ - slot: pokemon.slot, - previousPokemon: pokemon, - mode: 'search' - }); - }; - const handleCloseClick = () => { replaceContext.clear(); onClose(); @@ -438,19 +430,8 @@ {/if} {/if} -
-
- +
+
-

- TODO:DECIDE_DATA_RETENTION – Confirm whether EVs, moves, and held items persist after opening - the replace flow from this panel. -

diff --git a/src/components/team/PokemonSearch.svelte b/src/components/team/PokemonSearch.svelte index c3524a9..83adacf 100644 --- a/src/components/team/PokemonSearch.svelte +++ b/src/components/team/PokemonSearch.svelte @@ -5,6 +5,7 @@ import Icon from '@/components/ui/Icon.svelte'; import Input from '@/components/ui/Input.svelte'; import Button from '@/components/ui/Button.svelte'; + import ReplaceConfirmationPrompt from '@/components/team/ReplaceConfirmationPrompt.svelte'; import { cx } from '@/lib/cn'; import { type ReplaceContextState, @@ -13,9 +14,7 @@ import type { PokemonReplaceEventPayload, PokemonReplacementPayload, - PokemonStatSpread, - PokemonMoveSlot, - TeamPokemon, + PokemonSlotPreview, ReplacementCandidatePreview } from '@/components/team/types'; @@ -28,7 +27,7 @@ onReplace?: (payload: PokemonReplaceEventPayload) => void; onClose: () => void; slot?: number | null; - pendingPokemon?: TeamPokemon | null; + pendingPokemon?: PokemonSlotPreview | null; }>(); const dispatch = createEventDispatcher<{ replace: PokemonReplaceEventPayload }>(); @@ -75,6 +74,10 @@ }); let hasPrefilledQuery = $state(false); + // Confirmation state for replacement flow + let pendingConfirmation = $state<{ pokemon: PokemonData; form: PokemonForm | null } | null>(null); + let showConfirmation = $state(false); + const replaceContext = useTeamReplaceContext(); const activeSlot = $derived(slotProp ?? replaceState.activeSlot); const pendingReplacement = $derived(pendingReplacementProp ?? replaceState.pendingReplacement); @@ -216,22 +219,47 @@ const handleConfirm = () => { if (!selectedPokemon) return; - if (isSameSelection) { - // TODO:QA_SEARCH_REPLACE surface toast once UX copy is finalized. + if (isSameSelection) return; + + // If replacing (not adding to empty slot), require confirmation first + if (pendingReplacement) { + pendingConfirmation = { pokemon: selectedPokemon, form: selectedForm }; + showConfirmation = true; return; } - const payload = buildReplacementPayload(selectedPokemon, selectedForm); + + // For empty slots, proceed directly without confirmation + executeReplacement(selectedPokemon, selectedForm); + }; + + const executeReplacement = (pokemon: PokemonData, form: PokemonForm | null) => { + const payload = buildReplacementPayload(pokemon, form); if (!payload) return; onReplace?.(payload); dispatch('replace', payload); replaceContext.clear(); }; + const handleConfirmationAccept = () => { + if (!pendingConfirmation) return; + executeReplacement(pendingConfirmation.pokemon, pendingConfirmation.form); + pendingConfirmation = null; + showConfirmation = false; + }; + + const handleConfirmationCancel = () => { + pendingConfirmation = null; + showConfirmation = false; + // Stay in search - user can select a different species + }; + const buildReplacementPayload = ( pokemon: PokemonData, form: PokemonForm | null ): PokemonReplaceEventPayload | null => { if (activeSlot === null) return null; + + // Build minimal replacement payload - no EVs/IVs/moves/items copied from previous const payload: PokemonReplacementPayload = { slot: activeSlot, pokemonId: pokemon.id, @@ -247,99 +275,12 @@ secondaryType: pokemon.types[1]?.type.name ?? null }; - if (pendingReplacement) { - const pending = pendingReplacement; - payload.ability = pending.ability ?? null; - payload.nature = pending.nature ?? null; - payload.teraType = pending.teraType ?? null; - payload.evs = convertStatSpread(pending, 'ev'); - payload.ivs = convertStatSpread(pending, 'iv'); - const moves = convertMoves(pending); - if (moves) { - payload.moves = moves; - } - const heldItem = convertHeldItem(pending); - if (heldItem !== undefined) { - payload.heldItem = heldItem; - } - } - return { payload, previousPokemon: pendingReplacement, candidatePokemon: candidatePreview }; }; - - const convertStatSpread = (pokemon: TeamPokemon, kind: 'ev' | 'iv'): PokemonStatSpread => { - if (kind === 'ev') { - return { - hp: pokemon.evHp ?? 0, - atk: pokemon.evAtk ?? 0, - def: pokemon.evDef ?? 0, - spA: pokemon.evSpA ?? 0, - spD: pokemon.evSpD ?? 0, - spe: pokemon.evSpe ?? 0 - }; - } - return { - hp: pokemon.ivHp ?? 31, - atk: pokemon.ivAtk ?? 31, - def: pokemon.ivDef ?? 31, - spA: pokemon.ivSpA ?? 31, - spD: pokemon.ivSpD ?? 31, - spe: pokemon.ivSpe ?? 31 - }; - }; - - const convertMoves = (pokemon: TeamPokemon): PokemonMoveSlot[] | undefined => { - const moves: PokemonMoveSlot[] = [ - convertMove(pokemon.move1Id, pokemon.move1Name, pokemon.move1Type), - convertMove(pokemon.move2Id, pokemon.move2Name, pokemon.move2Type), - convertMove(pokemon.move3Id, pokemon.move3Name, pokemon.move3Type), - convertMove(pokemon.move4Id, pokemon.move4Name, pokemon.move4Type) - ]; - return moves.some((move) => move !== null) ? moves : undefined; - }; - - const convertMove = ( - id: number | null | undefined, - name: string | null | undefined, - type: string | null | undefined - ): PokemonMoveSlot => { - if (id == null && name == null && type == null) { - return null; - } - return { - id: id ?? null, - name: name ?? null, - type: type ?? null - }; - }; - - const convertHeldItem = ( - pokemon: TeamPokemon - ): PokemonReplacementPayload['heldItem'] | undefined => { - if ( - pokemon.heldItemId === undefined && - pokemon.heldItemName === undefined && - pokemon.heldItemSprite === undefined - ) { - return undefined; - } - if ( - pokemon.heldItemId == null && - pokemon.heldItemName == null && - pokemon.heldItemSprite == null - ) { - return null; - } - return { - id: pokemon.heldItemId ?? null, - name: pokemon.heldItemName ?? null, - sprite: pokemon.heldItemSprite ?? null - }; - };
@@ -377,13 +318,16 @@
-
- -
+ {#if !selectedPokemon} +
+ +
+ {/if} {#if pendingReplacement} {@const currentPokemon = pendingReplacement} -
+ +
@@ -600,3 +544,13 @@ {/if}
+ +{#if showConfirmation && pendingReplacement} + +{/if} diff --git a/src/components/team/ReplaceConfirmationPrompt.svelte b/src/components/team/ReplaceConfirmationPrompt.svelte index 0620289..0178756 100644 --- a/src/components/team/ReplaceConfirmationPrompt.svelte +++ b/src/components/team/ReplaceConfirmationPrompt.svelte @@ -3,22 +3,20 @@ import Button from '@/components/ui/Button.svelte'; import TypeBadge from '@/components/ui/TypeBadge.svelte'; import Icon from '@/components/ui/Icon.svelte'; - import type { TeamPokemon } from '@/components/team/types'; + import type { PokemonSlotPreview } from '@/components/team/types'; const { open = false, slot, previousPokemon, onConfirm, - onCancel, - onUndo = undefined + onCancel } = $props<{ open?: boolean; slot: number; - previousPokemon: TeamPokemon | null; + previousPokemon: PokemonSlotPreview | null; onConfirm?: () => void; onCancel?: () => void; - onUndo?: () => void; }>(); const pokemonLabel = $derived( @@ -27,9 +25,6 @@ const pokemonFormLabel = $derived( previousPokemon?.formName ? previousPokemon.formName.replace(/-/g, ' ') : null ); - const heldItemLabel = $derived( - previousPokemon?.heldItemName ? previousPokemon.heldItemName.replace(/-/g, ' ') : 'None' - ); const handleConfirm = () => { onConfirm?.(); @@ -38,23 +33,23 @@ const handleCancel = () => { onCancel?.(); }; - - const handleUndo = () => { - onUndo?.(); - };
-
-
- - Overwriting this slot will modify its configured build. +
+
+ + This action will reset the slot's build
-

- TODO:DECIDE_DATA_RETENTION – Clarify what EVs, moves, and items persist before enabling this - permanently. -

+
    +
  • • EVs will be reset to 0
  • +
  • • IVs will be reset to 31
  • +
  • • All moves will be cleared
  • +
  • • Held item will be removed
  • +
  • • Ability and nature will be cleared
  • +
+

This cannot be undone.

@@ -91,24 +86,8 @@ {/if}
-

Held item: {heldItemLabel}

+
- - {#if onUndo} - - {:else} -

- TODO:UNDO_BEHAVIOR – Expose rollback action once backend support lands. -

- {/if}
{#snippet actions()}
@@ -122,7 +101,7 @@ Keep current
{/snippet} diff --git a/src/components/team/TeamBuilder.svelte b/src/components/team/TeamBuilder.svelte index c4ad87a..7cd1907 100644 --- a/src/components/team/TeamBuilder.svelte +++ b/src/components/team/TeamBuilder.svelte @@ -3,7 +3,6 @@ import PokemonSearch from '@/components/team/PokemonSearch.svelte'; import PokemonDetails from '@/components/team/PokemonDetails.svelte'; import TeamAnalysis from '@/components/team/TeamAnalysis.svelte'; - import ReplaceConfirmationPrompt from '@/components/team/ReplaceConfirmationPrompt.svelte'; import type { TeamPokemon, PokemonReplaceEventPayload } from '@/components/team/types'; import { provideTeamReplaceContext, @@ -24,13 +23,11 @@ variantId: string; variantName: string; initialPokemon: TeamPokemon[]; - showReplaceConfirmation?: boolean; }>(); const teamName = $derived(props.teamName); const variantId = $derived(props.variantId); const getVariantName = () => props.variantName; const getInitialPokemon = () => props.initialPokemon; - const showReplaceConfirmation = $derived(props.showReplaceConfirmation); let pokemon = $state(getInitialPokemon()); let selectedPokemon = $state(null); @@ -44,37 +41,7 @@ viewMode: 'details', isSearchOpen: false }); - let isReplaceConfirmationVisible = $state(false); - let queuedReplaceView = $state(null); const isSearchOpen = $derived(replaceState.isSearchOpen); - const shouldRenderReplaceConfirmation = $derived( - Boolean(showReplaceConfirmation) && - isReplaceConfirmationVisible && - Boolean(replaceState.activeSlot && replaceState.pendingReplacement) - ); - - const baseOpen = replaceContext.open; - replaceContext.open = (payload) => { - const desiredMode: ReplaceViewMode = payload.mode ?? 'details'; - const shouldConfirm = - Boolean(showReplaceConfirmation) && - desiredMode === 'search' && - Boolean(payload.previousPokemon); - if (shouldConfirm) { - queuedReplaceView = desiredMode; - baseOpen({ - ...payload, - mode: 'details' - }); - isReplaceConfirmationVisible = true; - return; - } - queuedReplaceView = null; - baseOpen({ - ...payload, - mode: desiredMode - }); - }; $effect(() => { const unsubscribe = replaceContext.subscribe((state) => { @@ -99,6 +66,12 @@ }); }; + /** + * Sole entry point for initiating Pokemon replacement or addition. + * Clicking a slot card is the only way to open the PokemonSearch flow. + * This handler clears any stale context data (EVs/IVs/moves/items) via + * replaceContext.open() before launching the search modal. + */ const handleSlotClick = (slot: number) => { const existingPokemon = pokemonBySlot.get(slot); if (existingPokemon) { @@ -135,31 +108,12 @@ void executePokemonReplace(event); }; - const handleConfirmationConfirm = () => { - if (!isReplaceConfirmationVisible) return; - isReplaceConfirmationVisible = false; - const nextMode = queuedReplaceView ?? 'search'; - queuedReplaceView = null; - replaceContext.setViewMode(nextMode); - }; - - const handleConfirmationCancel = () => { - if (!isReplaceConfirmationVisible) return; - isReplaceConfirmationVisible = false; - queuedReplaceView = null; - replaceContext.clear(); - }; - const handleDetailsClose = () => { selectedPokemon = null; replaceContext.clear(); - isReplaceConfirmationVisible = false; - queuedReplaceView = null; }; const handleSearchClose = () => { - isReplaceConfirmationVisible = false; - queuedReplaceView = null; replaceContext.clear(); }; @@ -174,8 +128,6 @@ pokemon = pokemon.filter((p) => p.slot !== slot); selectedPokemon = null; replaceContext.clear(); - isReplaceConfirmationVisible = false; - queuedReplaceView = null; } catch (error) { console.error('Error removing Pokemon:', error); } @@ -378,14 +330,4 @@ {#if isSearchOpen} {/if} - - {#if shouldRenderReplaceConfirmation} - - {/if}
diff --git a/src/components/team/types.ts b/src/components/team/types.ts index 6f49065..895c3d7 100644 --- a/src/components/team/types.ts +++ b/src/components/team/types.ts @@ -76,9 +76,23 @@ export interface ReplacementCandidatePreview { secondaryType?: string | null; } +/** + * Minimal pokemon preview for slot display during replacement. + * Intentionally excludes EVs, IVs, moves, items, ability, and nature + * so that replacement flows start with a clean slate. + */ +export interface PokemonSlotPreview { + pokemonId: number; + pokemonName: string; + formName: string | null; + spriteUrl: string; + primaryType: string; + secondaryType: string | null; +} + export interface PokemonReplaceEventPayload { payload: PokemonReplacementPayload; - previousPokemon: TeamPokemon | null; + previousPokemon: PokemonSlotPreview | null; candidatePokemon: ReplacementCandidatePreview; } diff --git a/src/lib/server/services/team-pokemon.ts b/src/lib/server/services/team-pokemon.ts index 631638f..e37125d 100644 --- a/src/lib/server/services/team-pokemon.ts +++ b/src/lib/server/services/team-pokemon.ts @@ -208,19 +208,16 @@ export async function replacePokemonSlot({ const resolvedFormName = payload.formName !== undefined ? payload.formName : inferFormName(pokemonData.name); - const evSource = existing ? extractStatBlock(existing, 'ev') : DEFAULT_EVS; - const ivSource = existing ? extractStatBlock(existing, 'iv') : DEFAULT_IVS; - const mergedEvs = mergeStatBlocks(payload.evs, evSource); - const mergedIvs = mergeStatBlocks(payload.ivs, ivSource); - const mergedMoves = mergeMoves(payload.moves, existing ?? null); - const resolvedHeldItem = mergeHeldItem(payload.heldItem, existing ?? null); - const resolvedAbility = - payload.ability !== undefined ? payload.ability : (existing?.ability ?? null); - const resolvedNature = payload.nature !== undefined ? payload.nature : (existing?.nature ?? null); - const resolvedTeraType = - payload.teraType !== undefined ? payload.teraType : (existing?.teraType ?? null); - - // TODO:DECIDE_DATA_RETENTION confirm ability/nature/item retention on species replacements. + + // Always reset to defaults when replacing a Pokemon - never retain existing values + const mergedEvs = mergeStatBlocks(payload.evs, DEFAULT_EVS); + const mergedIvs = mergeStatBlocks(payload.ivs, DEFAULT_IVS); + const mergedMoves: NormalizedMoveSlot[] = payload.moves ?? [null, null, null, null]; + const resolvedHeldItem = payload.heldItem ?? null; + const resolvedAbility = payload.ability ?? null; + const resolvedNature = payload.nature ?? null; + const resolvedTeraType = payload.teraType ?? null; + const nextRecord: Omit = { teamVariantId: variantId, slot: payload.slot, @@ -460,27 +457,6 @@ function inferFormName(pokemonName: string): string | null { return dashIndex === -1 ? null : pokemonName.slice(dashIndex + 1); } -function extractStatBlock(row: TeamPokemonRow, kind: 'ev' | 'iv'): StatBlock { - if (kind === 'ev') { - return { - hp: row.evHp ?? 0, - atk: row.evAtk ?? 0, - def: row.evDef ?? 0, - spA: row.evSpA ?? 0, - spD: row.evSpD ?? 0, - spe: row.evSpe ?? 0 - }; - } - return { - hp: row.ivHp ?? 31, - atk: row.ivAtk ?? 31, - def: row.ivDef ?? 31, - spA: row.ivSpA ?? 31, - spD: row.ivSpD ?? 31, - spe: row.ivSpe ?? 31 - }; -} - function mergeStatBlocks(input: PartialStatBlock | undefined, base: StatBlock): StatBlock { return STAT_KEYS.reduce( (acc, key) => { @@ -491,42 +467,6 @@ function mergeStatBlocks(input: PartialStatBlock | undefined, base: StatBlock): ); } -function mergeMoves( - moves: NormalizedMoveSlot[] | undefined, - existing: TeamPokemonRow | null -): NormalizedMoveSlot[] { - const base = existing ? extractMoves(existing) : [null, null, null, null]; - if (!moves) { - return base; - } - return base.map((current, index) => { - if (moves[index] === undefined) { - return current; - } - return moves[index]; - }); -} - -function extractMoves(row: TeamPokemonRow): NormalizedMoveSlot[] { - return [ - convertMove(row.move1Id, row.move1Name, row.move1Type), - convertMove(row.move2Id, row.move2Name, row.move2Type), - convertMove(row.move3Id, row.move3Name, row.move3Type), - convertMove(row.move4Id, row.move4Name, row.move4Type) - ]; -} - -function convertMove( - id: number | null, - name: string | null, - type: string | null -): NormalizedMoveSlot { - if (id === null && name === null && type === null) { - return null; - } - return { id, name, type }; -} - function convertMovesToRecord(moves: NormalizedMoveSlot[]): Partial { const [move1, move2, move3, move4] = moves; return { @@ -545,22 +485,6 @@ function convertMovesToRecord(moves: NormalizedMoveSlot[]): Partial): Partial { const updates: Partial = {}; for (const key of NUMBER_EDIT_FIELDS) { diff --git a/src/lib/stores/team-replace-context.ts b/src/lib/stores/team-replace-context.ts index c48cab8..2cc474a 100644 --- a/src/lib/stores/team-replace-context.ts +++ b/src/lib/stores/team-replace-context.ts @@ -1,13 +1,16 @@ import { getContext, setContext } from 'svelte'; import { writable, type Readable } from 'svelte/store'; -import type { TeamPokemon } from '@/components/team/types'; +import type { TeamPokemon, PokemonSlotPreview } from '@/components/team/types'; export type ReplaceViewMode = 'details' | 'search'; +// Re-export for convenience +export type { PokemonSlotPreview } from '@/components/team/types'; + export interface ReplaceContextState { activeSlot: number | null; - pendingReplacement: TeamPokemon | null; + pendingReplacement: PokemonSlotPreview | null; viewMode: ReplaceViewMode; isSearchOpen: boolean; } @@ -40,9 +43,20 @@ export const createTeamReplaceContextStore = ( return { subscribe, open: ({ slot, previousPokemon, mode = 'details' }) => { + // Extract only display-relevant data, stripping EVs/IVs/moves/items + const preview: PokemonSlotPreview | null = previousPokemon + ? { + pokemonId: previousPokemon.pokemonId, + pokemonName: previousPokemon.pokemonName, + formName: previousPokemon.formName ?? null, + spriteUrl: previousPokemon.spriteUrl, + primaryType: previousPokemon.primaryType, + secondaryType: previousPokemon.secondaryType ?? null + } + : null; set({ activeSlot: slot, - pendingReplacement: previousPokemon, + pendingReplacement: preview, viewMode: mode, isSearchOpen: mode === 'search' }); From c6819648c3b47db431f77789a4b6193c87c11efa Mon Sep 17 00:00:00 2001 From: charmingruby Date: Mon, 2 Feb 2026 12:22:53 -0300 Subject: [PATCH 4/4] feat: improves rpi --- .opencode/agents/executor.md | 69 ++++++--------- .opencode/agents/implementer.md | 50 ++++++----- .opencode/agents/planner.md | 41 +++++---- .opencode/agents/researcher.md | 35 +++++--- .opencode/agents/reviewer.md | 87 +++++++------------ .opencode/agents/wrench.md | 37 ++++++++ .opencode/commands/.gitkeep | 1 - .opencode/commands/quality-gate.md | 10 +++ .../skills/svelte-kit-conventions/SKILL.md | 41 +++++++++ 9 files changed, 220 insertions(+), 151 deletions(-) create mode 100644 .opencode/agents/wrench.md delete mode 100644 .opencode/commands/.gitkeep create mode 100644 .opencode/commands/quality-gate.md create mode 100644 .opencode/skills/svelte-kit-conventions/SKILL.md diff --git a/.opencode/agents/executor.md b/.opencode/agents/executor.md index 2b08dd2..91fb687 100644 --- a/.opencode/agents/executor.md +++ b/.opencode/agents/executor.md @@ -1,5 +1,5 @@ --- -description: Orchestrate implementation workflow by coordinating Implementer and Reviewer cycles +description: Orchestrate Implementer/Reviewer cycles to execute a plan mode: primary tools: write: false @@ -9,55 +9,42 @@ tools: ## Role -Orchestrate the Implementation workflow by coordinating Implementer and Reviewer agents while honoring plan dependencies defined for this SvelteKit repo. +Orchestrate execution of a plan by coordinating Implementer and Reviewer subagents. -## Skill Dependencies +## Inputs -- ui-system (load before running any `.svelte` task) +- Plan file path: {PLAN_PATH} +- Task selector: {TASK_SELECTOR} (e.g., "all", "1..N", "1,3,7") +- Optional parallelism limit: {PARALLEL_LIMIT} -## Process +## Contract -For each task in the plan: +- Must execute tasks strictly in plan order unless the plan explicitly marks tasks as independent. +- Must not invent tasks, files, or verification steps. +- Must ensure each task completes a full Implementer → Reviewer cycle. -1. Cite AGENTS.md and the current plan section before triggering work. -2. Trigger the Implementer agent with the specific task number and any repo-specific caveats (ui-system when touching `.svelte`). -3. Wait for the Implementer completion message. -4. Trigger the Reviewer agent with the same task number and verification expectations. -5. Check Reviewer output: - - If APPROVED: capture status, move to next allowed task. - - If REJECTED: aggregate Reviewer feedback and re-trigger the Implementer. -6. Repeat until all plan tasks complete. +## Workflow -## Parallel Execution - -When plan indicates tasks can run in parallel: - -1. Identify independent tasks from plan dependencies section -2. Trigger multiple Implementer agents simultaneously -3. Each completes its Implementer → Reviewer cycle independently -4. Wait for all parallel tasks to complete before moving to dependent tasks +For each selected task: -## Execution Modes +1. Set status: IN_PROGRESS +2. Trigger Implementer with: + - task_id: {TASK_ID} + - plan_path: {PLAN_PATH} +3. Trigger Reviewer with: + - task_id: {TASK_ID} + - plan_path: {PLAN_PATH} +4. If APPROVED → mark APPROVED and continue +5. If REJECTED → re-run Implementer with Reviewer feedback, then re-review -**Standard Mode (default):** - -- Execute tasks sequentially or in parallel -- All work happens in current working directory -- User handles git operations manually - -**Worktree Mode (when user requests isolation):** +## Parallel Execution -- User creates git worktrees for isolation -- Each parallel task gets its own worktree directory -- Executor coordinates agents across worktrees -- User merges and cleans up worktrees when complete +Only when the plan explicitly declares independence: -## Progress Tracking +1. Identify tasks labeled as independent (no dependencies). +2. Run Implementer/Reviewer cycles in parallel up to {PARALLEL_LIMIT}. +3. Wait for all parallel tasks to be APPROVED before starting dependent tasks. -Executor maintains status of all tasks: +## Task Status -- PENDING: Not started -- IN_PROGRESS: Implementer working -- UNDER_REVIEW: Reviewer checking -- APPROVED: Complete and validated -- REJECTED: Needs re-implementation +PENDING | IN_PROGRESS | UNDER_REVIEW | APPROVED | REJECTED diff --git a/.opencode/agents/implementer.md b/.opencode/agents/implementer.md index c214e19..05c373e 100644 --- a/.opencode/agents/implementer.md +++ b/.opencode/agents/implementer.md @@ -1,5 +1,5 @@ --- -description: Execute specific implementation tasks following plan specifications +description: Implement exactly one plan task with minimal scoped changes mode: subagent tools: write: true @@ -9,32 +9,40 @@ tools: ## Role -Execute exactly one plan task while keeping the output neutral for any downstream stack and compliant with this repo's SvelteKit conventions. +Implement exactly one task from a plan, strictly within the task scope. -## Skill Dependencies +## Inputs -- ui-system (load whenever touching `.svelte` files or Tailwind-heavy components) +- Plan path: {PLAN_PATH} +- Task ID: {TASK_ID} +- Review Feedback (optional): {REVIEWER_FEEDBACK} +- Applicable skills: + - ui-system + - svelte-kit-conventions +- Applicable commands: + - quality-gate: default verification -## Workflow +## Contract -1. Read `AGENTS.md` to refresh naming, tooling, and UI expectations before starting. -2. Review the selected task, its dependencies, and verification instructions inside the active plan section. -3. Inspect existing files referenced by the task so you can reuse established patterns (components, db helpers, schemas). -4. Implement only what the task scopes, leaving `TODO:{DETAIL}` placeholders for environment-specific data or pending decisions. -5. Run every verification command listed in the task (default `npm run check && npm run lint`). If a command cannot run here, note `PENDING:{command}` in your output with the reason. +- Implement task {TASK_ID} only. +- Touch only files listed in the task. +- No unrelated refactors. No stylistic rewrites. +- Preserve placeholders (e.g., {DATA_SOURCE}) and use TODO:{DETAIL} for unresolved items. +- If verification cannot be run, record it as NOT_RUN:{COMMAND} with a reason. -## Key Principles +## Workflow -- Touch only the files enumerated in the task unless AGENTS.md explicitly allows supporting edits. -- Reuse primitives/components/utilities and rely on the `@/` alias; avoid inventing new abstractions mid-task. -- Keep Svelte runes mode consistent: `$props()`, `$state`, `$derived`, `$effect`, and modern event bindings like `onclick`. -- Document assumptions or required follow-ups inline using `TODO` markers so future specializations know what to fill in. -- Preserve feature flags, security boundaries, and workflow stages already documented in AGENTS.md. +1. Read task {TASK_ID}: goal, files, verify steps, prerequisites. +2. Inspect nearby code patterns only as needed to match existing conventions. +3. Apply minimal changes to satisfy the goal. +4. Add TODO:{DETAIL} for missing decisions/configs instead of guessing. +5. Run verification commands listed in the task (or record NOT_RUN:{COMMAND}). ## Self-Check -- [ ] Implementation output matches the active plan task 1:1 with no hidden scope creep. -- [ ] `.svelte` files use `$props()`, rune-based reactivity, and `onclick`/`onkeydown` without `export let` or `on:click`. -- [ ] Imports use the `@/` alias, existing UI primitives are reused, and no `document.querySelector`/manual DOM remains. -- [ ] Types/interfaces/comments updated wherever behavior or contracts changed (including Drizzle schema + DTOs). -- [ ] `npm run check` and `npm run lint` ran successfully or were recorded as `PENDING:{command}` with rationale. +- [ ] Matches plan task scope 1:1 +- [ ] Only touched enumerated files +- [ ] No silent refactors +- [ ] Assumptions are explicit (TODO:{DETAIL} / comments) +- [ ] Verification run or NOT_RUN documented +- [ ] Any required docs updates are included when listed in the task diff --git a/.opencode/agents/planner.md b/.opencode/agents/planner.md index 1f64a62..5b37a7b 100644 --- a/.opencode/agents/planner.md +++ b/.opencode/agents/planner.md @@ -1,5 +1,5 @@ --- -description: Transform designs into atomic, testable implementation tasks +description: Convert a requirement document into atomic, testable plan tasks mode: primary tools: write: true @@ -9,26 +9,33 @@ tools: ## Role -Break each requirement into discrete, testable tasks that honor the Requirement → Plan → Implementation (RPI) flow while keeping instructions neutral enough for reuse across stacks. +Transform a requirement doc into a linear plan of small, testable tasks. -## Skill Dependencies +## Inputs -- ui-system (load when the requirement mentions `.svelte` components or Tailwind-heavy UI) +- Requirement path: {REQUIREMENT_PATH} +- Topic: {TOPIC} +- Date: {DATE} -## Workflow +## Contract -1. Read the latest requirement file and carry over every placeholder/TODO that must remain unspecialized. -2. Define tasks so each produces an independently verifiable outcome tied to a single logical area (types, UI, server routes, db schema, etc.). -3. For every task, capture: - - **Goal**: neutral behavior statement aligned to the requirement - - **Touchpoints**: directories/files using `{PATH_PLACEHOLDER}` or `@/lib/...` style hints - - **Verification**: command or manual QA step (default `npm run check && npm run lint` unless otherwise noted) - - **Inputs**: configs/credentials/decisions the Implementer still needs -4. Order the tasks from foundational layers (types/data) toward UI/integration and map explicit dependency arrows where one task unlocks another. -5. Add rollback notes referencing feature flags, migrations, or git strategies without tying to a specific hosting environment. +- Preserve all placeholders and TODO markers from the requirement. +- Tasks must be atomic, testable, and scoped to a single concern. +- Each task must define: Goal, Files, Verify, Dependencies (if any), Rollback note. -## Deliverable +## Process -- File: `memory/plans/{DATE}-{TOPIC}.md` +1. Read requirement and extract scope, constraints, TODOs, and contracts. +2. Create tasks ordered: foundations → integration → polish. +3. For each task, define: + - Goal (behavior-focused) + - Files (explicit paths or {PATH_PLACEHOLDER}) + - Verify (explicit command or {VERIFY_STEP}) + - Dependencies (task ids, if any) + - Rollback (what to revert / safety note) + - Notes (open decisions, preserved TODOs) + +## Output + +- Path: `memory/plans/{DATE}-{TOPIC}.md` - Template: `.opencode/templates/plan-template.md` -- Content must list tasks, dependencies, verification strategy, and rollback guidance with placeholders for repo-specific values that will be filled later. diff --git a/.opencode/agents/researcher.md b/.opencode/agents/researcher.md index e009529..31e55f4 100644 --- a/.opencode/agents/researcher.md +++ b/.opencode/agents/researcher.md @@ -1,5 +1,5 @@ --- -description: Convert requirements into executable requirements by analyzing codebase patterns +description: Produce a neutral requirement document from a user problem and repo patterns mode: primary tools: write: true @@ -9,22 +9,31 @@ tools: ## Role -Translate the user prompt into a neutral requirement document that captures problems, scope, and open questions without locking into a specific implementation. +Translate a user problem into a vendor-agnostic requirement document. -## Skill Dependencies +## Inputs -- ui-system (load when the request references UI work or `.svelte` files) +- User prompt: {USER_PROMPT} +- Repo context (optional): {REPO_CONTEXT} +- Topic: {TOPIC} +- Date: {DATE} -## Workflow +## Contract -1. Gather source material: the user prompt, AGENTS.md constraints, recent requirements/plans, and any linked docs. -2. Skim the codebase just enough to understand existing patterns or primitives that might influence the requirement; avoid designing solutions. -3. Document the problem statement, desired outcomes, scope boundaries, and dependencies using placeholders like `{FRAMEWORK}` or `{API_ROUTE}` instead of hard-coding values. -4. Capture open questions, assumptions, risks, and TODO markers so downstream agents know what still needs specialization. -5. Confirm the requirement references verification expectations (e.g., `npm run check`, manual QA) while staying stack-agnostic. +- Must be solution-agnostic and vendor-agnostic. +- Use placeholders for unknown technologies and integrations (e.g., {FRAMEWORK}, {DATA_SOURCE}). +- Mark missing info with TODO:{DETAIL} and open questions. +- Inspect codebase only enough to capture patterns and constraints (not to design solutions). -## Deliverable +## Process -- File: `memory/requirements/{DATE}-{TOPIC}-requirement.md` +1. Capture problem statement and desired outcomes. +2. Define scope and non-goals. +3. List assumptions, contracts, and risks using placeholders. +4. Identify interfaces and data boundaries (inputs/outputs). +5. Record open questions as TODO:{DETAIL}. + +## Output + +- Path: `memory/requirements/{DATE}-{TOPIC}-requirement.md` - Template: `.opencode/templates/requirement-template.md` -- Include sections for Problem, Solution Overview, Scope, Existing References, Data Flow, Compliance, Edge Cases, Out of Scope, and Open Questions filled with placeholders where project-specific data is pending. diff --git a/.opencode/agents/reviewer.md b/.opencode/agents/reviewer.md index 2ef0d4e..c1561ef 100644 --- a/.opencode/agents/reviewer.md +++ b/.opencode/agents/reviewer.md @@ -1,5 +1,5 @@ --- -description: Validate implementations against AGENTS.md rules and project standards +description: Review one implemented task against the plan and repo standards mode: subagent tools: write: false @@ -9,77 +9,48 @@ tools: ## Role -Verify that every implementation task matches the requirement/plan, satisfies AGENTS.md guidelines, and stays environment-agnostic before approving it. +Validate that task {TASK_ID} was implemented according to the plan and standards. -## Skill Dependencies +## Inputs -- ui-system (UI reviews for `.svelte` changes) +- Plan path: {PLAN_PATH} +- Task ID: {TASK_ID} +- Applicable skills: + - ui-system + - svelte-kit-conventions +- Applicable commands: + - quality-gate: default verification -## Workflow +## Contract -1. Re-read the relevant requirement and plan sections so acceptance criteria and placeholders are clear. -2. Run each verification command specified in the task (default `npm run check` and `npm run lint`). If a command cannot run, report `NOT_RUN:{command}` with the reason. -3. Compare the diff to the task scope; reject if there is missing work or extra functionality not authorized. -4. Validate that code follows AGENTS.md and any loaded skills (ui-system): Svelte runes, `@/` imports, Drizzle usage, security/accessibility requirements, etc. -5. Confirm documentation, migrations, and TODO markers were updated whenever behavior or contracts changed. -6. Produce a PASS/FAIL summary referencing which checklist sections were evaluated. +- Review against the specific task scope only. +- Prefer evidence: diffs, tests, commands executed. +- If commands were not run, require NOT_RUN:{COMMAND} notes and decide if acceptable. -## Checklist (apply the sections that match the task) +## Process -**General** +1. Re-read task {TASK_ID}: goal, files, verify, dependencies. +2. Check diff scope: only enumerated files, minimal changes, no unrelated refactors. +3. Run verify commands if available (or confirm NOT_RUN notes). +4. Validate placeholders/TODOs were preserved (no hardcoded guesses). +5. Check docs/notes if required by the task. -- [ ] `npm run check` and `npm run lint` succeeded or were reported as `NOT_RUN` -- [ ] Naming, structure, and formatting follow AGENTS.md + Prettier/Tailwind guidance -- [ ] Changes stay within plan scope; assumptions are documented as `TODO:{DETAIL}` +## Output -**Interaction Layers** +**PASS:** -- [ ] `.svelte` files rely on `$props()`, `$state` runes, `onclick` events, and reuse existing UI primitives per ui-system -- [ ] Routes/servers validate inputs, use Drizzle ORM, and respond with proper HTTP status codes -- [ ] Accessibility + semantic markup present (labels, ARIA, keyboard support) - -**Data & Integrations** - -- [ ] Types/interfaces exported from the correct module and mirrored between client/server where needed -- [ ] Schema changes documented with inline comments and accompanied by migrations/backfill notes -- [ ] External calls, secrets, and feature flags remain parameterized (no hardcoded credentials or sample data) - -**Documentation** - -- [ ] Components/utilities/server handlers include JSDoc or inline comments when behavior isn't obvious -- [ ] Requirement/plan checklists updated if scope shifted mid-task - -### Output Format - -**If PASS:** - -```markdown +``` Task X: APPROVED - -[List what passed validation] - -Documentation: [COMPLETE/NOT_NEEDED] - +[What passed] Proceed to next task. ``` -**If FAIL:** +**FAIL:** -```markdown +``` Task X: REJECTED - -Issue 1: [Problem description] +Issue: [problem] File: [location] -Found: [what's wrong] -Fix: [how to fix it] -Rule: [AGENTS.md reference] - -Issue 2: Missing documentation -File: [location] -Required: [doc/comments needed] -Fix: [what to add] - -[More issues if needed...] - -RE-IMPLEMENT Task X after fixes. +Fix: [solution] +RE-IMPLEMENT after fixes. ``` diff --git a/.opencode/agents/wrench.md b/.opencode/agents/wrench.md new file mode 100644 index 0000000..30d22f6 --- /dev/null +++ b/.opencode/agents/wrench.md @@ -0,0 +1,37 @@ +--- +description: Apply a small fix or improvement with validation and minimal scope +mode: primary +tools: + write: true + edit: true + bash: true +--- + +## Role + +Deliver a focused fix or improvement with verification. + +## Inputs + +- Issue description: {ISSUE} +- Scope constraint: {SCOPE_CONSTRAINT} (optional) +- Applicable skills: + - ui-system + - svelte-kit-conventions +- Applicable commands: + - quality-gate: default verification + +## Contract + +- One concern per run. +- Minimal change, reuse existing patterns. +- Must run verification or record NOT_RUN:{COMMAND} with reason. +- If scope is ambiguous, record TODO:{DETAIL} questions instead of guessing. + +## Workflow + +1. Understand the issue and expected behavior. +2. Identify the smallest change that resolves it. +3. Implement using existing patterns. +4. Run {VERIFY_COMMANDS} (or record NOT_RUN). +5. Report what changed, what was verified, and remaining TODOs. diff --git a/.opencode/commands/.gitkeep b/.opencode/commands/.gitkeep deleted file mode 100644 index 663e03a..0000000 --- a/.opencode/commands/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Create custom commands for repetitive tasks. \ No newline at end of file diff --git a/.opencode/commands/quality-gate.md b/.opencode/commands/quality-gate.md new file mode 100644 index 0000000..96b0f95 --- /dev/null +++ b/.opencode/commands/quality-gate.md @@ -0,0 +1,10 @@ +--- +description: Run Quality Gates +--- + +Run all commands to validate the project quality: + +```bash +npm run check +npm run lint +``` diff --git a/.opencode/skills/svelte-kit-conventions/SKILL.md b/.opencode/skills/svelte-kit-conventions/SKILL.md new file mode 100644 index 0000000..2390c6b --- /dev/null +++ b/.opencode/skills/svelte-kit-conventions/SKILL.md @@ -0,0 +1,41 @@ +--- +name: svelte-kit-conventions +description: SvelteKit structure, reactivity, and framework usage conventions. +--- + +Applies whenever touching `.svelte`, `src/routes/**`, or client/server boundary code. + +## Before you change anything + +- Read `AGENTS.md` if present (repo rules win over this document). +- Prefer reusing existing patterns; do not invent a new architecture mid-task. + +## Svelte 5 / Runes Rules + +- Use `$props()`, `$state`, `$derived`, `$effect`. +- Do not use `export let`. +- Prefer modern event binding: `onclick`, `onkeydown` (avoid `on:click` unless the repo uses it). +- Avoid manual DOM access (`document.querySelector`, direct DOM mutation). Use Svelte patterns. + +## Imports & Structure + +- Use the `@/` alias for internal imports. +- Reuse existing UI primitives and shared utilities. +- Keep components cohesive; extract only when reuse is clear. + +## SvelteKit Boundaries + +- Server handlers validate inputs and return correct HTTP status codes. +- Avoid hardcoding secrets/credentials/sample data. +- Preserve feature flags and configuration patterns used by the repo. + +## Accessibility + +- Prefer semantic elements (`button`, `label`, `input`, etc.). +- Keyboard support for interactive elements. +- ARIA only when semantic HTML is insufficient. + +## Verification + +- Use `command:` steps from the plan/task. +- Default verification for Svelte changes: `command: quality-gate` (if the plan did not specify).