Import: ask merge vs replace; make the feed progress ring a link

- Save import now opens a small choice dialog (new lib/dialog.js) instead
  of a confirm(): "Add to my Pokédex" (merge, keeps everything) or
  "Replace mine with this". Replace resets seen/caught to exactly the
  save but keeps favourites and notes (new selection.replaceFlags). The
  dialog also explains that caught state is shared across games, so an
  imported save only ever adds species that game has and they count
  toward the National Dex too — no per-game caught flag, by design.
- The dex-feed progress ring is now an <a href="#/progress"> with a
  hover/press nudge — tapping it opens the per-game breakdown.

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-10 09:52:26 -04:00
parent 78df1a32e8
commit 840031c48a
5 changed files with 169 additions and 15 deletions

View File

@ -38,7 +38,12 @@ export function ProgressRing() {
const num = el('span', { class: 'ring__num' }, '0');
const tail = el('small', {});
const value = el('span', { class: 'ring__value' }, num, tail);
const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value));
const node = el(
'a',
{ class: 'ring', href: '#/progress', title: 'Progress by game', 'aria-label': 'Progress by game' },
svg,
el('div', { class: 'ring__label' }, value),
);
let first = true;
let shown = 0;

60
src/lib/dialog.js Normal file
View File

@ -0,0 +1,60 @@
import { el } from './dom.js';
/**
* A small centered modal with one or more choices. Resolves to the chosen
* option's `key`, or `null` if dismissed (backdrop, Escape, or a choice
* whose key is null).
*
* const pick = await chooseDialog({
* title: 'Import save',
* body: '84 seen, 40 caught.',
* choices: [
* { key: 'merge', label: 'Add to my Pokédex' },
* { key: 'replace', label: 'Replace mine', class: 'button--danger' },
* { key: null, label: 'Cancel', class: 'button--ghost' },
* ],
* });
*/
export function chooseDialog({ title, body, choices }) {
return new Promise((resolve) => {
const backdrop = el('div', {
class: 'sheet-backdrop dialog-backdrop is-open',
onclick: (e) => {
if (e.target === backdrop) done(null);
},
});
const panel = el(
'div',
{ class: 'dialog', role: 'dialog', 'aria-modal': 'true' },
el('h2', { class: 'dialog__title' }, title),
body ? el('p', { class: 'dialog__body' }, body) : null,
el(
'div',
{ class: 'dialog__actions' },
...choices.map((c) =>
el(
'button',
{
class: `button ${c.class || ''}`.trim(),
type: 'button',
onclick: () => done(c.key ?? null),
},
c.label,
),
),
),
);
const onKey = (e) => {
if (e.key === 'Escape') done(null);
};
function done(key) {
document.removeEventListener('keydown', onKey);
backdrop.remove();
resolve(key);
}
backdrop.append(panel);
document.body.append(backdrop);
document.addEventListener('keydown', onKey);
requestAnimationFrame(() => panel.querySelector('.button')?.focus());
});
}

View File

@ -60,6 +60,29 @@ export function importFlags({ seen = [], caught = [] } = {}) {
});
}
/**
* Set seen/caught to exactly the given lists favourites and notes are
* kept, everything else's seen/caught is cleared. For "replace with this
* save" imports.
*/
export function replaceFlags({ seen = [], caught = [] } = {}) {
selection.set((s) => {
const stamp = new Date().toISOString();
const pokemon = {};
for (const [id, e] of Object.entries(s.pokemon)) {
if (e.favorite || (e.note && e.note.trim())) {
pokemon[id] = { seen: false, caught: false, favorite: !!e.favorite, note: e.note || '', updatedAt: e.updatedAt || stamp };
}
}
const bump = (id, patch) => {
pokemon[id] = { ...(pokemon[id] || BLANK), ...patch, updatedAt: stamp };
};
for (const id of seen) bump(id, { seen: true });
for (const id of caught) bump(id, { seen: true, caught: true });
return { ...s, pokemon };
});
}
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
export function stats(speciesIds) {
const p = selection.get().pokemon;

View File

@ -233,6 +233,17 @@
width: 60px;
height: 60px;
flex: none;
display: block;
text-decoration: none;
color: inherit;
border-radius: 50%;
transition: transform 0.12s var(--ease-spring);
}
.ring:hover {
transform: scale(1.06);
}
.ring:active {
transform: scale(0.95);
}
.ring__svg {
width: 100%;
@ -3366,3 +3377,44 @@
animation-duration: 0.34s;
animation-timing-function: var(--ease-spring);
}
/* ---- Centered modal dialog ---------------------------------- */
.dialog-backdrop {
align-items: center;
padding: 20px;
}
.dialog {
width: 100%;
max-width: 420px;
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 20px;
transform: translateY(12px) scale(0.97);
opacity: 0;
transition: transform 0.2s var(--ease-spring), opacity 0.2s ease;
}
.dialog-backdrop.is-open .dialog {
transform: none;
opacity: 1;
}
.dialog__title {
margin: 0;
font-size: 1.1rem;
}
.dialog__body {
margin: 8px 0 0;
font-size: 0.9rem;
color: var(--text-dim);
line-height: 1.5;
}
.dialog__actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 18px;
}
.dialog__actions .button {
width: 100%;
justify-content: center;
}

View File

@ -1,6 +1,7 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection, importFlags } from '../store/selection.js';
import { selection, importFlags, replaceFlags } from '../store/selection.js';
import { chooseDialog } from '../lib/dialog.js';
import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js';
import { shinyHunts } from '../store/shinyHunts.js';
@ -123,7 +124,7 @@ export async function SettingsView() {
storageNote.textContent = 'Storage estimate not available in this browser.';
}
// Import the Pokédex out of a Gen 3 GBA save (.sav / .srm).
// Import the Pokédex out of a Gen 1/2/3 game save (.sav / .srm).
const saveNote = el('p', { class: 'settings__note' });
const saveInput = el('input', {
type: 'file',
@ -133,21 +134,34 @@ export async function SettingsView() {
const file = e.target.files[0];
if (!file) return;
saveNote.textContent = 'Reading…';
let dex;
try {
const dex = parseSaveDex(await file.arrayBuffer());
const who = dex.trainer || dex.game;
if (
confirm(
`${dex.game}${dex.trainer ? `${dex.trainer}` : ''}\n${dex.seen.length} seen, ${dex.caught.length} caught.\n\nMerge into your Pokédex? Nothing is removed.`,
)
) {
importFlags({ seen: dex.seen, caught: dex.caught });
saveNote.textContent = `Merged ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
} else {
saveNote.textContent = '';
}
dex = parseSaveDex(await file.arrayBuffer());
} catch (err) {
saveNote.textContent = err.message || 'Could not read that save file.';
e.target.value = '';
return;
}
const who = dex.trainer || dex.game;
const choice = await chooseDialog({
title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`,
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save (${dex.caught.length} of ${
[151, 251, 386][dex.gen - 1]
} for the game). Your caught record is shared across every game imported species also count toward the National Dex and any other game they appear in.`,
choices: [
{ key: 'merge', label: 'Add to my Pokédex' },
{ key: 'replace', label: 'Replace mine with this', class: 'button--danger' },
{ key: null, label: 'Cancel', class: 'button--ghost' },
],
});
if (choice === 'merge') {
importFlags({ seen: dex.seen, caught: dex.caught });
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
} else if (choice === 'replace') {
replaceFlags({ seen: dex.seen, caught: dex.caught });
saveNote.textContent = `Replaced tracking with ${who}'s Pokédex (favourites and notes kept).`;
} else {
saveNote.textContent = '';
}
e.target.value = '';
},