Shiny hunt tracker (#/shiny)

New store + view (linked from Settings > Your data): one card per active
hunt with a big +1 counter, the method's odds, and the running cumulative
chance (1 - (1-p)^count). Nine methods with shiny-charm variants
(Full odds, Masuda, SOS, Radar, DexNav, Catch Combo, Dynamax Adventure,
Mass/Massive Outbreak). "Found it" marks the Pokémon caught and clears
the hunt; per-hunt notes field.

Also: JSON export/import now covers team, formTracking and shinyHunts
alongside settings/selection; openPokemonPicker gains a `closeAfterPick`
option (used by the shiny + breeding single-pick flows).

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:06:46 -04:00
parent 48cb471489
commit b15ee3dd7f
7 changed files with 362 additions and 8 deletions

View File

@ -11,7 +11,7 @@ let openInstance = null;
* `onPick(id)` fires per selection; the sheet stays open so you can add
* several. Dismiss with the backdrop, , or Escape.
*/
export async function openPokemonPicker(onPick) {
export async function openPokemonPicker(onPick, { closeAfterPick = false } = {}) {
if (openInstance) return;
const snap = await loadSnapshot();
@ -69,7 +69,10 @@ export async function openPokemonPicker(onPick) {
{
class: 'picker__row',
type: 'button',
onclick: () => onPick(s.id),
onclick: () => {
onPick(s.id);
if (closeAfterPick) close();
},
},
Sprite(s.id, { style, alt: s.name, size: 44 }),
el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`),

View File

@ -12,6 +12,7 @@ import { TypeChartView } from './views/TypeChartView.js';
import { CompareView } from './views/CompareView.js';
import { ProgressView } from './views/ProgressView.js';
import { BreedingView } from './views/BreedingView.js';
import { ShinyView } from './views/ShinyView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js';
@ -27,6 +28,7 @@ const routes = [
{ pattern: /^#\/compare$/, view: () => CompareView() },
{ pattern: /^#\/progress$/, view: () => ProgressView() },
{ pattern: /^#\/breeding$/, view: () => BreedingView() },
{ pattern: /^#\/shiny$/, view: () => ShinyView() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
];

39
src/store/shinyHunts.js Normal file
View File

@ -0,0 +1,39 @@
import { createStore } from './createStore.js';
/**
* Shiny hunts: one entry per Pokémon you're actively hunting, with an
* encounter counter and the method's odds. Lives in localStorage next to
* the other tracking data; rides along in the JSON export.
*/
export const shinyHunts = createStore('pdx.shinyHunts', { hunts: [] });
let seq = Date.now();
const uid = () => `h${(seq++).toString(36)}`;
export function addHunt({ speciesId, method, charm }) {
shinyHunts.set((s) => ({
...s,
hunts: [
{ id: uid(), speciesId, method, charm: !!charm, count: 0, notes: '', startedAt: new Date().toISOString() },
...s.hunts,
],
}));
}
export function updateHunt(id, patch) {
shinyHunts.set((s) => ({
...s,
hunts: s.hunts.map((h) => (h.id === id ? { ...h, ...patch } : h)),
}));
}
export function bumpHunt(id, delta) {
shinyHunts.set((s) => ({
...s,
hunts: s.hunts.map((h) => (h.id === id ? { ...h, count: Math.max(0, h.count + delta) } : h)),
}));
}
export function removeHunt(id) {
shinyHunts.set((s) => ({ ...s, hunts: s.hunts.filter((h) => h.id !== id) }));
}

View File

@ -3162,3 +3162,106 @@
margin-top: 6px;
text-transform: capitalize;
}
/* ---- Shiny hunts ------------------------------------------- */
.shiny {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.shiny__card {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
'mon counter'
'chance chance'
'notes notes'
'actions actions';
gap: 8px 10px;
padding: 12px;
border-radius: 14px;
background: var(--surface-2);
}
.shiny__mon {
grid-area: mon;
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
}
.shiny__mon .sprite {
width: 56px;
height: 56px;
object-fit: contain;
}
.shiny__name {
display: block;
font-weight: 700;
text-transform: capitalize;
}
.shiny__method {
display: block;
font-size: 0.74rem;
color: var(--text-dim);
}
.shiny__counter {
grid-area: counter;
display: flex;
align-items: center;
gap: 8px;
}
.shiny__step {
width: 34px;
height: 34px;
border: 1.5px solid var(--border);
border-radius: 50%;
background: var(--surface);
color: var(--text);
font: inherit;
font-weight: 800;
cursor: pointer;
}
.shiny__step--add {
width: 48px;
border-radius: 999px;
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.shiny__count {
min-width: 3ch;
text-align: center;
font-size: 1.3rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.shiny__chance {
grid-area: chance;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-dim);
}
.shiny__notes {
grid-area: notes;
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 0.82rem;
}
.shiny__actions {
grid-area: actions;
display: flex;
gap: 8px;
}
.shiny__actions .button {
flex: 1;
}
.shiny__new {
align-self: flex-start;
}

View File

@ -109,11 +109,14 @@ export async function BreedingView() {
type: 'button',
class: 'breed__pick',
onclick: () =>
openPokemonPicker((id) => {
openPokemonPicker(
(id) => {
target = snap.speciesById.get(id);
ui.set({ breedTarget: id });
render();
}),
},
{ closeAfterPick: true },
),
},
target
? el('span', {}, Sprite(target.id, { style, size: 40, alt: target.name }), ` ${prettify(target.name)}`)

View File

@ -1,6 +1,9 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js';
import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js';
import { shinyHunts } from '../store/shinyHunts.js';
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() {
@ -130,6 +133,9 @@ export async function SettingsView() {
const data = JSON.parse(await file.text());
if (data.settings) settings.replace(data.settings);
if (data.selection) selection.replace(data.selection);
if (data.team) team.replace(data.team);
if (data.formTracking) formTracking.replace(data.formTracking);
if (data.shinyHunts) shinyHunts.replace(data.shinyHunts);
alert('Import complete.');
} catch {
alert('That file could not be read as a Pokédex backup.');
@ -186,7 +192,8 @@ export async function SettingsView() {
'p',
{ class: 'settings__note' },
el('a', { class: 'link', href: '#/progress' }, 'Progress by game'),
' — caught totals against every games dex.',
' · ',
el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'),
),
storageNote,
el('div', { class: 'settings__actions' },
@ -253,6 +260,9 @@ function exportData() {
exportedAt: new Date().toISOString(),
settings: settings.get(),
selection: selection.get(),
team: team.get(),
formTracking: formTracking.get(),
shinyHunts: shinyHunts.get(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',

194
src/views/ShinyView.js Normal file
View File

@ -0,0 +1,194 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { shinyHunts, addHunt, updateHunt, bumpHunt, removeHunt } from '../store/shinyHunts.js';
import { toggle, entry } from '../store/selection.js';
import { buzz } from '../lib/haptics.js';
import { Sprite } from '../components/Sprite.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
// Representative full-game odds (as 1/N), with a shiny-charm variant.
export const METHODS = [
{ id: 'full', label: 'Full odds', base: 4096, charm: 1365 },
{ id: 'masuda', label: 'Masuda Method', base: 683, charm: 512 },
{ id: 'sos', label: 'SOS chaining (31+)', base: 1365, charm: 1024 },
{ id: 'radar', label: 'Poké Radar (chain 40)', base: 99, charm: 99 },
{ id: 'dexnav', label: 'DexNav (high search level)', base: 1365, charm: 1024 },
{ id: 'combo', label: 'Catch Combo (31+)', base: 1024, charm: 819 },
{ id: 'dmax', label: 'Dynamax Adventure', base: 300, charm: 100 },
{ id: 'outbreak', label: 'Mass Outbreak (SV, 60+)', base: 512, charm: 205 },
{ id: 'massive', label: 'Massive Outbreak (Legends)', base: 158, charm: 128 },
];
const methodById = (id) => METHODS.find((m) => m.id === id) || METHODS[0];
function oddsFor(h) {
if (h.customOdds) return Math.max(1, h.customOdds);
const m = methodById(h.method);
return h.charm ? m.charm : m.base;
}
const pct = (n) => (n < 0.1 ? n.toFixed(2) : n < 10 ? n.toFixed(1) : Math.round(n));
export async function ShinyView() {
const view = el('section', { class: 'view lookup shinyview' });
const snap = await loadSnapshot();
const style = settings.get().spriteStyle;
const list = el('div', { class: 'shiny' });
function huntCard(h) {
const sp = snap.speciesById.get(h.speciesId);
const odds = oddsFor(h);
const cumulative = (n) => 1 - Math.pow(1 - 1 / odds, n);
const count = el('span', { class: 'shiny__count' }, String(h.count));
const chanceEl = el(
'span',
{ class: 'shiny__chance' },
`${pct(cumulative(h.count) * 100)}% by now · 1/${odds}`,
);
const refresh = () => {
const n = shinyHunts.get().hunts.find((x) => x.id === h.id)?.count ?? 0;
count.textContent = String(n);
chanceEl.textContent = `${pct(cumulative(n) * 100)}% by now · 1/${odds}`;
};
const notes = el('input', {
type: 'text',
class: 'shiny__notes',
value: h.notes || '',
placeholder: 'Notes — location, den, phase count…',
onchange: (e) => updateHunt(h.id, { notes: e.target.value }),
});
return el(
'div',
{ class: 'shiny__card' },
el(
'a',
{ class: 'shiny__mon', href: `#/pokemon/${h.speciesId}` },
Sprite(h.speciesId, { style, shiny: true, alt: sp?.name, size: 56 }),
el(
'span',
{},
el('span', { class: 'shiny__name' }, (sp?.name || `#${h.speciesId}`).replace(/-/g, ' ')),
el('span', { class: 'shiny__method' }, methodById(h.method).label + (h.charm ? ' · charm' : '')),
),
),
el(
'div',
{ class: 'shiny__counter' },
el('button', { type: 'button', class: 'shiny__step', onclick: () => { bumpHunt(h.id, -1); refresh(); } }, ''),
count,
el(
'button',
{
type: 'button',
class: 'shiny__step shiny__step--add',
onclick: () => {
bumpHunt(h.id, 1);
buzz(10);
refresh();
},
},
'+1',
),
),
chanceEl,
notes,
el(
'div',
{ class: 'shiny__actions' },
el(
'button',
{
type: 'button',
class: 'button',
onclick: () => {
if (!entry(h.speciesId).caught) toggle(h.speciesId, 'caught');
removeHunt(h.id);
render();
},
},
'✨ Found it',
),
el(
'button',
{
type: 'button',
class: 'button button--danger',
onclick: () => {
if (confirm('Delete this hunt?')) {
removeHunt(h.id);
render();
}
},
},
'Delete',
),
),
);
}
function newHunt() {
openPokemonPicker(
(id) => chooseMethod((method, charm) => addHunt({ speciesId: id, method, charm })),
{ closeAfterPick: true },
);
}
function chooseMethod(done) {
const backdrop = el('div', { class: 'sheet-backdrop is-open', onclick: (e) => e.target === backdrop && backdrop.remove() });
const methodSel = el('select', { class: 'search__input' }, ...METHODS.map((m) => el('option', { value: m.id }, m.label)));
const charmChk = el('input', { type: 'checkbox' });
const panel = el(
'div',
{ class: 'gsheet' },
el('div', { class: 'gsheet__head' }, el('h2', {}, 'Hunt method'), el('button', { class: 'gsheet__close', onclick: () => backdrop.remove() }, '✕')),
methodSel,
el('label', { class: 'lookup__check', style: 'margin-top:12px' }, charmChk, 'Shiny Charm'),
el(
'button',
{
class: 'button',
style: 'margin-top:16px',
onclick: () => {
done(methodSel.value, charmChk.checked);
backdrop.remove();
render();
},
},
'Start hunt',
),
);
backdrop.append(panel);
document.body.append(backdrop);
}
function render() {
clear(list);
const hunts = shinyHunts.get().hunts;
if (!hunts.length) {
list.append(el('p', { class: 'detail__muted' }, 'No active hunts. Start one below.'));
} else {
for (const h of hunts) list.append(huntCard(h));
}
list.append(
el('button', { type: 'button', class: 'lineup__add shiny__new', onclick: newHunt }, '', el('span', {}, 'New hunt')),
);
}
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/settings' }, ' Settings')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Shiny hunts'),
el('p', {}, 'Count encounters and watch the cumulative odds climb. “Found it” marks the Pokémon caught.'),
),
list,
);
render();
onTeardown(view, () => {});
return view;
}