Team builder: game-aware coverage + a weak-spots summary

Coverage was hardcoded to Gen 9. Now it reads the selected game: only
that generation's types get rows (no Fairy pre-Gen 6, no Steel/Dark
pre-Gen 2), member typings are era-accurate via the snapshot's pastTypes,
and the type chart / STAB maths run at the game's generation. Compare's
Type row follows suit. The panel re-renders on a game switch.

New "Weak spots" block above the grid: types that hit 2+ members
super-effectively, or that a member is weak to and nobody resists (those
get a "0 resist" flag), each listing the exposed members. Plus a
"no resist to" line for types the whole team only takes neutrally but
can't wall. The grid itself swaps the plain "weak" count for a diverging
resist/weak bar and tints rows where 2+ members are weak.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-08-29 14:49:35 -04:00
parent 99d472f469
commit c7a2a821fa
3 changed files with 243 additions and 27 deletions

View File

@ -93,8 +93,13 @@ Working app with a type-themed UI:
this game" and sort by power / accuracy / recency; Items filter by
category. Each has its own detail page. Query, scroll, tab and filters
persist.
- **Team** — a lineup of up to 6 with a Coverage table (weaknesses, STAB
gaps) and a Compare table; toggleable in the nav from Settings.
- **Team** — a lineup of up to 6. Coverage leads with a "weak spots"
summary (types that hit 2+ members, or that someone's weak to and nobody
resists), then a full matchup grid with a diverging resist◀│▶weak bar per
type; plus offensive STAB gaps and at-a-glance stats. A Compare table
stacks the six side by side. All matchup maths follow the selected game —
its generation's type chart and era-accurate typings (pre-Gen-6 Clefairy
is Normal, etc.). Toggleable in the nav from Settings.
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, sprite style, JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,

View File

