Forms in the team

Team slots are now { id, form } instead of bare ids (old saves migrate
on read via slot()). The detail page's team button adds whatever
mechanical form is selected — "+ Mega X" / "✓ Mega X in team" — and
TeamView merges that form's baked snapshot types/stats/BST into the
member so Coverage, Compare and the Calc all analyse the form (Mega
Charizard X shows as Fire/Dragon, BST 634). Lineup shows the form name
and sprite; remove targets the exact slot. The Add picker still adds
base forms — Megas etc. come from their own page.

CompareTable takes an optional spriteId; addToTeam/removeFromTeam/inTeam
gain a form argument.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-09 19:22:32 -04:00
parent b15ee3dd7f
commit 7f12f08718
5 changed files with 68 additions and 24 deletions

View File

@ -18,7 +18,8 @@ framework.
- **Detail data** (stats, abilities, flavor text, evolution) → fetched lazily from `pokeapi.co` per Pokémon, then cached by the service worker (stale-while-revalidate, 30-day TTL). - **Detail data** (stats, abilities, flavor text, evolution) → fetched lazily from `pokeapi.co` per Pokémon, then cached by the service worker (stale-while-revalidate, 30-day TTL).
- **Sprites** → cache-first with a capped LRU. - **Sprites** → cache-first with a capped LRU.
Export / import of `settings` + `selection` as JSON lives in **Settings**. Export / import of all local state (settings, tracking, team, form
tracking, shiny hunts) as JSON lives in **Settings**.
## Scripts ## Scripts

View File

@ -31,7 +31,7 @@ export function CompareTable(members, { gen = 9, style } = {}) {
el( el(
'a', 'a',
{ class: 'cmp__mem', href: `#/pokemon/${sp.id}` }, { class: 'cmp__mem', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, size: 44, alt: sp.name }), Sprite(sp.spriteId ?? sp.id, { style, size: 44, alt: sp.name }),
el('span', {}, sp.name.replace(/-/g, ' ')), el('span', {}, sp.name.replace(/-/g, ' ')),
), ),
), ),

View File

@ -1,28 +1,45 @@
import { createStore } from './createStore.js'; import { createStore } from './createStore.js';
/** /**
* The working "lineup" up to 6 Pokémon (national dex ids) analysed by the * The working "lineup" up to 6 slots analysed by the Team view (coverage
* Team view (coverage) and Compare view. Persisted. * / compare / calc). A slot is `{ id, form }` where `id` is the national
* dex id and `form` is a snapshot form slug (Mega, regional, alt forme) or
* null for the base Pokémon. Persisted; old saves stored bare ids and are
* migrated on read.
*/ */
export const team = createStore('pdx.team', { members: [] }); export const team = createStore('pdx.team', { members: [] });
export const MAX_TEAM = 6; export const MAX_TEAM = 6;
export function addToTeam(id) { /** Normalise a stored member (number | {id,form}) to {id, form}. */
export function slot(m) {
return typeof m === 'number' ? { id: m, form: null } : { id: m.id, form: m.form || null };
}
const sameSlot = (a, b) => a.id === b.id && (a.form || null) === (b.form || null);
export function members() {
return team.get().members.map(slot);
}
export function addToTeam(id, form = null) {
team.set((s) => { team.set((s) => {
if (s.members.includes(id) || s.members.length >= MAX_TEAM) return s; const list = s.members.map(slot);
return { ...s, members: [...s.members, id] }; if (list.length >= MAX_TEAM || list.some((m) => sameSlot(m, { id, form }))) return s;
return { ...s, members: [...list, { id, form: form || null }] };
}); });
} }
export function removeFromTeam(id) { export function removeFromTeam(id, form = null) {
team.set((s) => ({ ...s, members: s.members.filter((x) => x !== id) })); team.set((s) => ({
...s,
members: s.members.map(slot).filter((m) => !sameSlot(m, { id, form: form || null })),
}));
} }
export function clearTeam() { export function clearTeam() {
team.set({ members: [] }); team.set({ members: [] });
} }
export function inTeam(id) { export function inTeam(id, form = null) {
return team.get().members.includes(id); return team.get().members.map(slot).some((m) => sameSlot(m, { id, form: form || null }));
} }

View File

