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).
- **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

View File

@ -31,7 +31,7 @@ export function CompareTable(members, { gen = 9, style } = {}) {
el(
'a',
{ 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, ' ')),
),
),

View File

@ -1,28 +1,45 @@
import { createStore } from './createStore.js';
/**
* The working "lineup" up to 6 Pokémon (national dex ids) analysed by the
* Team view (coverage) and Compare view. Persisted.
* The working "lineup" up to 6 slots analysed by the Team view (coverage
* / 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 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) => {
if (s.members.includes(id) || s.members.length >= MAX_TEAM) return s;
return { ...s, members: [...s.members, id] };
const list = s.members.map(slot);
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) {
team.set((s) => ({ ...s, members: s.members.filter((x) => x !== id) }));
export function removeFromTeam(id, form = null) {
team.set((s) => ({
...s,
members: s.members.map(slot).filter((m) => !sameSlot(m, { id, form: form || null })),
}));
}
export function clearTeam() {
team.set({ members: [] });
}
export function inTeam(id) {
return team.get().members.includes(id);
export function inTeam(id, form = null) {
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;
}
// 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', {
class: 'ptrack__btn ptrack__btn--team',
type: 'button',
onclick: () => {
if (inTeam(nationalId)) removeFromTeam(nationalId);
else addToTeam(nationalId);
if (inTeam(nationalId, teamForm())) removeFromTeam(nationalId, teamForm());
else addToTeam(nationalId, teamForm());
syncTeamBtn();
},
});
function syncTeamBtn() {
const on = inTeam(nationalId);
const on = inTeam(nationalId, teamForm());
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.disabled = !on && full;
}
@ -662,6 +666,7 @@ export async function PokemonDetail(nationalId) {
activeCosmetic = null;
if (cosmeticSelect) cosmeticSelect.value = '';
syncFormPills();
syncTeamBtn();
heroForm.textContent = form ? form.name : '';
try {
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 { settings } from '../store/settings.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 { typesForGen } from '../lib/type-resolve.js';
import { forGeneration } from '../components/MovesList.js';
@ -80,7 +80,28 @@ export async function TeamView() {
);
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() {
@ -99,7 +120,7 @@ export async function TeamView() {
type: 'button',
'aria-label': `Remove ${sp.name}`,
onclick: () => {
removeFromTeam(sp.id);
removeFromTeam(sp.id, sp._slotForm);
},
},
'✕',
@ -107,7 +128,7 @@ export async function TeamView() {
el(
'a',
{ 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, ' ')),
),
),
@ -120,7 +141,7 @@ export async function TeamView() {
{
class: 'lineup__add',
type: 'button',
onclick: () => openPokemonPicker(addToTeam),
onclick: () => openPokemonPicker((id) => addToTeam(id), { closeAfterPick: true }),
},
'',
el('span', {}, 'Add'),
@ -215,7 +236,7 @@ export async function TeamView() {
href: `#/pokemon/${sp.id}`,
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(
'a',
{ 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'),
@ -410,7 +431,7 @@ export async function TeamView() {
? el(
'button',
{ 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('button', { type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() }, `Choose ${sideLabel}`),
@ -441,7 +462,7 @@ export async function TeamView() {
recalc();
},
},
Sprite(sp.id, { style, size: 26, alt: sp.name }),
Sprite(sp.spriteId ?? sp.id, { style, size: 26, alt: sp.name }),
),
),
)