@ -2139,18 +2139,128 @@
.cov__cell.m05 { background: color-mix(in srgb, var(--good) 24%, transparent); }
.cov__cell.m025 { background: color-mix(in srgb, var(--good) 48%, transparent); color: #fff; }
.cov__cell.m0 { background: var(--surface-2); color: var(--text-dim); }
.cov__sum {
width: 42px;
.cov__row--threat {
background: color-mix(in srgb, var(--danger) 12%, transparent);
box-shadow: inset 3px 0 0 var(--danger);
border-radius: 6px;
}
/* diverging bar: resists grow left of centre, weaknesses grow right */
.cov__bal {
width: 138px;
flex: none;
text-align: center;
font-size: 0.78rem;
display: flex;
align-items: center;
}
.cov__bal--head {
font-size: 0.66rem;
font-weight: 700;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.03em;
justify-content: center;
}
.cov__sum.is-bad {
color: var(--danger);
.cov__bal-side {
flex: 1;
display: flex;
align-items: center;
}
.cov__bal-side--l {
justify-content: flex-end;
border-right: 1px solid var(--border);
padding-right: 3px;
}
.cov__bal-side--r {
padding-left: 3px;
}
.cov__bal-res,
.cov__bal-weak {
height: 12px;
border-radius: 3px;
min-width: 0;
}
.cov__bal-res {
background: var(--good);
}
.cov__bal-weak {
background: var(--danger);
color: #fff;
font-size: 0.7rem;
font-weight: 800;
line-height: 12px;
text-align: center;
}
/* ---- Weak-spots summary ------------------------------------- */
.cov__ok {
color: var(--good);
font-weight: 600;
margin: 4px 0 2px;
}
.weakspots {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 6px;
}
.weakspot {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 7px 10px;
border-radius: 12px;
background: color-mix(in srgb, var(--danger) 10%, var(--surface-2));
}
.weakspot.is-nores {
background: color-mix(in srgb, var(--danger) 20%, var(--surface-2));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 45%, transparent);
}
.weakspot__count {
font-weight: 800;
font-size: 0.8rem;
}
.weakspot__flag {
font-size: 0.68rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #fff;
background: var(--danger);
padding: 2px 6px;
border-radius: 999px;
}
.weakspot__res {
font-size: 0.72rem;
color: var(--text-dim);
font-weight: 600;
}
.weakspot__mem {
display: flex;
gap: 3px;
margin-left: auto;
}
.weakspot__chip {
width: 26px;
height: 26px;
display: grid;
place-items: center;
}
.weakspot__chip .sprite {
width: 26px;
height: 26px;
object-fit: contain;
}
.cov__unres {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin: 2px 0 4px;
}
.cov__unres-k {
font-size: 0.78rem;
font-weight: 700;
color: var(--text-dim);
}
/* ---- Compare table ------------------------------------------- */

View File

@ -4,6 +4,7 @@ import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { team, addToTeam, removeFromTeam, clearTeam, MAX_TEAM } from '../store/team.js';
import { TYPES, defenseVector, multiplier } from '../data/type-chart.js';
import { typesForGen } from '../lib/type-resolve.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
@ -143,38 +144,124 @@ export async function TeamView() {
}
function coverage(mem) {
const gen = 9;
const vectors = mem.map((sp) => defenseVector(sp.types, gen));
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
const gameLabel = st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup);
const style = st.spriteStyle;
// Defensive weakness table
// Only the types that exist in the selected game (no Fairy before Gen 6,
// no Steel/Dark before Gen 2). Member typings are era-accurate too —
// pre-Gen-6 Clefairy is Normal, Gen 1 Magnemite is pure Electric.
const GEN_TYPES = TYPES.filter((t) => {
if (t === 'fairy' && gen < 6) return false;
if ((t === 'steel' || t === 'dark') && gen < 2) return false;
return true;
});
const memTypes = mem.map((sp) => typesForGen(sp, gen));
const vectors = memTypes.map((types) => defenseVector(types, gen));
// Per attacking type: which members are weak, how many resist.
const analysis = GEN_TYPES.map((t) => {
const weak = [];
let resist = 0;
vectors.forEach((v, i) => {
const m = v[t] ?? 1;
if (m >= 2) weak.push({ sp: mem[i], m });
else if (m < 1) resist += 1;
});
return { type: t, weak, weakN: weak.length, resist };
});
// A "weak spot": 2+ members weak, or someone weak and nobody resists it.
const threats = analysis
.filter((a) => a.weakN >= 2 || (a.weakN >= 1 && a.resist === 0))
.sort((a, b) => b.weakN - a.weakN || a.resist - b.resist);
const unresisted = analysis
.filter((a) => a.weakN === 0 && a.resist === 0)
.map((a) => a.type);
const weakSpots = threats.length
? el(
'div',
{ class: 'weakspots' },
...threats.map((a) =>
el(
'div',
{ class: `weakspot${a.resist === 0 ? ' is-nores' : ''}` },
TypeChip(a.type),
el('span', { class: 'weakspot__count' }, `${a.weakN} weak`),
a.resist === 0
? el('span', { class: 'weakspot__flag' }, '0 resist')
: el('span', { class: 'weakspot__res' }, `${a.resist} resist`),
el(
'span',
{ class: 'weakspot__mem' },
...a.weak.map(({ sp, m }) =>
el(
'a',
{
class: 'weakspot__chip',
href: `#/pokemon/${sp.id}`,
title: `${prettify(sp.name)} takes ${m}×`,
},
Sprite(sp.id, { style, size: 26, alt: sp.name }),
),
),
),
),
),
)
: el('p', { class: 'cov__ok' }, '✓ Nothing hits two or more of your team super-effectively.');
// Defensive grid: a diverging bar (resists ◀ | ▶ weaknesses) makes the
// rows to worry about jump out; threat rows get a tinted background.
const head = el(
'div',
{ class: 'cov__row cov__row--head' },
el('span', {}, ''),
...mem.map((sp) => el('span', { class: 'cov__mem' }, Sprite(sp.id, { size: 34, alt: sp.name }))),
el('span', { class: 'cov__sum' }, 'Weak'),
el('span', { class: 'cov__type' }, ''),
...mem.map((sp) =>
el(
'a',
{ class: 'cov__mem', href: `#/pokemon/${sp.id}`, title: prettify(sp.name) },
Sprite(sp.id, { style, size: 34, alt: sp.name }),
),
),
el('span', { class: 'cov__bal cov__bal--head' }, 'resist · weak'),
);
const rows = TYPES.map((t) => {
let weak = 0;
const rows = GEN_TYPES.map((t) => {
const a = analysis.find((x) => x.type === t);
const cells = vectors.map((v) => {
const m = v[t] ?? 1;
if (m >= 2) weak += 1;
return el('span', { class: `cov__cell ${multClass(m)}` }, multText(m));
});
const bal = el(
'span',
{ class: 'cov__bal' },
el(
'span',
{ class: 'cov__bal-side cov__bal-side--l' },
el('span', { class: 'cov__bal-res', style: `width:${Math.min(a.resist, 6) * 10}px` }),
),
el(
'span',
{ class: 'cov__bal-side cov__bal-side--r' },
el('span', { class: 'cov__bal-weak', style: `width:${Math.min(a.weakN, 6) * 10}px` }, a.weakN || ''),
),
);
return el(
'div',
{ class: 'cov__row' },
{ class: `cov__row${a.weakN >= 2 ? ' cov__row--threat' : ''}` },
el('span', { class: 'cov__type' }, TypeChip(t)),
...cells,
el('span', { class: `cov__sum${weak >= 2 ? ' is-bad' : ''}` }, weak || ''),
bal,
);
});
// Offensive coverage gaps: defending types no member hits super-effectively
const gaps = TYPES.filter((def) => {
if (gen < 6 && def === 'fairy') return false;
return !mem.some((sp) => sp.types.some((atk) => multiplier(atk, [def], gen) >= 2));
});
const gaps = GEN_TYPES.filter(
(def) => !mem.some((sp, i) => memTypes[i].some((atk) => multiplier(atk, [def], gen) >= 2)),
);
// Quick team stats
const avgBst = Math.round(mem.reduce((s, m) => s + (m.bst || 0), 0) / mem.length);
@ -190,7 +277,17 @@ export async function TeamView() {
return el(
'div',
{},
el('h2', { class: 'ppanel__sub' }, 'Type coverage — how each type hits your team'),
el('h2', { class: 'ppanel__sub' }, `Weak spots · ${gameLabel} (Gen ${gen})`),
weakSpots,
unresisted.length
? el(
'p',
{ class: 'cov__unres' },
el('span', { class: 'cov__unres-k' }, 'No resist to: '),
el('span', { class: 'matchups__types' }, ...unresisted.map(TypeChip)),
)
: null,
el('h2', { class: 'ppanel__sub' }, 'Full matchup grid'),
el('div', { class: 'cov' }, head, ...rows),
el('h2', { class: 'ppanel__sub' }, 'Offensive gaps (no super-effective STAB)'),
gaps.length
@ -213,6 +310,8 @@ export async function TeamView() {
}
function compare(mem) {
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
const maxOf = (key) => Math.max(...mem.map((m) => m.stats?.[key] ?? 0));
const row = (label, cells, cls = '') =>
el('div', { class: `cmp__row ${cls}` }, el('span', { class: 'cmp__label' }, label), ...cells);
@ -234,7 +333,7 @@ export async function TeamView() {
),
row(
'Type',
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...sp.types.map(TypeChip))),
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...typesForGen(sp, gen).map(TypeChip))),
),
...STAT_ROWS.map(([key, label]) => {
const mx = maxOf(key);
@ -270,10 +369,12 @@ export async function TeamView() {
render();
const off = team.subscribe(render);
const offStyle = settings.subscribe(renderLineup);
// Re-render on any settings change: sprite style feeds the lineup, and the
// selected game drives the coverage generation + era typings.
const offSettings = settings.subscribe(render);
onTeardown(view, () => {
off();
offStyle();
offSettings();
});
return view;
}