@ -254,19 +254,23 @@ export async function PokemonDetail(nationalId) {
return b; return b;
} }
// The active mechanical form (Mega, regional, alt forme) is what gets
// added — its baked types/stats drive the Team coverage/compare/calc.
const teamForm = () => activeForm?.slug || null;
const teamBtn = el('button', { const teamBtn = el('button', {
class: 'ptrack__btn ptrack__btn--team', class: 'ptrack__btn ptrack__btn--team',
type: 'button', type: 'button',
onclick: () => { onclick: () => {
if (inTeam(nationalId)) removeFromTeam(nationalId); if (inTeam(nationalId, teamForm())) removeFromTeam(nationalId, teamForm());
else addToTeam(nationalId); else addToTeam(nationalId, teamForm());
syncTeamBtn(); syncTeamBtn();
}, },
}); });
function syncTeamBtn() { function syncTeamBtn() {
const on = inTeam(nationalId); const on = inTeam(nationalId, teamForm());
const full = team.get().members.length >= MAX_TEAM; const full = team.get().members.length >= MAX_TEAM;
teamBtn.textContent = on ? '✓ In team' : full ? 'Team full' : ' Team'; const label = activeForm ? `${activeForm.name} in team` : 'In team';
teamBtn.textContent = on ? `${label}` : full ? 'Team full' : activeForm ? ` ${activeForm.name}` : ' Team';
teamBtn.classList.toggle('is-on', on); teamBtn.classList.toggle('is-on', on);
teamBtn.disabled = !on && full; teamBtn.disabled = !on && full;
} }
@ -662,6 +666,7 @@ export async function PokemonDetail(nationalId) {
activeCosmetic = null; activeCosmetic = null;
if (cosmeticSelect) cosmeticSelect.value = ''; if (cosmeticSelect) cosmeticSelect.value = '';
syncFormPills(); syncFormPills();
syncTeamBtn();
heroForm.textContent = form ? form.name : ''; heroForm.textContent = form ? form.name : '';
try { try {
pk = form ? await getPokemon(form.slug) : pokemon; pk = form ? await getPokemon(form.slug) : pokemon;

View File

@ -2,7 +2,7 @@ import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js'; import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js'; import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js'; import { ui } from '../store/ui.js';
import { team, addToTeam, removeFromTeam, clearTeam, MAX_TEAM } from '../store/team.js'; import { team, addToTeam, removeFromTeam, clearTeam, slot, MAX_TEAM } from '../store/team.js';
import { TYPES, defenseVector, multiplier } from '../data/type-chart.js'; import { TYPES, defenseVector, multiplier } from '../data/type-chart.js';
import { typesForGen } from '../lib/type-resolve.js'; import { typesForGen } from '../lib/type-resolve.js';
import { forGeneration } from '../components/MovesList.js'; import { forGeneration } from '../components/MovesList.js';
@ -80,7 +80,28 @@ export async function TeamView() {
); );
function members() { function members() {
return team.get().members.map((id) => snap.speciesById.get(id)).filter(Boolean); return team
.get()
.members.map((m) => {
const s = slot(m);
const base = snap.speciesById.get(s.id);
if (!base) return null;
const f = s.form && (base.forms || []).find((x) => x.slug === s.form);
if (!f) return { ...base, _slotForm: null };
// Merge the snapshot form's baked types / stats so coverage,
// compare and the calc all analyse the form, not the base.
return {
...base,
_slotForm: s.form,
name: `${base.name} (${f.name})`,
spriteId: f.id,
types: f.types || base.types,
pastTypes: [],
stats: f.stats || base.stats,
bst: f.bst ?? base.bst,
};
})
.filter(Boolean);
} }
function renderLineup() { function renderLineup() {
@ -99,7 +120,7 @@ export async function TeamView() {
type: 'button', type: 'button',
'aria-label': `Remove ${sp.name}`, 'aria-label': `Remove ${sp.name}`,
onclick: () => { onclick: () => {
removeFromTeam(sp.id); removeFromTeam(sp.id, sp._slotForm);
}, },
}, },
'✕', '✕',
@ -107,7 +128,7 @@ export async function TeamView() {
el( el(
'a', 'a',
{ href: `#/pokemon/${sp.id}`, class: 'lineup__link' }, { href: `#/pokemon/${sp.id}`, class: 'lineup__link' },
Sprite(sp.id, { style, alt: sp.name, size: 64 }), Sprite(sp.spriteId ?? sp.id, { style, alt: sp.name, size: 64 }),
el('span', { class: 'lineup__name' }, sp.name.replace(/-/g, ' ')), el('span', { class: 'lineup__name' }, sp.name.replace(/-/g, ' ')),
), ),
), ),
@ -120,7 +141,7 @@ export async function TeamView() {
{ {
class: 'lineup__add', class: 'lineup__add',
type: 'button', type: 'button',
onclick: () => openPokemonPicker(addToTeam), onclick: () => openPokemonPicker((id) => addToTeam(id), { closeAfterPick: true }),
}, },
'', '',
el('span', {}, 'Add'), el('span', {}, 'Add'),
@ -215,7 +236,7 @@ export async function TeamView() {
href: `#/pokemon/${sp.id}`, href: `#/pokemon/${sp.id}`,
title: `${prettify(sp.name)} takes ${m}×`, title: `${prettify(sp.name)} takes ${m}×`,
}, },
Sprite(sp.id, { style, size: 26, alt: sp.name }), Sprite(sp.spriteId ?? sp.id, { style, size: 26, alt: sp.name }),
), ),
), ),
), ),
@ -234,7 +255,7 @@ export async function TeamView() {
el( el(
'a', 'a',
{ class: 'cov__mem', href: `#/pokemon/${sp.id}`, title: prettify(sp.name) }, { class: 'cov__mem', href: `#/pokemon/${sp.id}`, title: prettify(sp.name) },
Sprite(sp.id, { style, size: 34, alt: sp.name }), Sprite(sp.spriteId ?? sp.id, { style, size: 34, alt: sp.name }),
), ),
), ),
el('span', { class: 'cov__bal cov__bal--head' }, 'resist · weak'), el('span', { class: 'cov__bal cov__bal--head' }, 'resist · weak'),
@ -410,7 +431,7 @@ export async function TeamView() {
? el( ? el(
'button', 'button',
{ type: 'button', class: 'calc__pick', onclick: () => pick() }, { type: 'button', class: 'calc__pick', onclick: () => pick() },
Sprite(sp.id, { style, size: 48, alt: sp.name }), Sprite(sp.spriteId ?? sp.id, { style, size: 48, alt: sp.name }),
el('span', {}, prettify(sp.name)), el('span', {}, prettify(sp.name)),
) )
: el('button', { type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() }, `Choose ${sideLabel}`), : el('button', { type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() }, `Choose ${sideLabel}`),
@ -441,7 +462,7 @@ export async function TeamView() {
recalc(); recalc();
}, },
}, },
Sprite(sp.id, { style, size: 26, alt: sp.name }), Sprite(sp.spriteId ?? sp.id, { style, size: 26, alt: sp.name }),
), ),
), ),
) )