mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 10:32:10 +00:00
perf(pokedex): send the box grid as packed rows with the page
The server load fetched a full combined-data page at a 9999 item page size, carrying detail text and ownership fields the grid never renders, and the client re-fetched the same payload after hydration. Load a trimmed grid row instead and pack it as positional tuples so field names are not repeated for every one of a thousand-plus entries. Entry detail is fetched on demand from the new per-entry endpoint when a cell is opened, and the grid marks itself interactive so other page-start work can queue behind it.
This commit is contained in:
@@ -1,15 +1,109 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import type { CatchRecord } from '$lib/models/CatchRecord';
|
|
||||||
import type { CombinedData } from '$lib/models/CombinedData';
|
|
||||||
import type { SharedCatchStatus, SharedCombinedData } from '$lib/models/SharedPokedex';
|
|
||||||
import { calculateBoxPlacement } from '$lib/utils/boxPlacement';
|
import { calculateBoxPlacement } from '$lib/utils/boxPlacement';
|
||||||
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
|
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
|
||||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
|
||||||
|
import type { PokedexGridRow } from '$lib/models/PokedexGridRow';
|
||||||
|
import { markGridInteractive } from '$lib/utils/criticalPageWork';
|
||||||
|
|
||||||
|
export let virtualize = false;
|
||||||
|
export let retryLoad: (() => void) | null = null;
|
||||||
|
let renderAll = false;
|
||||||
|
let visibleBoxes = new Set([1, 2, 3, 4]);
|
||||||
|
let focusedBox: number | null = null;
|
||||||
|
let grid: HTMLDivElement;
|
||||||
|
const shells = new Map<number, HTMLElement>();
|
||||||
|
let viewportFrame = 0;
|
||||||
|
let mounted = false;
|
||||||
|
export let gridKey = '';
|
||||||
|
let markedKey: string | null = null;
|
||||||
|
let scrollAnchor: { number: number; top: number } | null = null;
|
||||||
|
|
||||||
|
function measureViewport() {
|
||||||
|
viewportFrame = 0;
|
||||||
|
const next = new Set<number>();
|
||||||
|
scrollAnchor = null;
|
||||||
|
for (const [number, node] of shells) {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
if (!scrollAnchor && rect.bottom > 0) scrollAnchor = { number, top: rect.top };
|
||||||
|
const overscan = rect.height + 16;
|
||||||
|
if (rect.bottom >= -overscan && rect.top <= window.innerHeight + overscan) next.add(number);
|
||||||
|
}
|
||||||
|
visibleBoxes = next;
|
||||||
|
}
|
||||||
|
function resizeViewport() {
|
||||||
|
if (scrollAnchor && window.scrollY > 0) {
|
||||||
|
const node = shells.get(scrollAnchor.number);
|
||||||
|
if (node) window.scrollBy(0, node.getBoundingClientRect().top - scrollAnchor.top);
|
||||||
|
}
|
||||||
|
scheduleViewport();
|
||||||
|
}
|
||||||
|
function trackFocus(event: FocusEvent) {
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (!target?.closest('[data-box-number], [role="dialog"]')) focusedBox = null;
|
||||||
|
}
|
||||||
|
function scheduleViewport() {
|
||||||
|
if (!viewportFrame) viewportFrame = requestAnimationFrame(measureViewport);
|
||||||
|
}
|
||||||
|
function boxShell(node: HTMLElement, number: number) {
|
||||||
|
shells.set(number, node);
|
||||||
|
scheduleViewport();
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
shells.delete(number);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function focusEntry(index: number) {
|
||||||
|
if (!combinedData || index < 0 || index >= combinedData.length) return;
|
||||||
|
focusedBox = Math.floor(index / 30) + 1;
|
||||||
|
await tick();
|
||||||
|
grid.querySelector<HTMLElement>(`[data-entry-index="${index}"]`)?.focus();
|
||||||
|
}
|
||||||
|
function navigateEntry(event: KeyboardEvent, index: number) {
|
||||||
|
const offset = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -6, ArrowDown: 6 }[event.key];
|
||||||
|
if (offset !== undefined) {
|
||||||
|
event.preventDefault();
|
||||||
|
void focusEntry(index + offset);
|
||||||
|
} else if (event.key === 'Tab') {
|
||||||
|
const next = index + (event.shiftKey ? -1 : 1);
|
||||||
|
if (
|
||||||
|
next >= 0 &&
|
||||||
|
next < (combinedData?.length ?? 0) &&
|
||||||
|
!grid.querySelector(`[data-entry-index="${next}"]`)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
void focusEntry(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$: if (mounted && combinedData && gridKey !== markedKey) {
|
||||||
|
markedKey = gridKey;
|
||||||
|
grid?.removeAttribute('data-grid-interactive');
|
||||||
|
void tick().then(() => {
|
||||||
|
if (virtualize) markGridInteractive();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
|
const observer = new ResizeObserver(resizeViewport);
|
||||||
|
if (grid) observer.observe(grid);
|
||||||
|
window.addEventListener('scroll', scheduleViewport, { passive: true });
|
||||||
|
window.addEventListener('resize', resizeViewport);
|
||||||
|
window.addEventListener('focusin', trackFocus);
|
||||||
|
measureViewport();
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
cancelAnimationFrame(viewportFrame);
|
||||||
|
window.removeEventListener('scroll', scheduleViewport);
|
||||||
|
window.removeEventListener('resize', resizeViewport);
|
||||||
|
window.removeEventListener('focusin', trackFocus);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export let showShiny = false;
|
export let showShiny = false;
|
||||||
type DisplayData = CombinedData | SharedCombinedData;
|
type DisplayData = PokedexGridRow | SharedCombinedData;
|
||||||
type DisplayStatus = CatchRecord | SharedCatchStatus | null;
|
type DisplayStatus = DisplayData['catchRecord'];
|
||||||
|
|
||||||
export let combinedData: DisplayData[] | null;
|
export let combinedData: DisplayData[] | null;
|
||||||
export let readOnly = false;
|
export let readOnly = false;
|
||||||
@@ -119,21 +213,21 @@
|
|||||||
|
|
||||||
const BOX_VIEW_LAYOUT_STORAGE_KEY = 'livingdex:boxViewLayout:v1';
|
const BOX_VIEW_LAYOUT_STORAGE_KEY = 'livingdex:boxViewLayout:v1';
|
||||||
type BoxViewLayout = 'comfortable' | 'compact' | 'ultra';
|
type BoxViewLayout = 'comfortable' | 'compact' | 'ultra';
|
||||||
let boxViewLayout: BoxViewLayout = 'comfortable';
|
export let initialLayout: BoxViewLayout = 'comfortable';
|
||||||
|
let boxViewLayout: BoxViewLayout = initialLayout;
|
||||||
onMount(() => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(BOX_VIEW_LAYOUT_STORAGE_KEY);
|
|
||||||
if (stored === 'comfortable' || stored === 'compact' || stored === 'ultra') {
|
|
||||||
boxViewLayout = stored;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore (privacy mode / disabled storage)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function persistBoxViewLayout(next: BoxViewLayout) {
|
function persistBoxViewLayout(next: BoxViewLayout) {
|
||||||
|
const anchor = [...shells.entries()].find(
|
||||||
|
([, node]) => node.getBoundingClientRect().bottom > 0
|
||||||
|
);
|
||||||
|
const top = anchor?.[1].getBoundingClientRect().top;
|
||||||
boxViewLayout = next;
|
boxViewLayout = next;
|
||||||
|
document.cookie = `boxViewLayout=${next};path=/;max-age=31536000;SameSite=Lax`;
|
||||||
|
void tick().then(() => {
|
||||||
|
if (anchor && top !== undefined && window.scrollY > 0)
|
||||||
|
window.scrollBy(0, anchor[1].getBoundingClientRect().top - top);
|
||||||
|
measureViewport();
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(BOX_VIEW_LAYOUT_STORAGE_KEY, next);
|
localStorage.setItem(BOX_VIEW_LAYOUT_STORAGE_KEY, next);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -202,7 +296,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="flex-1 p-4 w-full">
|
<main class="flex-1 p-4 w-full">
|
||||||
<div class="max-w-fit mx-auto">
|
<div class="max-w-[1440px] w-full mx-auto">
|
||||||
{#if combinedData && combinedData.length > 0}
|
{#if combinedData && combinedData.length > 0}
|
||||||
<div class="container mx-auto">
|
<div class="container mx-auto">
|
||||||
<div class="card bg-base-100 shadow mb-4">
|
<div class="card bg-base-100 shadow mb-4">
|
||||||
@@ -212,6 +306,7 @@
|
|||||||
<span class="label-text font-semibold">Box view layout</span>
|
<span class="label-text font-semibold">Box view layout</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
data-offline-action
|
||||||
id="box-view-layout"
|
id="box-view-layout"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
bind:value={boxViewLayout}
|
bind:value={boxViewLayout}
|
||||||
@@ -222,6 +317,16 @@
|
|||||||
<option value="compact">Compact (3 boxes/row)</option>
|
<option value="compact">Compact (3 boxes/row)</option>
|
||||||
<option value="ultra">Ultra (4 boxes/row)</option>
|
<option value="ultra">Ultra (4 boxes/row)</option>
|
||||||
</select>
|
</select>
|
||||||
|
{#if virtualize}
|
||||||
|
<label class="label cursor-pointer gap-2"
|
||||||
|
><input
|
||||||
|
data-offline-action
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
bind:checked={renderAll}
|
||||||
|
/>Render all boxes</label
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span class="font-semibold">Legend:</span>
|
<span class="font-semibold">Legend:</span>
|
||||||
@@ -288,6 +393,7 @@
|
|||||||
<span class="font-semibold">Filters:</span>
|
<span class="font-semibold">Filters:</span>
|
||||||
<label class="label cursor-pointer gap-2 p-0">
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
<input
|
<input
|
||||||
|
data-offline-action
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
bind:checked={filterNotCaught}
|
bind:checked={filterNotCaught}
|
||||||
@@ -296,6 +402,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="label cursor-pointer gap-2 p-0">
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
<input
|
<input
|
||||||
|
data-offline-action
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
bind:checked={filterNeedsToEvolve}
|
bind:checked={filterNeedsToEvolve}
|
||||||
@@ -304,6 +411,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="label cursor-pointer gap-2 p-0">
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
<input
|
<input
|
||||||
|
data-offline-action
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
bind:checked={filterInHome}
|
bind:checked={filterInHome}
|
||||||
@@ -313,6 +421,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="label cursor-pointer gap-2 p-0">
|
<label class="label cursor-pointer gap-2 p-0">
|
||||||
<input
|
<input
|
||||||
|
data-offline-action
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
bind:checked={filterNotInHome}
|
bind:checked={filterNotInHome}
|
||||||
@@ -350,233 +459,252 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
bind:this={grid}
|
||||||
class="boxes-grid"
|
class="boxes-grid"
|
||||||
style="--boxes-per-row: {boxesPerRow}; --cell-padding: {cellPaddingRem}rem; --sprite-size: {spriteSizePx}px;"
|
style="--boxes-per-row: {boxesPerRow}; --cell-padding: {cellPaddingRem}rem; --sprite-size: {spriteSizePx}px;"
|
||||||
>
|
>
|
||||||
{#each boxNumbers as boxNumber}
|
{#each boxNumbers as boxNumber (boxNumber)}
|
||||||
{@const bulkMenuId = `box-${boxNumber}-bulk-menu`}
|
{@const bulkMenuId = `box-${boxNumber}-bulk-menu`}
|
||||||
<div class="mb-8">
|
<div class="box-shell" use:boxShell={boxNumber} data-box-number={boxNumber}>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4 relative z-20">
|
{#if !virtualize || renderAll || visibleBoxes.has(boxNumber) || focusedBox === boxNumber}
|
||||||
<h2 class="text-xl font-bold">Box {boxNumber}</h2>
|
<div class="box-content">
|
||||||
{#if !readOnly}<div class="relative">
|
<div
|
||||||
<button
|
class="box-heading flex items-center justify-between gap-3 mb-4 relative z-20"
|
||||||
type="button"
|
>
|
||||||
class="btn btn-sm btn-outline relative z-[210]"
|
<h2 class="text-xl font-bold">Box {boxNumber}</h2>
|
||||||
aria-label="Open bulk actions menu"
|
{#if !readOnly}<div class="relative">
|
||||||
aria-haspopup="menu"
|
<button
|
||||||
aria-controls={bulkMenuId}
|
type="button"
|
||||||
aria-expanded={openBulkMenuForBox === boxNumber}
|
class="btn btn-sm btn-outline relative z-[210]"
|
||||||
on:click={(event) => {
|
aria-label="Open bulk actions menu"
|
||||||
event.stopPropagation();
|
aria-haspopup="menu"
|
||||||
openBulkMenuForBox = openBulkMenuForBox === boxNumber ? null : boxNumber;
|
aria-controls={bulkMenuId}
|
||||||
}}
|
aria-expanded={openBulkMenuForBox === boxNumber}
|
||||||
on:keydown={(event) => {
|
on:click={(event) => {
|
||||||
if (event.key === 'Escape') openBulkMenuForBox = null;
|
event.stopPropagation();
|
||||||
}}
|
openBulkMenuForBox =
|
||||||
>
|
openBulkMenuForBox === boxNumber ? null : boxNumber;
|
||||||
⋯
|
}}
|
||||||
</button>
|
on:keydown={(event) => {
|
||||||
|
if (event.key === 'Escape') openBulkMenuForBox = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
|
||||||
{#if openBulkMenuForBox === boxNumber}
|
{#if openBulkMenuForBox === boxNumber}
|
||||||
<ul
|
<ul
|
||||||
id={bulkMenuId}
|
id={bulkMenuId}
|
||||||
class="menu bg-base-100 rounded-box absolute right-0 mt-2 z-[220] w-56 p-2 shadow border border-base-300"
|
class="menu bg-base-100 rounded-box absolute right-0 mt-2 z-[220] w-56 p-2 shadow border border-base-300"
|
||||||
>
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click|stopPropagation={() => {
|
|
||||||
markBoxAsNotCaught(boxNumber);
|
|
||||||
openBulkMenuForBox = null;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Mark box as Not caught
|
<li>
|
||||||
</button>
|
<button
|
||||||
</li>
|
type="button"
|
||||||
<li>
|
on:click|stopPropagation={() => {
|
||||||
<button
|
markBoxAsNotCaught(boxNumber);
|
||||||
type="button"
|
openBulkMenuForBox = null;
|
||||||
on:click|stopPropagation={() => {
|
}}
|
||||||
markBoxAsCaught(boxNumber);
|
>
|
||||||
openBulkMenuForBox = null;
|
Mark box as Not caught
|
||||||
}}
|
</button>
|
||||||
>
|
</li>
|
||||||
Mark box as Caught
|
<li>
|
||||||
</button>
|
<button
|
||||||
</li>
|
type="button"
|
||||||
<li>
|
on:click|stopPropagation={() => {
|
||||||
<button
|
markBoxAsCaught(boxNumber);
|
||||||
type="button"
|
openBulkMenuForBox = null;
|
||||||
on:click|stopPropagation={() => {
|
}}
|
||||||
markBoxAsNeedsToEvolve(boxNumber);
|
>
|
||||||
openBulkMenuForBox = null;
|
Mark box as Caught
|
||||||
}}
|
</button>
|
||||||
>
|
</li>
|
||||||
Mark box as Needs to evolve
|
<li>
|
||||||
</button>
|
<button
|
||||||
</li>
|
type="button"
|
||||||
<li>
|
on:click|stopPropagation={() => {
|
||||||
<button
|
markBoxAsNeedsToEvolve(boxNumber);
|
||||||
type="button"
|
openBulkMenuForBox = null;
|
||||||
on:click|stopPropagation={() => {
|
}}
|
||||||
markBoxAsInHome(boxNumber);
|
>
|
||||||
openBulkMenuForBox = null;
|
Mark box as Needs to evolve
|
||||||
}}
|
</button>
|
||||||
>
|
</li>
|
||||||
Mark box as In HOME
|
<li>
|
||||||
</button>
|
<button
|
||||||
</li>
|
type="button"
|
||||||
<li>
|
on:click|stopPropagation={() => {
|
||||||
<button
|
markBoxAsInHome(boxNumber);
|
||||||
type="button"
|
openBulkMenuForBox = null;
|
||||||
on:click|stopPropagation={() => {
|
}}
|
||||||
markBoxAsNotInHome(boxNumber);
|
>
|
||||||
openBulkMenuForBox = null;
|
Mark box as In HOME
|
||||||
}}
|
</button>
|
||||||
>
|
</li>
|
||||||
Mark box as Not in HOME
|
<li>
|
||||||
</button>
|
<button
|
||||||
</li>
|
type="button"
|
||||||
</ul>
|
on:click|stopPropagation={() => {
|
||||||
{/if}
|
markBoxAsNotInHome(boxNumber);
|
||||||
</div>{/if}
|
openBulkMenuForBox = null;
|
||||||
</div>
|
}}
|
||||||
<div class="grid grid-cols-6">
|
>
|
||||||
{#each BOX_POSITIONS as positionInBox}
|
Mark box as Not in HOME
|
||||||
{@const globalIndex = (boxNumber - 1) * POKEMON_PER_BOX + positionInBox}
|
</button>
|
||||||
{@const placement = calculateBoxPlacement(globalIndex)}
|
</li>
|
||||||
{@const entry = combinedData?.[globalIndex]}
|
</ul>
|
||||||
{@const pokedexEntry = entry?.pokedexEntry}
|
{/if}
|
||||||
{@const catchRecord = entry?.catchRecord ?? null}
|
</div>{/if}
|
||||||
{@const isFilteredOut =
|
</div>
|
||||||
!!entry && filtersActive && !!filtersKey && !matchesFilters(catchRecord)}
|
<div class="grid grid-cols-6">
|
||||||
{#if entry && pokedexEntry}
|
{#each BOX_POSITIONS as positionInBox}
|
||||||
<button
|
{@const globalIndex = (boxNumber - 1) * POKEMON_PER_BOX + positionInBox}
|
||||||
type="button"
|
{@const placement = calculateBoxPlacement(globalIndex)}
|
||||||
class="pokemon-box {cellStatusClasses(catchRecord)} {isFilteredOut
|
{@const entry = combinedData?.[globalIndex]}
|
||||||
? 'pokemon-box--filtered-out'
|
{@const pokedexEntry = entry?.pokedexEntry}
|
||||||
: 'hover:scale-105 hover:shadow-lg hover:z-50'} transition-all cursor-pointer relative"
|
{@const catchRecord = entry?.catchRecord ?? null}
|
||||||
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
|
{@const isFilteredOut =
|
||||||
|
!!entry && filtersActive && !!filtersKey && !matchesFilters(catchRecord)}
|
||||||
|
{#if entry && pokedexEntry}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pokemon-box {cellStatusClasses(catchRecord)} {isFilteredOut
|
||||||
|
? 'pokemon-box--filtered-out'
|
||||||
|
: 'hover:scale-105 hover:shadow-lg hover:z-50'} transition-all cursor-pointer relative"
|
||||||
|
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
|
||||||
{cellBackgroundColourStyle(globalIndex, catchRecord)}"
|
{cellBackgroundColourStyle(globalIndex, catchRecord)}"
|
||||||
aria-disabled={isFilteredOut}
|
data-offline-action
|
||||||
on:click={() => {
|
data-entry-index={globalIndex}
|
||||||
if (!isFilteredOut) onPokemonClick({ pokedexEntry, catchRecord });
|
data-entry-id={pokedexEntry._id}
|
||||||
}}
|
on:focus={() => (focusedBox = boxNumber)}
|
||||||
aria-label="View details for {pokedexEntry.pokemon}. Status: {statusLabel(
|
on:keydown={(event) => navigateEntry(event, globalIndex)}
|
||||||
catchRecord
|
aria-disabled={isFilteredOut}
|
||||||
)}"
|
on:click={() => {
|
||||||
>
|
if (!isFilteredOut) onPokemonClick(entry);
|
||||||
<Tooltip>
|
}}
|
||||||
<div slot="hover-target" class="w-full h-full">
|
aria-label="View details for {pokedexEntry.pokemon}{pokedexEntry.form
|
||||||
{#if catchRecord?.caught}
|
? ` (${pokedexEntry.form})`
|
||||||
<span
|
: ''}. Status: {statusLabel(catchRecord)}"
|
||||||
class="status-badge status-badge--caught absolute left-0.5 status-badge-top z-10"
|
>
|
||||||
title="Caught"
|
<span class="cell-tooltip">
|
||||||
>
|
<span class="block w-full h-full">
|
||||||
<svg
|
{#if catchRecord?.caught}
|
||||||
class="status-icon"
|
<span
|
||||||
viewBox="0 0 24 24"
|
class="status-badge status-badge--caught absolute left-0.5 status-badge-top z-10"
|
||||||
fill="none"
|
title="Caught"
|
||||||
stroke="currentColor"
|
>
|
||||||
stroke-width="3"
|
<svg
|
||||||
stroke-linecap="round"
|
class="status-icon"
|
||||||
stroke-linejoin="round"
|
viewBox="0 0 24 24"
|
||||||
aria-hidden="true"
|
fill="none"
|
||||||
>
|
stroke="currentColor"
|
||||||
<path d="M5 13l4 4L19 7" />
|
stroke-width="3"
|
||||||
</svg>
|
stroke-linecap="round"
|
||||||
<span class="sr-only">Caught</span>
|
stroke-linejoin="round"
|
||||||
</span>
|
aria-hidden="true"
|
||||||
{:else if catchRecord?.haveToEvolve}
|
>
|
||||||
<span
|
<path d="M5 13l4 4L19 7" />
|
||||||
class="status-badge status-badge--evolve absolute left-0.5 status-badge-top z-10"
|
</svg>
|
||||||
title="Caught but needs to evolve"
|
<span class="sr-only">Caught</span>
|
||||||
>
|
</span>
|
||||||
<svg
|
{:else if catchRecord?.haveToEvolve}
|
||||||
class="status-icon"
|
<span
|
||||||
viewBox="0 0 24 24"
|
class="status-badge status-badge--evolve absolute left-0.5 status-badge-top z-10"
|
||||||
fill="none"
|
title="Caught but needs to evolve"
|
||||||
stroke="currentColor"
|
>
|
||||||
stroke-width="3"
|
<svg
|
||||||
stroke-linecap="round"
|
class="status-icon"
|
||||||
stroke-linejoin="round"
|
viewBox="0 0 24 24"
|
||||||
aria-hidden="true"
|
fill="none"
|
||||||
>
|
stroke="currentColor"
|
||||||
<path d="M12 19V5" />
|
stroke-width="3"
|
||||||
<path d="M5 12l7-7 7 7" />
|
stroke-linecap="round"
|
||||||
</svg>
|
stroke-linejoin="round"
|
||||||
<span class="sr-only">Caught but needs to evolve</span>
|
aria-hidden="true"
|
||||||
</span>
|
>
|
||||||
{/if}
|
<path d="M12 19V5" />
|
||||||
{#if catchRecord?.inHome}
|
<path d="M5 12l7-7 7 7" />
|
||||||
<span
|
</svg>
|
||||||
class="status-badge status-badge--home absolute right-0.5 status-badge-top z-10"
|
<span class="sr-only">Caught but needs to evolve</span>
|
||||||
title="In Pokémon HOME"
|
</span>
|
||||||
>
|
{/if}
|
||||||
<svg
|
{#if catchRecord?.inHome}
|
||||||
class="status-icon"
|
<span
|
||||||
viewBox="0 0 24 24"
|
class="status-badge status-badge--home absolute right-0.5 status-badge-top z-10"
|
||||||
fill="currentColor"
|
title="In Pokémon HOME"
|
||||||
stroke="currentColor"
|
>
|
||||||
stroke-width="2"
|
<svg
|
||||||
stroke-linecap="round"
|
class="status-icon"
|
||||||
stroke-linejoin="round"
|
viewBox="0 0 24 24"
|
||||||
aria-hidden="true"
|
fill="currentColor"
|
||||||
>
|
stroke="currentColor"
|
||||||
<path
|
stroke-width="2"
|
||||||
d="M12 3 3 10.5V21a1 1 0 0 0 1 1h5v-6h6v6h5a1 1 0 0 0 1-1V10.5L12 3Z"
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 3 3 10.5V21a1 1 0 0 0 1 1h5v-6h6v6h5a1 1 0 0 0 1-1V10.5L12 3Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">In HOME</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<div class="pokemon-box-inner">
|
||||||
|
<PokemonSprite
|
||||||
|
pokemonName={pokedexEntry.pokemon}
|
||||||
|
pokedexNumber={pokedexEntry.pokedexNumber}
|
||||||
|
form={pokedexEntry.form}
|
||||||
|
spriteKey={pokedexEntry.spriteKey}
|
||||||
|
shiny={showShiny}
|
||||||
|
variant="grid"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</div>
|
||||||
<span class="sr-only">In HOME</span>
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
<span class="cell-tooltip-text" role="tooltip">
|
||||||
<div class="pokemon-box-inner">
|
<div class="font-bold">
|
||||||
<PokemonSprite
|
{pokedexEntry.pokemon}
|
||||||
pokemonName={pokedexEntry.pokemon}
|
{pokedexEntry.form ? `(${pokedexEntry.form})` : ''}
|
||||||
pokedexNumber={pokedexEntry.pokedexNumber}
|
</div>
|
||||||
form={pokedexEntry.form}
|
<div>{pokedexEntry.pokedexNumber.toString().padStart(3, '0')}</div>
|
||||||
spriteKey={pokedexEntry.spriteKey}
|
<div>
|
||||||
shiny={showShiny}
|
Caught: {catchRecord?.caught ? 'Yes' : 'No'} <br />
|
||||||
/>
|
Caught but needs to Evolve: {catchRecord?.haveToEvolve
|
||||||
</div>
|
? 'Yes'
|
||||||
</div>
|
: 'No'}
|
||||||
<div slot="tooltip">
|
<br />
|
||||||
<div class="font-bold">
|
In Home: {catchRecord?.inHome ? 'Yes' : 'No'}
|
||||||
{pokedexEntry.pokemon}
|
</div>
|
||||||
{pokedexEntry.form ? `(${pokedexEntry.form})` : ''}
|
</span>
|
||||||
</div>
|
</span>
|
||||||
<div>{pokedexEntry.pokedexNumber.toString().padStart(3, '0')}</div>
|
</button>
|
||||||
<div>
|
{:else}
|
||||||
Caught: {catchRecord?.caught ? 'Yes' : 'No'} <br />
|
<button
|
||||||
Caught but needs to Evolve: {catchRecord?.haveToEvolve ? 'Yes' : 'No'}
|
type="button"
|
||||||
<br />
|
class="pokemon-box pokemon-box--empty"
|
||||||
In Home: {catchRecord?.inHome ? 'Yes' : 'No'}
|
disabled
|
||||||
</div>
|
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pokemon-box pokemon-box--empty"
|
|
||||||
disabled
|
|
||||||
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
|
|
||||||
{cellBackgroundColourStyle(globalIndex, null)}"
|
{cellBackgroundColourStyle(globalIndex, null)}"
|
||||||
aria-label="Empty box slot"
|
aria-label="Empty box slot"
|
||||||
>
|
>
|
||||||
<div class="pokemon-box-inner" aria-hidden="true">
|
<div class="pokemon-box-inner" aria-hidden="true">
|
||||||
<span class="sprite-placeholder" />
|
<span class="sprite-placeholder" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if failedToLoad}
|
{:else if failedToLoad}
|
||||||
{#if creatingRecords && totalRecordsCreated > 0}
|
{#if retryLoad}
|
||||||
|
<p role="alert">Unable to load Pokédex.</p>
|
||||||
|
<button class="btn" on:click={retryLoad}>Retry loading Pokédex</button>
|
||||||
|
{:else if creatingRecords && totalRecordsCreated > 0}
|
||||||
<p>Processed {totalRecordsCreated} Pokédex entries so far...</p>
|
<p>Processed {totalRecordsCreated} Pokédex entries so far...</p>
|
||||||
<p>Please be patient, this may take some time.</p>
|
<p>Please be patient, this may take some time.</p>
|
||||||
{:else if creatingRecords}
|
{:else if creatingRecords}
|
||||||
@@ -592,6 +720,8 @@
|
|||||||
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button>
|
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if combinedData}
|
||||||
|
<p>No entries match this Pokédex.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="min-w-max mx-auto">
|
<div class="min-w-max mx-auto">
|
||||||
<h1>Loading Pokédex</h1>
|
<h1>Loading Pokédex</h1>
|
||||||
@@ -602,6 +732,43 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.box-shell {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.box-shell::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
padding-top: calc(83.333333% + 80px);
|
||||||
|
}
|
||||||
|
.box-content {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 32px;
|
||||||
|
}
|
||||||
|
.box-heading {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.cell-tooltip {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.cell-tooltip-text {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
background: #1f2937;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
width: 13rem;
|
||||||
|
}
|
||||||
|
.pokemon-box:hover .cell-tooltip-text,
|
||||||
|
.pokemon-box:focus-visible .cell-tooltip-text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Theme-aware backgrounds for non-caught box slots.
|
Theme-aware backgrounds for non-caught box slots.
|
||||||
- Light mode (`pokeball`) keeps the original exact colors.
|
- Light mode (`pokeball`) keeps the original exact colors.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { PokedexEntry } from './PokedexEntry';
|
||||||
|
import type { CatchRecord } from './CatchRecord';
|
||||||
|
|
||||||
|
/** Complete ordering/status data, without detail text or repeated ownership fields. */
|
||||||
|
export type PokedexGridRow = {
|
||||||
|
pokedexEntry: Pick<
|
||||||
|
PokedexEntry,
|
||||||
|
'_id' | 'pokemon' | 'pokedexNumber' | 'form' | 'spriteKey' | 'canGigantamax'
|
||||||
|
>;
|
||||||
|
catchRecord: Pick<
|
||||||
|
CatchRecord,
|
||||||
|
'_id' | 'caught' | 'haveToEvolve' | 'inHome' | 'hasGigantamaxed'
|
||||||
|
> | null;
|
||||||
|
};
|
||||||
|
export type CatchRecordPatch = Partial<CatchRecord> &
|
||||||
|
Pick<CatchRecord, 'userId' | 'pokedexId' | 'pokemonId'>;
|
||||||
|
|
||||||
|
/** Version 1 transport rows: avoid repeating field names 1,000+ times. IDs remain available. */
|
||||||
|
export type PackedGridRow = [
|
||||||
|
entryId: string,
|
||||||
|
number: number,
|
||||||
|
name: string,
|
||||||
|
form: string,
|
||||||
|
spriteKey: string,
|
||||||
|
canGigantamax: boolean,
|
||||||
|
catchId: string | null,
|
||||||
|
status: number
|
||||||
|
];
|
||||||
|
export function packGrid(rows: PokedexGridRow[]): PackedGridRow[] {
|
||||||
|
return rows.map(({ pokedexEntry: e, catchRecord: c }) => [
|
||||||
|
e._id,
|
||||||
|
e.pokedexNumber,
|
||||||
|
e.pokemon,
|
||||||
|
e.form,
|
||||||
|
e.spriteKey,
|
||||||
|
e.canGigantamax,
|
||||||
|
c?._id ?? null,
|
||||||
|
(c?.caught ? 1 : 0) |
|
||||||
|
(c?.haveToEvolve ? 2 : 0) |
|
||||||
|
(c?.inHome ? 4 : 0) |
|
||||||
|
(c?.hasGigantamaxed ? 8 : 0)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
export function unpackGrid(rows: PackedGridRow[]): PokedexGridRow[] {
|
||||||
|
return rows.map(([id, number, name, form, spriteKey, canGigantamax, catchId, status]) => ({
|
||||||
|
pokedexEntry: { _id: id, pokedexNumber: number, pokemon: name, form, spriteKey, canGigantamax },
|
||||||
|
catchRecord:
|
||||||
|
catchId === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
_id: catchId,
|
||||||
|
caught: !!(status & 1),
|
||||||
|
haveToEvolve: !!(status & 2),
|
||||||
|
inHome: !!(status & 4),
|
||||||
|
hasGigantamaxed: !!(status & 8)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { PokedexGridRow } from '$lib/models/PokedexGridRow';
|
||||||
import { type PokedexEntry, type PokedexEntryDB } from '$lib/models/PokedexEntry';
|
import { type PokedexEntry, type PokedexEntryDB } from '$lib/models/PokedexEntry';
|
||||||
import { type CatchRecord, type CatchRecordDB } from '$lib/models/CatchRecord';
|
import { type CatchRecord, type CatchRecordDB } from '$lib/models/CatchRecord';
|
||||||
import { type CombinedData } from '$lib/models/CombinedData';
|
import { type CombinedData } from '$lib/models/CombinedData';
|
||||||
@@ -20,9 +21,17 @@ class CombinedDataRepository {
|
|||||||
constructor(
|
constructor(
|
||||||
private supabase: SupabaseClient,
|
private supabase: SupabaseClient,
|
||||||
private userId: string | null,
|
private userId: string | null,
|
||||||
private pokedexId: string | null
|
private pokedexId: string | null,
|
||||||
|
private compact = false
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private scopedEntries = new Map<string, Promise<PokedexEntryDB[]>>();
|
||||||
|
private get entryColumns() {
|
||||||
|
return this.compact
|
||||||
|
? 'id,pokedexNumber,pokemon,form,spriteKey,canGigantamax,unownSortOrder,formSortBucket,formSortRegionOrder,formSortRegionalSub,formSortLabel'
|
||||||
|
: '*';
|
||||||
|
}
|
||||||
|
|
||||||
// Transform Supabase data to match frontend expectations (minimal transformation)
|
// Transform Supabase data to match frontend expectations (minimal transformation)
|
||||||
private transformPokedexEntry(entry: PokedexEntryDB): PokedexEntry {
|
private transformPokedexEntry(entry: PokedexEntryDB): PokedexEntry {
|
||||||
return {
|
return {
|
||||||
@@ -57,7 +66,7 @@ class CombinedDataRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildEntriesQuery(enableForms: boolean, region: string, game: string) {
|
private buildEntriesQuery(enableForms: boolean, region: string, game: string) {
|
||||||
let query = this.supabase.from('pokedex_entries').select('*');
|
let query = this.supabase.from('pokedex_entries').select(this.entryColumns);
|
||||||
|
|
||||||
if (!enableForms) {
|
if (!enableForms) {
|
||||||
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
||||||
@@ -86,7 +95,10 @@ class CombinedDataRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildDexEntriesQuery(dexScopes: string[], enableForms: boolean, region: string) {
|
private buildDexEntriesQuery(dexScopes: string[], enableForms: boolean, region: string) {
|
||||||
let query = this.supabase.from('game_pokedex_entry_details').select('*').in('dexId', dexScopes);
|
let query = this.supabase
|
||||||
|
.from('game_pokedex_entry_details')
|
||||||
|
.select(this.compact ? `${this.entryColumns},dexNumber,dexSortOrder` : '*')
|
||||||
|
.in('dexId', dexScopes);
|
||||||
|
|
||||||
if (!enableForms) {
|
if (!enableForms) {
|
||||||
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
||||||
@@ -128,7 +140,10 @@ class CombinedDataRepository {
|
|||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const end = start + maxRows - 1;
|
const end = start + maxRows - 1;
|
||||||
let query = this.supabase.from('pokedex_entries').select('*').not('form', 'is', null);
|
let query = this.supabase
|
||||||
|
.from('pokedex_entries')
|
||||||
|
.select(this.entryColumns)
|
||||||
|
.not('form', 'is', null);
|
||||||
|
|
||||||
if (game) {
|
if (game) {
|
||||||
query = query.contains('gamesToCatchIn', [game]);
|
query = query.contains('gamesToCatchIn', [game]);
|
||||||
@@ -140,13 +155,12 @@ class CombinedDataRepository {
|
|||||||
const { data, error } = await query.order('id', { ascending: true }).range(start, end);
|
const { data, error } = await query.order('id', { ascending: true }).range(start, end);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error fetching forms for game:', error);
|
throw new Error('Unable to load form entries');
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) break;
|
if (!data || data.length === 0) break;
|
||||||
|
|
||||||
allForms.push(...(data as PokedexEntryDB[]).filter((e) => !excludeIds.has(e.id)));
|
allForms.push(...(data as unknown as PokedexEntryDB[]).filter((e) => !excludeIds.has(e.id)));
|
||||||
|
|
||||||
if (data.length < maxRows) break;
|
if (data.length < maxRows) break;
|
||||||
start = end + 1;
|
start = end + 1;
|
||||||
@@ -155,7 +169,17 @@ class CombinedDataRepository {
|
|||||||
return allForms;
|
return allForms;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchAllDexEntries(
|
private fetchAllDexEntries(dexScopes: string[], enableForms: boolean, region: string, game = '') {
|
||||||
|
const key = JSON.stringify([dexScopes, enableForms, region, game]);
|
||||||
|
let entries = this.scopedEntries.get(key);
|
||||||
|
if (!entries) {
|
||||||
|
entries = this.readAllDexEntries(dexScopes, enableForms, region, game);
|
||||||
|
this.scopedEntries.set(key, entries);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAllDexEntries(
|
||||||
dexScopes: string[],
|
dexScopes: string[],
|
||||||
enableForms: boolean,
|
enableForms: boolean,
|
||||||
region: string,
|
region: string,
|
||||||
@@ -175,15 +199,14 @@ class CombinedDataRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error finding dex-scoped combined data:', error);
|
throw new Error('Unable to load dex entries');
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push(...(data as RawDexEntry[]));
|
entries.push(...(data as unknown as RawDexEntry[]));
|
||||||
|
|
||||||
if (data.length < maxRows) {
|
if (data.length < maxRows) {
|
||||||
break;
|
break;
|
||||||
@@ -283,15 +306,14 @@ class CombinedDataRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error finding paginated combined data:', error);
|
throw new Error('Unable to load dex entries');
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push(...data);
|
entries.push(...(data as unknown as PokedexEntryDB[]));
|
||||||
|
|
||||||
if (data.length < end - start + 1) {
|
if (data.length < end - start + 1) {
|
||||||
break;
|
break;
|
||||||
@@ -320,15 +342,14 @@ class CombinedDataRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error finding combined data:', error);
|
throw new Error('Unable to load dex entries');
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push(...data);
|
entries.push(...(data as unknown as PokedexEntryDB[]));
|
||||||
|
|
||||||
if (data.length < maxRows) {
|
if (data.length < maxRows) {
|
||||||
break;
|
break;
|
||||||
@@ -351,24 +372,79 @@ class CombinedDataRepository {
|
|||||||
const chunk = entryIds.slice(i, i + chunkSize);
|
const chunk = entryIds.slice(i, i + chunkSize);
|
||||||
const { data, error } = await this.supabase
|
const { data, error } = await this.supabase
|
||||||
.from('catch_records')
|
.from('catch_records')
|
||||||
.select('*')
|
.select(this.compact ? 'id,pokemonId,caught,haveToEvolve,inHome,hasGigantamaxed' : '*')
|
||||||
.eq('userId', userId)
|
.eq('userId', userId)
|
||||||
.eq('pokedexId', this.pokedexId)
|
.eq('pokedexId', this.pokedexId)
|
||||||
.in('pokemonId', chunk);
|
.in('pokemonId', chunk);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error loading catch records:', error);
|
throw new Error('Unable to load catch records');
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
records.push(...data);
|
records.push(...(data as unknown as CatchRecordDB[]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findGridEntries(enableForms: boolean, game: string, dexScopes: string[]) {
|
||||||
|
if (!this.compact) throw new Error('Grid reads require a compact repository');
|
||||||
|
const entries = dexScopes.length
|
||||||
|
? this.dedupeEntries(await this.fetchAllDexEntries(dexScopes, enableForms, '', game))
|
||||||
|
: await this.fetchAllEntries(enableForms, '', game);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinGridCatches(entries: PokedexEntryDB[]): Promise<PokedexGridRow[]> {
|
||||||
|
const catches = new Map(
|
||||||
|
(
|
||||||
|
await this.fetchCatchRecords(
|
||||||
|
entries.map((e) => e.id),
|
||||||
|
this.userId!
|
||||||
|
)
|
||||||
|
).map((r) => [r.pokemonId, r])
|
||||||
|
);
|
||||||
|
return entries.map((e) => {
|
||||||
|
const c = catches.get(e.id);
|
||||||
|
return {
|
||||||
|
pokedexEntry: {
|
||||||
|
_id: String(e.id),
|
||||||
|
pokemon: e.pokemon,
|
||||||
|
pokedexNumber: e.pokedexNumber,
|
||||||
|
form: e.form || '',
|
||||||
|
spriteKey: e.spriteKey || '',
|
||||||
|
canGigantamax: e.canGigantamax
|
||||||
|
},
|
||||||
|
catchRecord: c
|
||||||
|
? {
|
||||||
|
_id: c.id,
|
||||||
|
caught: c.caught,
|
||||||
|
haveToEvolve: c.haveToEvolve,
|
||||||
|
inHome: c.inHome,
|
||||||
|
hasGigantamaxed: c.hasGigantamaxed
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEntryDetail(entryId: number): Promise<CombinedData | null> {
|
||||||
|
const { data, error } = await this.supabase
|
||||||
|
.from('pokedex_entries')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', entryId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw new Error('Unable to load entry details');
|
||||||
|
if (!data) return null;
|
||||||
|
const catches = await this.fetchCatchRecords([entryId], this.userId!);
|
||||||
|
return {
|
||||||
|
pokedexEntry: this.transformPokedexEntry(data),
|
||||||
|
catchRecord: catches[0] ? this.transformCatchRecord(catches[0]) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async findAllCombinedData(
|
async findAllCombinedData(
|
||||||
userId: string,
|
userId: string,
|
||||||
enableForms: boolean = true,
|
enableForms: boolean = true,
|
||||||
@@ -392,9 +468,9 @@ class CombinedDataRepository {
|
|||||||
catchRecords = await this.fetchCatchRecords(entryIds, userId);
|
catchRecords = await this.fetchCatchRecords(entryIds, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine the data exactly like master branch
|
const catchesById = new Map(catchRecords.map((record) => [record.pokemonId, record]));
|
||||||
const combinedData = entries.map((entry) => {
|
const combinedData = entries.map((entry) => {
|
||||||
const userCatchRecord = catchRecords.find((record) => record.pokemonId === entry.id) || null;
|
const userCatchRecord = catchesById.get(entry.id) || null;
|
||||||
|
|
||||||
const transformedEntry = this.transformPokedexEntry(entry);
|
const transformedEntry = this.transformPokedexEntry(entry);
|
||||||
const transformedCatchRecord = userCatchRecord
|
const transformedCatchRecord = userCatchRecord
|
||||||
@@ -440,9 +516,9 @@ class CombinedDataRepository {
|
|||||||
catchRecords = await this.fetchCatchRecords(entryIds, userId);
|
catchRecords = await this.fetchCatchRecords(entryIds, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine the data exactly like master branch
|
const catchesById = new Map(catchRecords.map((record) => [record.pokemonId, record]));
|
||||||
const combinedData = entries.map((entry) => {
|
const combinedData = entries.map((entry) => {
|
||||||
const userCatchRecord = catchRecords.find((record) => record.pokemonId === entry.id) || null;
|
const userCatchRecord = catchesById.get(entry.id) || null;
|
||||||
|
|
||||||
const transformedEntry = this.transformPokedexEntry(entry);
|
const transformedEntry = this.transformPokedexEntry(entry);
|
||||||
const transformedCatchRecord = userCatchRecord
|
const transformedCatchRecord = userCatchRecord
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
|
import { resolveDexScopes } from './PokedexDexScopeService';
|
||||||
|
import type { PokedexPerformance } from '$lib/server/pokedexPerformance';
|
||||||
|
|
||||||
|
export async function loadPokedexGrid(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
pokedex: Pokedex,
|
||||||
|
timings?: PokedexPerformance
|
||||||
|
) {
|
||||||
|
const measure = <T>(stage: 'scopes' | 'entries' | 'catches', run: () => Promise<T>) =>
|
||||||
|
timings ? timings.measure(stage, run) : run();
|
||||||
|
const scopes = await measure('scopes', () => resolveDexScopes(supabase, pokedex));
|
||||||
|
const repo = new CombinedDataRepository(supabase, userId, pokedex._id, true);
|
||||||
|
const entries = await measure('entries', () =>
|
||||||
|
repo.findGridEntries(pokedex.isFormDex, pokedex.gameScope || '', scopes)
|
||||||
|
);
|
||||||
|
return measure('catches', () => repo.joinGridCatches(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPokedexEntryDetail(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
pokedex: Pokedex,
|
||||||
|
entryId: number
|
||||||
|
) {
|
||||||
|
const scopes = await resolveDexScopes(supabase, pokedex);
|
||||||
|
const membership = new CombinedDataRepository(supabase, userId, pokedex._id, true);
|
||||||
|
const entries = await membership.findGridEntries(
|
||||||
|
pokedex.isFormDex,
|
||||||
|
pokedex.gameScope || '',
|
||||||
|
scopes
|
||||||
|
);
|
||||||
|
if (!entries.some((entry) => entry.id === entryId)) return null;
|
||||||
|
return new CombinedDataRepository(supabase, userId, pokedex._id).findEntryDetail(entryId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** Shared scheduling boundary for optional page-start work. Explicit refreshes bypass it. */
|
||||||
|
const READY_EVENT = 'livingdex:grid-interactive';
|
||||||
|
export function markGridInteractive() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!document.querySelector('[data-entry-index]')) return;
|
||||||
|
if (!document.querySelector('[data-grid-interactive]')) {
|
||||||
|
performance.mark('pokedex:first-interactive');
|
||||||
|
}
|
||||||
|
document
|
||||||
|
.querySelector('[data-entry-index]')
|
||||||
|
?.closest('.boxes-grid')
|
||||||
|
?.setAttribute('data-grid-interactive', 'true');
|
||||||
|
window.dispatchEvent(new Event(READY_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afterCriticalPageWork(run: () => void): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
let scheduled = false;
|
||||||
|
let idle: number | undefined;
|
||||||
|
let frame: number | undefined;
|
||||||
|
const perform = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancel();
|
||||||
|
run();
|
||||||
|
};
|
||||||
|
const schedule = () => {
|
||||||
|
if (scheduled || cancelled) return;
|
||||||
|
scheduled = true;
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
if ('requestIdleCallback' in window)
|
||||||
|
idle = window.requestIdleCallback(perform, { timeout: 5000 });
|
||||||
|
else frame = requestAnimationFrame(perform);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const fallback = window.setTimeout(perform, 5000);
|
||||||
|
const cancel = () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(fallback);
|
||||||
|
window.removeEventListener(READY_EVENT, schedule);
|
||||||
|
if (idle !== undefined) window.cancelIdleCallback(idle);
|
||||||
|
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
window.addEventListener(READY_EVENT, schedule);
|
||||||
|
if (
|
||||||
|
!location.pathname.startsWith('/pokedex/') ||
|
||||||
|
document.querySelector('[data-grid-interactive]')
|
||||||
|
)
|
||||||
|
schedule();
|
||||||
|
return cancel;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
|
import { loadPokedexEntryDetail } from '$lib/services/PokedexGridService';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
const userId = await requireAuth(event);
|
||||||
|
const entryId = Number(event.params.entryId);
|
||||||
|
if (!Number.isSafeInteger(entryId) || entryId < 1) throw error(400, 'Invalid entry');
|
||||||
|
const pokedex = await new PokedexRepository(event.locals.supabase, userId).findById(
|
||||||
|
event.params.id
|
||||||
|
);
|
||||||
|
if (!pokedex) throw error(404, 'Pokédex not found');
|
||||||
|
const detail = await loadPokedexEntryDetail(event.locals.supabase, userId, pokedex, entryId);
|
||||||
|
if (!detail) throw error(404, 'Entry not found');
|
||||||
|
return json(detail, { headers: { 'cache-control': 'private, no-store' } });
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { packGrid } from '$lib/models/PokedexGridRow';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
|
import { loadPokedexGrid } from '$lib/services/PokedexGridService';
|
||||||
|
import { PokedexPerformance } from '$lib/server/pokedexPerformance';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
const timings = new PokedexPerformance();
|
||||||
|
const userId = await timings.measure('auth', () => requireAuth(event));
|
||||||
|
const pokedex = await timings.measure('ownership', () =>
|
||||||
|
new PokedexRepository(event.locals.supabase, userId).findById(event.params.id)
|
||||||
|
);
|
||||||
|
if (!pokedex) throw error(404, 'Pokédex not found');
|
||||||
|
const grid = await loadPokedexGrid(event.locals.supabase, userId, pokedex, timings);
|
||||||
|
const packed = timings.prepare(() => packGrid(grid));
|
||||||
|
timings.recordAuth(event.locals.pokedexAuthMs);
|
||||||
|
const timing = timings.finish();
|
||||||
|
return json(
|
||||||
|
{ grid: packed },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'cache-control': 'private, no-store',
|
||||||
|
...(timing ? { 'server-timing': timing } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,47 +1,33 @@
|
|||||||
|
import { packGrid } from '$lib/models/PokedexGridRow';
|
||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
|
import { loadPokedexGrid } from '$lib/services/PokedexGridService';
|
||||||
|
import { PokedexPerformance } from '$lib/server/pokedexPerformance';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
// Must match the page's itemsPerPage: the box view needs the whole dex in one page.
|
export const load: PageServerLoad = async ({ locals, params, setHeaders, cookies }) => {
|
||||||
const INITIAL_PAGE_SIZE = 9999;
|
const timings = new PokedexPerformance();
|
||||||
|
const { session, user } = await timings.measure('auth', () => locals.safeGetSession());
|
||||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
if (!session || !user) throw redirect(303, '/signin');
|
||||||
const { safeGetSession, supabase } = locals;
|
const pokedex = await timings.measure('ownership', () =>
|
||||||
const { session, user } = await safeGetSession();
|
new PokedexRepository(locals.supabase, user.id).findById(params.id)
|
||||||
|
);
|
||||||
// Require authentication
|
if (!pokedex) throw error(404, 'Pokédex not found');
|
||||||
if (!session || !user) {
|
let grid = null;
|
||||||
throw redirect(303, '/signin');
|
try {
|
||||||
|
grid = await loadPokedexGrid(locals.supabase, user.id, pokedex, timings);
|
||||||
|
} catch {
|
||||||
|
console.error('Unable to load Pokédex grid');
|
||||||
}
|
}
|
||||||
|
const packed = timings.prepare(() => (grid ? packGrid(grid) : null));
|
||||||
const { id } = params;
|
timings.recordAuth(locals.pokedexAuthMs);
|
||||||
|
const timing = timings.finish();
|
||||||
// Fetch pokédex to verify ownership (RLS will also block, but we want a proper 404)
|
setHeaders({
|
||||||
const repo = new PokedexRepository(supabase, user.id);
|
'cache-control': 'private, no-store',
|
||||||
const pokedex = await repo.findById(id);
|
...(timing ? { 'server-timing': timing } : {})
|
||||||
|
});
|
||||||
if (!pokedex) {
|
const layout = cookies.get('boxViewLayout');
|
||||||
// Either doesn't exist or user doesn't own it
|
const boxViewLayout: 'comfortable' | 'compact' | 'ultra' =
|
||||||
throw error(404, 'Pokédex not found');
|
layout === 'compact' || layout === 'ultra' ? layout : 'comfortable';
|
||||||
}
|
return { pokedex, grid: packed, boxViewLayout };
|
||||||
|
|
||||||
// Streamed rather than awaited: the page shell renders straight away and the entries arrive in
|
|
||||||
// the same response, instead of the browser requesting them after hydration. A failure resolves
|
|
||||||
// to null so the page falls back to fetching (and reporting) through the API.
|
|
||||||
const initialCombinedData = loadCombinedDataPage(supabase, user.id, pokedex, {
|
|
||||||
page: 1,
|
|
||||||
limit: INITIAL_PAGE_SIZE,
|
|
||||||
enableForms: pokedex.isFormDex
|
|
||||||
})
|
|
||||||
.then((result) => result.combinedData)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('Unable to preload combined data', err);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
pokedex,
|
|
||||||
initialCombinedData
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import { user } from '$lib/stores/user.js';
|
import { user } from '$lib/stores/user.js';
|
||||||
import { type User } from '@supabase/auth-js';
|
import { type User } from '@supabase/auth-js';
|
||||||
import { type CombinedData } from '$lib/models/CombinedData';
|
import { type CombinedData } from '$lib/models/CombinedData';
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
import { readOfflineEntry, requestOfflineSync } from '$lib/stores/offlineSync';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import {
|
import {
|
||||||
PROVIDER_LABELS,
|
PROVIDER_LABELS,
|
||||||
@@ -25,7 +25,12 @@
|
|||||||
refreshBackupStatus
|
refreshBackupStatus
|
||||||
} from '$lib/stores/backupStatus';
|
} from '$lib/stores/backupStatus';
|
||||||
import type { ExportProvider } from '$lib/models/PokedexExportIntegration';
|
import type { ExportProvider } from '$lib/models/PokedexExportIntegration';
|
||||||
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
|
import {
|
||||||
|
unpackGrid,
|
||||||
|
type PokedexGridRow,
|
||||||
|
type CatchRecordPatch
|
||||||
|
} from '$lib/models/PokedexGridRow';
|
||||||
|
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@@ -42,19 +47,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let combinedData = null as CombinedData[] | null;
|
let combinedData: PokedexGridRow[] | null = null;
|
||||||
let currentPage = 1 as number;
|
|
||||||
// Box view requires the full dataset for correct box numbering/placement.
|
|
||||||
// If/when a paginated list view is introduced, this can be lowered and paired with UI controls.
|
|
||||||
let itemsPerPage = 9999 as number;
|
|
||||||
type CatchUpdateEvent = CustomEvent<{
|
type CatchUpdateEvent = CustomEvent<{
|
||||||
catchRecord: CatchRecord;
|
catchRecord: CatchRecord;
|
||||||
source: 'toggle' | 'notes' | 'notes-blur';
|
source: 'toggle' | 'notes' | 'notes-blur';
|
||||||
|
changes?: Partial<CatchRecord>;
|
||||||
}>;
|
}>;
|
||||||
let creatingRecords = false;
|
let creatingRecords = false;
|
||||||
let totalRecordsCreated = 0;
|
let totalRecordsCreated = 0;
|
||||||
let failedToLoad = false;
|
let failedToLoad = false;
|
||||||
let localUser: User | null;
|
let localUser: User | null = data.user ?? null;
|
||||||
|
let userStoreReady = false;
|
||||||
let boxNumbers: number[] = [];
|
let boxNumbers: number[] = [];
|
||||||
let showModal = false;
|
let showModal = false;
|
||||||
let selectedPokemon: CombinedData | null = null;
|
let selectedPokemon: CombinedData | null = null;
|
||||||
@@ -62,6 +65,7 @@
|
|||||||
let shareUrl = '';
|
let shareUrl = '';
|
||||||
let shareFeedback = '';
|
let shareFeedback = '';
|
||||||
let nativeShareSupported = false;
|
let nativeShareSupported = false;
|
||||||
|
let online = true;
|
||||||
|
|
||||||
let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null;
|
let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null;
|
||||||
let catchWriteQueueKey: string | null = null;
|
let catchWriteQueueKey: string | null = null;
|
||||||
@@ -170,9 +174,13 @@
|
|||||||
$: showShiny = !!pokedex?.isShinyDex;
|
$: showShiny = !!pokedex?.isShinyDex;
|
||||||
|
|
||||||
const unsubscribe = user.subscribe((value) => {
|
const unsubscribe = user.subscribe((value) => {
|
||||||
localUser = value;
|
if (userStoreReady) localUser = value;
|
||||||
});
|
});
|
||||||
onDestroy(unsubscribe);
|
onDestroy(unsubscribe);
|
||||||
|
onDestroy(() => {
|
||||||
|
detailRequest++;
|
||||||
|
detailAbort?.abort();
|
||||||
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
catchWriteQueueUnsubscribe?.();
|
catchWriteQueueUnsubscribe?.();
|
||||||
catchWriteQueueUnsubscribe = null;
|
catchWriteQueueUnsubscribe = null;
|
||||||
@@ -181,9 +189,74 @@
|
|||||||
resetExportState();
|
resetExportState();
|
||||||
});
|
});
|
||||||
|
|
||||||
function openPokemonModal(pokemon: CombinedData | SharedCombinedData) {
|
let selectedSummary: PokedexGridRow | null = null;
|
||||||
selectedPokemon = pokemon as CombinedData;
|
let detailError = '';
|
||||||
|
let detailRequest = 0;
|
||||||
|
let detailAbort: AbortController | null = null;
|
||||||
|
let returnFocus: HTMLElement | null = null;
|
||||||
|
const detailCache = new Map<string, CombinedData>();
|
||||||
|
let detailOwner = '';
|
||||||
|
$: if (localUser?.id !== detailOwner) {
|
||||||
|
detailOwner = localUser?.id ?? '';
|
||||||
|
detailCache.clear();
|
||||||
|
closePokemonModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPokemonModal(pokemon: PokedexGridRow) {
|
||||||
|
if (!showModal) returnFocus = document.activeElement as HTMLElement;
|
||||||
|
selectedSummary = pokemon;
|
||||||
|
selectedPokemon = null;
|
||||||
|
detailError = '';
|
||||||
showModal = true;
|
showModal = true;
|
||||||
|
const request = ++detailRequest;
|
||||||
|
detailAbort?.abort();
|
||||||
|
detailAbort = new AbortController();
|
||||||
|
const id = pokedexId;
|
||||||
|
const owner = localUser?.id || '';
|
||||||
|
const entryId = pokemon.pokedexEntry._id;
|
||||||
|
const key = `${owner}:${id}:${entryId}`;
|
||||||
|
try {
|
||||||
|
let detail = detailCache.get(key);
|
||||||
|
if (!detail) {
|
||||||
|
if (!navigator.onLine) detail = (await readOfflineEntry(owner, id, entryId)) ?? undefined;
|
||||||
|
else {
|
||||||
|
const response = await fetch(`/api/pokedexes/${id}/entries/${entryId}`, {
|
||||||
|
signal: detailAbort.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Unable to load details. Please retry.');
|
||||||
|
detail = await response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (request !== detailRequest || id !== pokedexId || owner !== localUser?.id) return;
|
||||||
|
if (!detail) throw new Error('These details are not saved for offline use.');
|
||||||
|
detailCache.set(key, detail);
|
||||||
|
// A detail response must not undo status changes made while it was in flight.
|
||||||
|
const current = combinedData?.find((row) => row.pokedexEntry._id === entryId)?.catchRecord;
|
||||||
|
const pending = catchWriteQueue?.getPendingPatch(entryId);
|
||||||
|
selectedPokemon = {
|
||||||
|
...detail,
|
||||||
|
catchRecord:
|
||||||
|
detail.catchRecord || current || pending
|
||||||
|
? {
|
||||||
|
_id: '',
|
||||||
|
userId: owner,
|
||||||
|
pokedexId: id,
|
||||||
|
pokemonId: entryId,
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: false,
|
||||||
|
inHome: false,
|
||||||
|
hasGigantamaxed: false,
|
||||||
|
personalNotes: '',
|
||||||
|
...detail.catchRecord,
|
||||||
|
...current,
|
||||||
|
...pending
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (request !== detailRequest || id !== pokedexId || owner !== localUser?.id) return;
|
||||||
|
detailError = error instanceof Error ? error.message : 'Unable to load details.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShareModal() {
|
function openShareModal() {
|
||||||
@@ -223,15 +296,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closePokemonModal() {
|
function closePokemonModal() {
|
||||||
|
detailRequest++;
|
||||||
|
detailAbort?.abort();
|
||||||
showModal = false;
|
showModal = false;
|
||||||
selectedPokemon = null;
|
selectedPokemon = null;
|
||||||
|
selectedSummary = null;
|
||||||
|
if (browser && returnFocus) {
|
||||||
|
const target = returnFocus;
|
||||||
|
void tick().then(() => target.isConnected && target.focus());
|
||||||
|
}
|
||||||
|
returnFocus = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCatchWriteQueue() {
|
function ensureCatchWriteQueue() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
if (!pokedexId) return;
|
if (!pokedexId) return;
|
||||||
if (!localUser?.id) return;
|
if (!localUser?.id) return;
|
||||||
const desiredKey = `${localUser.id}:${pokedexId}`;
|
const ownerId = localUser.id;
|
||||||
|
const desiredKey = `${ownerId}:${pokedexId}`;
|
||||||
if (catchWriteQueue && catchWriteQueueKey === desiredKey) return;
|
if (catchWriteQueue && catchWriteQueueKey === desiredKey) return;
|
||||||
|
|
||||||
resetExportState();
|
resetExportState();
|
||||||
@@ -244,7 +326,8 @@
|
|||||||
endpointUrl: `/api/pokedexes/${pokedexId}/catch-records`,
|
endpointUrl: `/api/pokedexes/${pokedexId}/catch-records`,
|
||||||
fetchFn: fetch,
|
fetchFn: fetch,
|
||||||
batchSize: 200,
|
batchSize: 200,
|
||||||
concurrency: 1
|
concurrency: 1,
|
||||||
|
isCurrentUser: () => get(user)?.id === ownerId
|
||||||
});
|
});
|
||||||
catchWriteQueueKey = desiredKey;
|
catchWriteQueueKey = desiredKey;
|
||||||
|
|
||||||
@@ -271,67 +354,93 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
|
let editGeneration = 0;
|
||||||
|
function applyOptimisticCatchRecordUpdate(next: CatchRecordPatch) {
|
||||||
if (!combinedData) return;
|
if (!combinedData) return;
|
||||||
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
|
editGeneration++;
|
||||||
if (idx === -1) return;
|
combinedData = combinedData.map((row) =>
|
||||||
// Replace the catchRecord entry with the updated version.
|
row.pokedexEntry._id === next.pokemonId
|
||||||
const current = combinedData[idx];
|
? {
|
||||||
const patched: CombinedData = {
|
...row,
|
||||||
...current,
|
catchRecord: {
|
||||||
catchRecord: {
|
_id: '',
|
||||||
...(current.catchRecord ?? next),
|
caught: false,
|
||||||
...next
|
haveToEvolve: false,
|
||||||
}
|
inHome: false,
|
||||||
};
|
hasGigantamaxed: false,
|
||||||
combinedData = [...combinedData.slice(0, idx), patched, ...combinedData.slice(idx + 1)];
|
...row.catchRecord,
|
||||||
|
...next
|
||||||
if (selectedPokemon?.pokedexEntry._id === next.pokemonId) {
|
}
|
||||||
selectedPokemon = patched;
|
}
|
||||||
}
|
: row
|
||||||
|
);
|
||||||
|
const key = `${next.userId}:${next.pokedexId}:${next.pokemonId}`;
|
||||||
|
const cached = detailCache.get(key);
|
||||||
|
if (cached)
|
||||||
|
detailCache.set(key, {
|
||||||
|
...cached,
|
||||||
|
catchRecord: {
|
||||||
|
_id: '',
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: false,
|
||||||
|
inHome: false,
|
||||||
|
hasGigantamaxed: false,
|
||||||
|
personalNotes: '',
|
||||||
|
...cached.catchRecord,
|
||||||
|
...next
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (selectedPokemon?.pokedexEntry._id === next.pokemonId)
|
||||||
|
selectedPokemon = {
|
||||||
|
...selectedPokemon,
|
||||||
|
catchRecord: {
|
||||||
|
_id: '',
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: false,
|
||||||
|
inHome: false,
|
||||||
|
hasGigantamaxed: false,
|
||||||
|
personalNotes: '',
|
||||||
|
...selectedPokemon.catchRecord,
|
||||||
|
...next
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModalCatchUpdate(event: CatchUpdateEvent) {
|
async function handleModalCatchUpdate(event: CatchUpdateEvent) {
|
||||||
await updateACatch(event);
|
await updateACatch(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetDataOptions = {
|
let gridRequest = 0;
|
||||||
page?: number;
|
async function getData({ setCombinedDataToNull = true } = {}) {
|
||||||
perPage?: number;
|
const id = pokedexId;
|
||||||
setCombinedDataToNull?: boolean;
|
const owner = localUser?.id;
|
||||||
};
|
const request = ++gridRequest;
|
||||||
|
const generation = editGeneration;
|
||||||
async function getData({
|
if (setCombinedDataToNull) combinedData = null;
|
||||||
page = currentPage,
|
failedToLoad = false;
|
||||||
perPage = itemsPerPage,
|
try {
|
||||||
setCombinedDataToNull = true
|
const response = await fetch(`/api/pokedexes/${id}/grid`);
|
||||||
}: GetDataOptions = {}) {
|
if (!response.ok) throw new Error('Unable to load grid');
|
||||||
if (!pokedex || !pokedexId) return;
|
const result = await response.json();
|
||||||
if (setCombinedDataToNull) {
|
if (
|
||||||
combinedData = null;
|
request !== gridRequest ||
|
||||||
}
|
id !== pokedexId ||
|
||||||
const effectivePage = Math.max(1, page);
|
owner !== localUser?.id ||
|
||||||
const effectivePerPage = Math.max(1, perPage);
|
generation !== editGeneration
|
||||||
// Use new pokédex-scoped endpoint
|
)
|
||||||
const endpoint = `/api/pokedexes/${pokedexId}/combined-data?page=${effectivePage}&limit=${effectivePerPage}&enableForms=${pokedex.isFormDex}`;
|
return;
|
||||||
|
combinedData = unpackGrid(result.grid);
|
||||||
const response = await fetch(endpoint);
|
detailCache.clear();
|
||||||
const fetchedData = await response.json();
|
} catch {
|
||||||
if (fetchedData.error) {
|
if (request === gridRequest && id === pokedexId && owner === localUser?.id)
|
||||||
failedToLoad = true;
|
failedToLoad = true;
|
||||||
return;
|
|
||||||
}
|
|
||||||
combinedData = fetchedData.combinedData;
|
|
||||||
// Always extract box numbers for box view
|
|
||||||
if (combinedData) {
|
|
||||||
boxNumbers = calculateBoxNumbers(combinedData.length);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateACatch(event: CatchUpdateEvent) {
|
async function updateACatch(event: CatchUpdateEvent) {
|
||||||
if (!pokedexId) return;
|
if (!pokedexId) return;
|
||||||
ensureCatchWriteQueue();
|
ensureCatchWriteQueue();
|
||||||
const { catchRecord, source } = event.detail;
|
const { catchRecord, source, changes } = event.detail;
|
||||||
// Enforce mutual exclusivity (should be impossible to have both true).
|
// Enforce mutual exclusivity (should be impossible to have both true).
|
||||||
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
|
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
|
||||||
if (sanitizedCatchRecord.caught) {
|
if (sanitizedCatchRecord.caught) {
|
||||||
@@ -346,11 +455,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic UI: update local state immediately.
|
// Optimistic UI: update local state immediately.
|
||||||
applyOptimisticCatchRecordUpdate(sanitizedCatchRecord);
|
const patch: CatchRecordPatch = {
|
||||||
|
userId: localUser.id,
|
||||||
|
pokedexId,
|
||||||
|
pokemonId: sanitizedCatchRecord.pokemonId,
|
||||||
|
...(changes ??
|
||||||
|
(source === 'toggle'
|
||||||
|
? {
|
||||||
|
caught: sanitizedCatchRecord.caught,
|
||||||
|
haveToEvolve: sanitizedCatchRecord.haveToEvolve,
|
||||||
|
inHome: sanitizedCatchRecord.inHome,
|
||||||
|
hasGigantamaxed: sanitizedCatchRecord.hasGigantamaxed
|
||||||
|
}
|
||||||
|
: { personalNotes: sanitizedCatchRecord.personalNotes }))
|
||||||
|
};
|
||||||
|
applyOptimisticCatchRecordUpdate(patch);
|
||||||
|
|
||||||
// Queue a background write with coalescing.
|
// Queue a background write with coalescing.
|
||||||
const debounceMs = source === 'notes' ? 650 : 0;
|
const debounceMs = source === 'notes' ? 650 : 0;
|
||||||
catchWriteQueue?.enqueue(sanitizedCatchRecord, {
|
catchWriteQueue?.enqueue(patch, {
|
||||||
debounceMs,
|
debounceMs,
|
||||||
flushSoon: true
|
flushSoon: true
|
||||||
});
|
});
|
||||||
@@ -375,37 +498,14 @@
|
|||||||
if (!pokedexId) return;
|
if (!pokedexId) return;
|
||||||
ensureCatchWriteQueue();
|
ensureCatchWriteQueue();
|
||||||
|
|
||||||
const catchRecordsToUpdate: CatchRecord[] = combinedData
|
const catchRecordsToUpdate: CatchRecordPatch[] = combinedData
|
||||||
.filter((_, index) => calculateBoxPlacement(index).box === boxNumber)
|
.filter((_, index) => calculateBoxPlacement(index).box === boxNumber)
|
||||||
.map(({ pokedexEntry, catchRecord }) => {
|
.map(({ pokedexEntry }) => ({
|
||||||
// Create default record if null
|
userId: localUser?.id || '',
|
||||||
const baseRecord: CatchRecord = catchRecord ?? {
|
pokedexId,
|
||||||
_id: '',
|
pokemonId: pokedexEntry._id,
|
||||||
userId: localUser?.id || '',
|
...(inHome !== null ? { inHome } : { caught, haveToEvolve: needsToEvolve })
|
||||||
pokemonId: pokedexEntry._id,
|
}));
|
||||||
pokedexId: pokedexId,
|
|
||||||
haveToEvolve: false,
|
|
||||||
caught: false,
|
|
||||||
inHome: false,
|
|
||||||
hasGigantamaxed: false,
|
|
||||||
personalNotes: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
let updatedRecord: CatchRecord = { ...baseRecord };
|
|
||||||
if (inHome !== null) {
|
|
||||||
updatedRecord = {
|
|
||||||
...updatedRecord,
|
|
||||||
inHome
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
updatedRecord = {
|
|
||||||
...updatedRecord,
|
|
||||||
caught,
|
|
||||||
haveToEvolve: needsToEvolve
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return updatedRecord;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optimistic patch: apply locally first.
|
// Optimistic patch: apply locally first.
|
||||||
for (const record of catchRecordsToUpdate) {
|
for (const record of catchRecordsToUpdate) {
|
||||||
@@ -498,42 +598,25 @@
|
|||||||
|
|
||||||
creatingRecords = false;
|
creatingRecords = false;
|
||||||
failedToLoad = false;
|
failedToLoad = false;
|
||||||
await getData({ page: currentPage, perPage: itemsPerPage });
|
await getData();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show data whenever the dex or pagination changes (client-side only). The first page is streamed
|
let shownData: PageData | undefined;
|
||||||
// from the server load, so it only needs fetching when that failed or the page changes.
|
$: if (data !== shownData) {
|
||||||
let shownKey = '';
|
shownData = data;
|
||||||
function showPage(
|
localUser = data.user ?? null;
|
||||||
id: string,
|
gridRequest++;
|
||||||
page: number,
|
closePokemonModal();
|
||||||
perPage: number,
|
detailCache.clear();
|
||||||
initial: Promise<CombinedData[] | null> | undefined
|
combinedData = data.grid ? unpackGrid(data.grid) : null;
|
||||||
) {
|
failedToLoad = data.grid === null;
|
||||||
const key = `${id}:${page}:${perPage}`;
|
|
||||||
if (key === shownKey) return;
|
|
||||||
shownKey = key;
|
|
||||||
if (page !== 1 || !initial) {
|
|
||||||
void getData({ page, perPage });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
combinedData = null;
|
|
||||||
void initial.then((rows) => {
|
|
||||||
if (shownKey !== key) return;
|
|
||||||
if (!rows) {
|
|
||||||
void getData({ page, perPage });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
combinedData = rows;
|
|
||||||
boxNumbers = calculateBoxNumbers(rows.length);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
$: if (browser && pokedexId)
|
$: boxNumbers = calculateBoxNumbers(combinedData?.length ?? 0);
|
||||||
showPage(pokedexId, currentPage, itemsPerPage, data?.initialCombinedData);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
userStoreReady = true;
|
||||||
nativeShareSupported = typeof navigator.share === 'function';
|
nativeShareSupported = typeof navigator.share === 'function';
|
||||||
|
|
||||||
const flushKeepalive = () => {
|
const flushKeepalive = () => {
|
||||||
@@ -546,7 +629,15 @@
|
|||||||
if (document.visibilityState === 'hidden') flushKeepalive();
|
if (document.visibilityState === 'hidden') flushKeepalive();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOnline = () => void catchWriteQueue?.flushNow();
|
online = navigator.onLine;
|
||||||
|
const onOffline = () => {
|
||||||
|
online = false;
|
||||||
|
};
|
||||||
|
const onOnline = () => {
|
||||||
|
online = true;
|
||||||
|
void catchWriteQueue?.flushNow();
|
||||||
|
};
|
||||||
|
window.addEventListener('offline', onOffline);
|
||||||
|
|
||||||
window.addEventListener('pagehide', flushKeepalive);
|
window.addEventListener('pagehide', flushKeepalive);
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
@@ -557,13 +648,14 @@
|
|||||||
if (!pokedexId) return;
|
if (!pokedexId) return;
|
||||||
if (creatingRecords) return;
|
if (creatingRecords) return;
|
||||||
if (catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0) return;
|
if (catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0) return;
|
||||||
void getData({ page: currentPage, perPage: itemsPerPage, setCombinedDataToNull: false });
|
void getData({ setCombinedDataToNull: false });
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('pagehide', flushKeepalive);
|
window.removeEventListener('pagehide', flushKeepalive);
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
window.removeEventListener('online', onOnline);
|
window.removeEventListener('online', onOnline);
|
||||||
|
window.removeEventListener('offline', onOffline);
|
||||||
window.clearInterval(reconcileInterval);
|
window.clearInterval(reconcileInterval);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -767,7 +859,7 @@
|
|||||||
<!-- Box View -->
|
<!-- Box View -->
|
||||||
<PokedexViewBoxes
|
<PokedexViewBoxes
|
||||||
{showShiny}
|
{showShiny}
|
||||||
bind:combinedData
|
{combinedData}
|
||||||
bind:boxNumbers
|
bind:boxNumbers
|
||||||
bind:creatingRecords
|
bind:creatingRecords
|
||||||
{totalRecordsCreated}
|
{totalRecordsCreated}
|
||||||
@@ -778,22 +870,59 @@
|
|||||||
{markBoxAsInHome}
|
{markBoxAsInHome}
|
||||||
{markBoxAsNotInHome}
|
{markBoxAsNotInHome}
|
||||||
{createCatchRecords}
|
{createCatchRecords}
|
||||||
onPokemonClick={openPokemonModal}
|
retryLoad={() => getData()}
|
||||||
|
virtualize={true}
|
||||||
|
gridKey={pokedexId}
|
||||||
|
initialLayout={data.boxViewLayout}
|
||||||
|
onPokemonClick={(row) => {
|
||||||
|
const own = combinedData?.find((entry) => entry.pokedexEntry._id === row.pokedexEntry._id);
|
||||||
|
if (own) void openPokemonModal(own);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showModal && selectedPokemon}
|
{#if showModal && selectedSummary}
|
||||||
<PokedexModal isOpen={showModal} onClose={closePokemonModal}>
|
<PokedexModal isOpen={showModal} onClose={closePokemonModal}>
|
||||||
<PokedexEntryCatchRecord
|
{#if selectedPokemon}
|
||||||
pokedexEntry={selectedPokemon.pokedexEntry}
|
<PokedexEntryCatchRecord
|
||||||
bind:catchRecord={selectedPokemon.catchRecord}
|
pokedexEntry={selectedPokemon.pokedexEntry}
|
||||||
{showOrigins}
|
bind:catchRecord={selectedPokemon.catchRecord}
|
||||||
showForms={pokedex.isFormDex}
|
{showOrigins}
|
||||||
{showShiny}
|
showForms={pokedex.isFormDex}
|
||||||
userId={localUser?.id}
|
{showShiny}
|
||||||
{pokedexId}
|
userId={localUser?.id}
|
||||||
on:updateCatch={handleModalCatchUpdate}
|
{pokedexId}
|
||||||
/>
|
on:updateCatch={handleModalCatchUpdate}
|
||||||
|
readOnly={!online}
|
||||||
|
sharedCatchStatus={selectedPokemon.catchRecord}
|
||||||
|
/>
|
||||||
|
{#if !online && selectedPokemon.catchRecord?.personalNotes}<p class="p-6">
|
||||||
|
Notes: {selectedPokemon.catchRecord.personalNotes}
|
||||||
|
</p>{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="p-6" aria-busy={!detailError}>
|
||||||
|
<h2 class="text-xl font-bold">{selectedSummary.pokedexEntry.pokemon}</h2>
|
||||||
|
<div class="w-64 h-64">
|
||||||
|
<PokemonSprite
|
||||||
|
pokemonName={selectedSummary.pokedexEntry.pokemon}
|
||||||
|
pokedexNumber={selectedSummary.pokedexEntry.pokedexNumber}
|
||||||
|
form={selectedSummary.pokedexEntry.form}
|
||||||
|
spriteKey={selectedSummary.pokedexEntry.spriteKey}
|
||||||
|
shiny={showShiny}
|
||||||
|
loadingStrategy="eager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if detailError}
|
||||||
|
<p role="alert">{detailError}</p>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-offline-action
|
||||||
|
on:click={() => selectedSummary && openPokemonModal(selectedSummary)}
|
||||||
|
>Retry details</button
|
||||||
|
>
|
||||||
|
{:else}<p role="status">Loading details…</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</PokedexModal>
|
</PokedexModal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,12 @@
|
|||||||
showShiny={shared.isShinyDex}
|
showShiny={shared.isShinyDex}
|
||||||
combinedData={shared.combinedData}
|
combinedData={shared.combinedData}
|
||||||
{boxNumbers}
|
{boxNumbers}
|
||||||
onPokemonClick={handlePokemonClick}
|
onPokemonClick={(row) => {
|
||||||
|
const full = shared.combinedData.find(
|
||||||
|
(entry) => entry.pokedexEntry._id === row.pokedexEntry._id
|
||||||
|
);
|
||||||
|
if (full) handlePokemonClick(full);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Feature: Signed-in page speed
|
|||||||
Scenario: A Pokédex opens without a second round trip for its entries
|
Scenario: A Pokédex opens without a second round trip for its entries
|
||||||
When I load the Pokédex page directly
|
When I load the Pokédex page directly
|
||||||
Then its entries appear within 5 seconds
|
Then its entries appear within 5 seconds
|
||||||
And the browser did not request the entries separately
|
And the browser did not request the grid separately
|
||||||
|
|
||||||
Scenario: Moving between my Pokédex list and a Pokédex is quick
|
Scenario: Moving between my Pokédex list and a Pokédex is quick
|
||||||
When I switch between my Pokédex list and the Pokédex
|
When I switch between my Pokédex list and the Pokédex
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ When('I load the Pokédex page directly', async ({ page, state }) => {
|
|||||||
// Only requests made while the page first loads matter; the page's 60s reconciliation refetch
|
// Only requests made while the page first loads matter; the page's 60s reconciliation refetch
|
||||||
// can't fire within this window.
|
// can't fire within this window.
|
||||||
const countEntryRequests = (request: { url(): string }) => {
|
const countEntryRequests = (request: { url(): string }) => {
|
||||||
if (/\/api\/pokedexes\/[^/]+\/combined-data/.test(request.url())) record.entryRequests++;
|
if (/\/api\/pokedexes\/[^/]+\/(?:grid|combined-data)/.test(request.url()))
|
||||||
|
record.entryRequests++;
|
||||||
};
|
};
|
||||||
page.on('request', countEntryRequests);
|
page.on('request', countEntryRequests);
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@ Then('its entries appear within {int} seconds', async ({ page }, seconds: number
|
|||||||
expect(entriesMs!).toBeLessThan(seconds * 1000);
|
expect(entriesMs!).toBeLessThan(seconds * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
Then('the browser did not request the entries separately', async ({ page }) => {
|
Then('the browser did not request the grid separately', async ({ page }) => {
|
||||||
// The server load streams the first page of entries with the HTML, so the page must not make
|
// The server load includes the compact grid with the HTML, so the page must not make
|
||||||
// the old hydrate-then-fetch round trip.
|
// the old hydrate-then-fetch round trip.
|
||||||
expect(timings.get(page)?.entryRequests).toBe(0);
|
expect(timings.get(page)?.entryRequests).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { packGrid, unpackGrid } from '$lib/models/PokedexGridRow';
|
||||||
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import { beforeAll, afterAll, describe, expect, it } from 'vitest';
|
||||||
|
import { requireLoopbackUrl } from '../support/loopback';
|
||||||
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
|
import CatchRecordRepository from '$lib/repositories/CatchRecordRepository';
|
||||||
|
import { loadPokedexGrid, loadPokedexEntryDetail } from '$lib/services/PokedexGridService';
|
||||||
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
|
|
||||||
|
const url = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
describe('compact Pokédex and partial catch writes', () => {
|
||||||
|
let admin: SupabaseClient;
|
||||||
|
let client: SupabaseClient;
|
||||||
|
let owner = '';
|
||||||
|
let national: Pokedex;
|
||||||
|
let scoped: Pokedex;
|
||||||
|
let firstId: string;
|
||||||
|
let secondId: string;
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!serviceKey || !anonKey) throw new Error('Use npm run test:integration');
|
||||||
|
admin = createClient(url, serviceKey, { auth: { persistSession: false } });
|
||||||
|
const email = `grid-${crypto.randomUUID()}@example.test`;
|
||||||
|
const password = crypto.randomUUID();
|
||||||
|
const created = await admin.auth.admin.createUser({ email, password, email_confirm: true });
|
||||||
|
if (created.error) throw created.error;
|
||||||
|
owner = created.data.user.id;
|
||||||
|
client = createClient(url, anonKey, { auth: { persistSession: false } });
|
||||||
|
const signed = await client.auth.signInWithPassword({ email, password });
|
||||||
|
if (signed.error) throw signed.error;
|
||||||
|
const repo = new PokedexRepository(client, owner);
|
||||||
|
national = await repo.create({ name: 'Grid national', isFormDex: false });
|
||||||
|
scoped = await repo.create({ name: 'Grid forms', isFormDex: true, gameScope: 'Scarlet' });
|
||||||
|
const links = await client
|
||||||
|
.from('pokedex_dex_scopes')
|
||||||
|
.insert({ pokedexId: scoped._id, dexId: 'scarlet-paldea' });
|
||||||
|
if (links.error) throw links.error;
|
||||||
|
scoped = (await repo.findById(scoped._id))!;
|
||||||
|
const grid = await loadPokedexGrid(client, owner, national);
|
||||||
|
[firstId, secondId] = grid.slice(0, 2).map((row) => row.pokedexEntry._id);
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
if (owner) await admin.auth.admin.deleteUser(owner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads saved scopes with ownership and prevents cross-account reads', async () => {
|
||||||
|
expect(scoped.dexScopes).toEqual(['scarlet-paldea']);
|
||||||
|
const other = new PokedexRepository(client, crypto.randomUUID());
|
||||||
|
expect(await other.findById(scoped._id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches full ordering and statuses while reducing serialized rows by at least 60%', async () => {
|
||||||
|
for (const dex of [national, scoped]) {
|
||||||
|
const initial = await loadPokedexGrid(client, owner, dex);
|
||||||
|
const catches = new CatchRecordRepository(client, owner, dex._id);
|
||||||
|
for (let offset = 0; offset < initial.length - 1; offset += 500)
|
||||||
|
await catches.bulkUpsert(
|
||||||
|
initial.slice(offset, Math.min(offset + 500, initial.length - 1)).map((row, index) => ({
|
||||||
|
pokemonId: row.pokedexEntry._id,
|
||||||
|
caught: index % 3 === 0,
|
||||||
|
inHome: index % 7 === 0,
|
||||||
|
personalNotes: ''
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const grid = await loadPokedexGrid(client, owner, dex);
|
||||||
|
const full = await new CombinedDataRepository(client, owner, dex._id).findAllCombinedData(
|
||||||
|
owner,
|
||||||
|
dex.isFormDex,
|
||||||
|
'',
|
||||||
|
dex.gameScope || '',
|
||||||
|
dex.dexScopes
|
||||||
|
);
|
||||||
|
expect(grid.map((row) => row.pokedexEntry._id)).toEqual(
|
||||||
|
full.map((row) => row.pokedexEntry._id)
|
||||||
|
);
|
||||||
|
expect(grid.length).toBeGreaterThan(400);
|
||||||
|
expect(unpackGrid(packGrid(grid))).toEqual(grid);
|
||||||
|
expect(JSON.stringify(packGrid(grid)).length).toBeLessThan(JSON.stringify(full).length * 0.4);
|
||||||
|
expect(JSON.stringify(grid)).not.toContain('personalNotes');
|
||||||
|
expect(JSON.stringify(grid)).not.toContain('catchInformation');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves omitted notes and statuses in mixed partial bulk writes, including new records', async () => {
|
||||||
|
const repo = new CatchRecordRepository(client, owner, national._id);
|
||||||
|
await repo.bulkUpsert([
|
||||||
|
{ pokemonId: firstId, personalNotes: 'Keep this note', caught: true, inHome: true }
|
||||||
|
]);
|
||||||
|
await repo.bulkUpsert([
|
||||||
|
{ pokemonId: firstId, haveToEvolve: true, caught: false },
|
||||||
|
{ pokemonId: secondId, personalNotes: 'New record' }
|
||||||
|
]);
|
||||||
|
expect(await repo.findByUserAndPokemon(owner, firstId, national._id)).toMatchObject({
|
||||||
|
personalNotes: 'Keep this note',
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: true,
|
||||||
|
inHome: true
|
||||||
|
});
|
||||||
|
expect(await repo.findByUserAndPokemon(owner, secondId, national._id)).toMatchObject({
|
||||||
|
personalNotes: 'New record',
|
||||||
|
caught: false
|
||||||
|
});
|
||||||
|
await repo.bulkUpsert([{ pokemonId: firstId, personalNotes: '' }]);
|
||||||
|
expect(await repo.findByUserAndPokemon(owner, firstId, national._id)).toMatchObject({
|
||||||
|
personalNotes: '',
|
||||||
|
inHome: true,
|
||||||
|
haveToEvolve: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns full details only for members of this dex', async () => {
|
||||||
|
const detail = await loadPokedexEntryDetail(client, owner, national, Number(firstId));
|
||||||
|
expect(detail?.pokedexEntry).toHaveProperty('catchInformation');
|
||||||
|
expect(detail?.catchRecord).toHaveProperty('personalNotes');
|
||||||
|
expect(await loadPokedexEntryDetail(client, owner, national, 999999)).toBeNull();
|
||||||
|
const forms = await loadPokedexGrid(client, owner, scoped);
|
||||||
|
const base = new Set(
|
||||||
|
(await loadPokedexGrid(client, owner, national)).map((row) => row.pokedexEntry._id)
|
||||||
|
);
|
||||||
|
const formOnly = forms.find((row) => !base.has(row.pokedexEntry._id));
|
||||||
|
expect(formOnly).toBeDefined();
|
||||||
|
expect(
|
||||||
|
await loadPokedexEntryDetail(client, owner, national, Number(formOnly!.pokedexEntry._id))
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,9 @@ type TableQuery = { table: string; calls: Call[] };
|
|||||||
* `await query` / `await query.range(...)` resolve like a real PostgREST response. Returning
|
* `await query` / `await query.range(...)` resolve like a real PostgREST response. Returning
|
||||||
* an empty data set keeps the repository's paging loops to a single iteration.
|
* an empty data set keeps the repository's paging loops to a single iteration.
|
||||||
*/
|
*/
|
||||||
function createSupabaseStub() {
|
function createSupabaseStub(
|
||||||
|
resultFor: (table: string) => unknown = () => ({ data: [], error: null, count: 0 })
|
||||||
|
) {
|
||||||
const queries: TableQuery[] = [];
|
const queries: TableQuery[] = [];
|
||||||
|
|
||||||
const from = (table: string) => {
|
const from = (table: string) => {
|
||||||
@@ -23,8 +25,7 @@ function createSupabaseStub() {
|
|||||||
{
|
{
|
||||||
get(_target, prop: string) {
|
get(_target, prop: string) {
|
||||||
if (prop === 'then') {
|
if (prop === 'then') {
|
||||||
return (resolve: (value: unknown) => unknown) =>
|
return (resolve: (value: unknown) => unknown) => resolve(resultFor(table));
|
||||||
resolve({ data: [], error: null, count: 0 });
|
|
||||||
}
|
}
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
record.calls.push({ method: prop, args });
|
record.calls.push({ method: prop, args });
|
||||||
@@ -115,3 +116,121 @@ describe('CombinedDataRepository base-form filtering', () => {
|
|||||||
expect(mentionsIsDefaultForm(supplement)).toBe(false);
|
expect(mentionsIsDefaultForm(supplement)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('compact grid reads', () => {
|
||||||
|
const entry = {
|
||||||
|
id: 1,
|
||||||
|
pokedexNumber: 1,
|
||||||
|
pokemon: 'Bulbasaur',
|
||||||
|
form: null,
|
||||||
|
spriteKey: '1',
|
||||||
|
canGigantamax: false
|
||||||
|
};
|
||||||
|
it('joins catch flags by ID and retains entries without catches', async () => {
|
||||||
|
const { supabase } = createSupabaseStub((table) => ({
|
||||||
|
data:
|
||||||
|
table === 'catch_records'
|
||||||
|
? [{ id: 'catch', pokemonId: 1, caught: true, personalNotes: 'private' }]
|
||||||
|
: [entry, { ...entry, id: 2 }],
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
|
||||||
|
const rows = await repo.joinGridCatches(await repo.findGridEntries(false, '', []));
|
||||||
|
expect(rows[0].catchRecord).toMatchObject({ _id: 'catch', caught: true });
|
||||||
|
expect(rows[0].catchRecord).not.toHaveProperty('personalNotes');
|
||||||
|
expect(rows[1].catchRecord).toBeNull();
|
||||||
|
});
|
||||||
|
it('deduplicates overlapping scopes without dropping named form supplements', async () => {
|
||||||
|
const base = { ...entry, pokemon: 'Rotom', form: 'Lightbulb', dexNumber: 1, dexSortOrder: 1 };
|
||||||
|
const { supabase } = createSupabaseStub((table) => ({
|
||||||
|
data:
|
||||||
|
table === 'game_pokedex_entry_details' ? [base, base] : [{ ...base, id: 2, form: 'Heat' }],
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
|
||||||
|
const rows = await repo.findGridEntries(true, 'Black', ['one', 'two']);
|
||||||
|
expect(rows.map((row) => [row.id, row.form])).toEqual([
|
||||||
|
[1, 'Lightbulb'],
|
||||||
|
[2, 'Heat']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it.each([[[]], [['scope']]])(
|
||||||
|
'reports entry query failure instead of an empty grid (%j)',
|
||||||
|
async (scopes) => {
|
||||||
|
const { supabase } = createSupabaseStub(() => ({
|
||||||
|
data: null,
|
||||||
|
error: { message: 'unavailable' }
|
||||||
|
}));
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
|
||||||
|
await expect(repo.findGridEntries(false, '', scopes)).rejects.toThrow('Unable to load');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it('reports catch failure instead of displaying everything as uncaught', async () => {
|
||||||
|
const { supabase } = createSupabaseStub((table) =>
|
||||||
|
table === 'catch_records'
|
||||||
|
? { data: null, error: { message: 'unavailable' } }
|
||||||
|
: { data: [entry], error: null }
|
||||||
|
);
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
|
||||||
|
await expect(repo.joinGridCatches(await repo.findGridEntries(false, '', []))).rejects.toThrow(
|
||||||
|
'Unable to load catch records'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('keeps a failed detail read distinct from a missing entry', async () => {
|
||||||
|
const missing = createSupabaseStub(() => ({ data: null, error: null }));
|
||||||
|
expect(
|
||||||
|
await new CombinedDataRepository(missing.supabase, 'owner', 'dex').findEntryDetail(1)
|
||||||
|
).toBeNull();
|
||||||
|
const failed = createSupabaseStub(() => ({ data: null, error: { message: 'unavailable' } }));
|
||||||
|
await expect(
|
||||||
|
new CombinedDataRepository(failed.supabase, 'owner', 'dex').findEntryDetail(1)
|
||||||
|
).rejects.toThrow('Unable to load entry details');
|
||||||
|
});
|
||||||
|
it.each([false, true])(
|
||||||
|
'returns full instructions with optional catch notes (caught: %s)',
|
||||||
|
async (caught) => {
|
||||||
|
const { supabase } = createSupabaseStub((table) => ({
|
||||||
|
data:
|
||||||
|
table === 'catch_records'
|
||||||
|
? caught
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'catch',
|
||||||
|
pokemonId: 1,
|
||||||
|
userId: 'owner',
|
||||||
|
pokedexId: 'dex',
|
||||||
|
personalNotes: 'Saved note'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
: { ...entry, catchInformation: 'Full instructions' },
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
const result = await new CombinedDataRepository(supabase, 'owner', 'dex').findEntryDetail(1);
|
||||||
|
expect(result?.pokedexEntry.catchInformation).toBe('Full instructions');
|
||||||
|
if (caught) expect(result?.catchRecord?.personalNotes).toBe('Saved note');
|
||||||
|
else expect(result?.catchRecord).toBeNull();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it('shares a scoped read between simultaneous rows and count requests', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub();
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'owner', 'dex');
|
||||||
|
await Promise.all([
|
||||||
|
repo.findCombinedData('owner', 1, 30, true, '', 'Scarlet', ['scarlet-paldea']),
|
||||||
|
repo.countCombinedData(true, '', 'Scarlet', ['scarlet-paldea'])
|
||||||
|
]);
|
||||||
|
expect(queryFor(queries, 'game_pokedex_entry_details')).toHaveLength(1);
|
||||||
|
expect(queryFor(queries, 'pokedex_entries')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it('selects compact columns and does not count the full grid', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub();
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
|
||||||
|
await repo.joinGridCatches(await repo.findGridEntries(false, '', []));
|
||||||
|
expect(queries).toHaveLength(1);
|
||||||
|
const selection = queries[0].calls.find((call) => call.method === 'select');
|
||||||
|
expect(selection?.args[0]).not.toContain('*');
|
||||||
|
expect(selection?.args[0]).not.toContain('notes');
|
||||||
|
expect(selection?.args[0]).not.toContain('Information');
|
||||||
|
expect(selection?.args).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { afterCriticalPageWork, markGridInteractive } from '$lib/utils/criticalPageWork';
|
||||||
|
|
||||||
|
describe('optional work scheduling', () => {
|
||||||
|
let events: EventTarget & Record<string, unknown>;
|
||||||
|
let idle: (() => void) | undefined;
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
events = Object.assign(new EventTarget(), {
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestIdleCallback: vi.fn((callback: () => void) => {
|
||||||
|
idle = callback;
|
||||||
|
return 1;
|
||||||
|
}),
|
||||||
|
cancelIdleCallback: vi.fn()
|
||||||
|
});
|
||||||
|
idle = undefined;
|
||||||
|
vi.stubGlobal('window', events);
|
||||||
|
vi.stubGlobal('location', { pathname: '/pokedex/example' });
|
||||||
|
vi.stubGlobal('document', { querySelector: () => null });
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => setTimeout(callback, 16));
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', clearTimeout);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for interactive cells, coalesces events, then runs at idle once', () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
afterCriticalPageWork(run);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
events.dispatchEvent(new Event('livingdex:grid-interactive'));
|
||||||
|
events.dispatchEvent(new Event('livingdex:grid-interactive'));
|
||||||
|
vi.advanceTimersByTime(16);
|
||||||
|
expect(events.requestIdleCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
idle!();
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('falls back after five seconds if no grid becomes interactive', () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
afterCriticalPageWork(run);
|
||||||
|
vi.advanceTimersByTime(4999);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('cancels work after navigation or an explicit refresh takes over', () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const cancel = afterCriticalPageWork(run);
|
||||||
|
events.dispatchEvent(new Event('livingdex:grid-interactive'));
|
||||||
|
vi.advanceTimersByTime(16);
|
||||||
|
cancel();
|
||||||
|
idle!();
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('does not announce a grid with no populated cells', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
events.addEventListener('livingdex:grid-interactive', listener);
|
||||||
|
markGridInteractive();
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('schedules other pages without waiting for a grid, even without idle callbacks', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/backup-settings' });
|
||||||
|
delete events.requestIdleCallback;
|
||||||
|
const run = vi.fn();
|
||||||
|
afterCriticalPageWork(run);
|
||||||
|
vi.advanceTimersByTime(32);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('marks populated cells once and allows work registered after hydration', () => {
|
||||||
|
let ready = false;
|
||||||
|
const grid = {
|
||||||
|
setAttribute: () => {
|
||||||
|
ready = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cell = { closest: () => grid };
|
||||||
|
vi.stubGlobal('document', {
|
||||||
|
querySelector: (selector: string) =>
|
||||||
|
selector === '[data-entry-index]' ? cell : ready ? grid : null
|
||||||
|
});
|
||||||
|
const mark = vi.fn();
|
||||||
|
vi.stubGlobal('performance', { mark });
|
||||||
|
markGridInteractive();
|
||||||
|
markGridInteractive();
|
||||||
|
expect(mark).toHaveBeenCalledTimes(1);
|
||||||
|
const run = vi.fn();
|
||||||
|
afterCriticalPageWork(run);
|
||||||
|
vi.advanceTimersByTime(16);
|
||||||
|
idle!();
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('does not access the DOM during SSR', () => {
|
||||||
|
vi.stubGlobal('window', undefined);
|
||||||
|
expect(() => markGridInteractive()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { packGrid, unpackGrid, type PokedexGridRow } from '$lib/models/PokedexGridRow';
|
||||||
|
describe('grid transport', () => {
|
||||||
|
it('round-trips missing catches and every flag combination', () => {
|
||||||
|
const rows: PokedexGridRow[] = Array.from({ length: 17 }, (_, flags) => ({
|
||||||
|
pokedexEntry: {
|
||||||
|
_id: String(flags),
|
||||||
|
pokedexNumber: 25,
|
||||||
|
pokemon: 'Pikachu',
|
||||||
|
form: 'Female',
|
||||||
|
spriteKey: '25',
|
||||||
|
canGigantamax: true
|
||||||
|
},
|
||||||
|
catchRecord:
|
||||||
|
flags === 16
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
_id: `catch-${flags}`,
|
||||||
|
caught: !!(flags & 1),
|
||||||
|
haveToEvolve: !!(flags & 2),
|
||||||
|
inHome: !!(flags & 4),
|
||||||
|
hasGigantamaxed: !!(flags & 8)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
expect(unpackGrid(packGrid(rows))).toEqual(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user