mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-14 17:42:17 +00:00
fix: preserve default forms and complete dex mappings
This commit is contained in:
@@ -29,6 +29,7 @@ export interface PokedexEntryDB {
|
||||
form: string | null;
|
||||
spriteKey: string | null;
|
||||
canGigantamax: boolean;
|
||||
isDefaultForm: boolean;
|
||||
regionToCatchIn: string | null;
|
||||
gamesToCatchIn: string[] | null;
|
||||
regionToEvolveIn: string | null;
|
||||
|
||||
@@ -60,8 +60,9 @@ class CombinedDataRepository {
|
||||
let query = this.supabase.from('pokedex_entries').select('*');
|
||||
|
||||
if (!enableForms) {
|
||||
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
|
||||
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
|
||||
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
||||
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
|
||||
query = query.eq('isDefaultForm', true);
|
||||
}
|
||||
|
||||
if (region) {
|
||||
@@ -88,8 +89,9 @@ class CombinedDataRepository {
|
||||
let query = this.supabase.from('game_pokedex_entry_details').select('*').in('dexId', dexScopes);
|
||||
|
||||
if (!enableForms) {
|
||||
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
|
||||
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
|
||||
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
||||
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
|
||||
query = query.eq('isDefaultForm', true);
|
||||
}
|
||||
|
||||
if (region) {
|
||||
@@ -109,8 +111,12 @@ class CombinedDataRepository {
|
||||
return query;
|
||||
}
|
||||
|
||||
// Fetch all non-base form entries for a game from pokedex_entries, excluding already-seen IDs.
|
||||
// game_pokedex_entries is seeded with base forms only, so forms must be supplemented from here.
|
||||
// Fetch all named-form entries for a game from pokedex_entries, excluding already-seen IDs.
|
||||
// game_pokedex_entries is seeded from form IS NULL rows, so named forms must be supplemented
|
||||
// from here. Deliberately keyed on `form`, NOT isDefaultForm: a default form with a name
|
||||
// (e.g. Rotom "Lightbulb") is usually absent from the game dex tables, so filtering on
|
||||
// isDefaultForm would drop it from game-scoped form dexes entirely. excludeIds already
|
||||
// dedupes anything the dex table does list.
|
||||
private async fetchFormsForGame(
|
||||
game: string,
|
||||
region: string,
|
||||
@@ -470,8 +476,9 @@ class CombinedDataRepository {
|
||||
|
||||
// Apply same filters as in findCombinedData
|
||||
if (!enableForms) {
|
||||
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
|
||||
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
|
||||
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
||||
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
|
||||
query = query.eq('isDefaultForm', true);
|
||||
}
|
||||
|
||||
if (region) {
|
||||
|
||||
@@ -2,6 +2,44 @@ import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||
|
||||
// PostgREST caps every response at `max_rows` (1000, see supabase/config.toml) and truncates
|
||||
// SILENTLY rather than erroring, so any query here that can return more must page. All three
|
||||
// can: game_pokedex_entries holds 11k+ rows, the base-form set is 1025 and a form dex 1390.
|
||||
const MAX_ROWS_PER_REQUEST = 1000;
|
||||
// Keeps `.in()` lists well clear of URL length limits.
|
||||
const ID_CHUNK_SIZE = 500;
|
||||
|
||||
type PageResult = { data: unknown[] | null; error: { message: string } | null };
|
||||
|
||||
/** Repeatedly fetch `fetchPage` ranges until a short page signals the end. */
|
||||
async function fetchAllRows<T>(
|
||||
fetchPage: (from: number, to: number) => PromiseLike<PageResult>,
|
||||
context: string
|
||||
): Promise<T[]> {
|
||||
const rows: T[] = [];
|
||||
|
||||
for (let from = 0; ; from += MAX_ROWS_PER_REQUEST) {
|
||||
const { data, error } = await fetchPage(from, from + MAX_ROWS_PER_REQUEST - 1);
|
||||
|
||||
if (error) {
|
||||
console.error(`Error ${context}:`, error);
|
||||
throw new Error(`Failed to ${context}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!data?.length) break;
|
||||
rows.push(...(data as T[]));
|
||||
if (data.length < MAX_ROWS_PER_REQUEST) break;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function chunk<T>(values: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < values.length; i += size) chunks.push(values.slice(i, i + size));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate expected pokedex entries based on pokedex configuration
|
||||
* Applies the same filtering logic as CombinedDataRepository
|
||||
@@ -14,54 +52,51 @@ export async function calculateExpectedEntries(
|
||||
const dexScopes = await resolveDexScopes(supabase, pokedex);
|
||||
|
||||
if (dexScopes.length > 0) {
|
||||
const { data: dexEntries, error: dexError } = await supabase
|
||||
.from('game_pokedex_entries')
|
||||
.select('pokemonId')
|
||||
.in('dexId', dexScopes);
|
||||
const dexEntries = await fetchAllRows<{ pokemonId: number }>(
|
||||
(from, to) =>
|
||||
supabase
|
||||
.from('game_pokedex_entries')
|
||||
.select('pokemonId')
|
||||
.in('dexId', dexScopes)
|
||||
// (dexId, pokemonId) is the primary key, so this orders pages deterministically.
|
||||
.order('dexId', { ascending: true })
|
||||
.order('pokemonId', { ascending: true })
|
||||
.range(from, to),
|
||||
'calculate dex-scoped entries'
|
||||
);
|
||||
|
||||
if (dexError) {
|
||||
console.error('Error calculating dex-scoped entries:', dexError);
|
||||
throw new Error(`Failed to calculate dex-scoped entries: ${dexError.message}`);
|
||||
}
|
||||
|
||||
const uniqueIds = Array.from(new Set(dexEntries?.map((row) => row.pokemonId) || []));
|
||||
const uniqueIds = Array.from(new Set(dexEntries.map((row) => row.pokemonId)));
|
||||
if (uniqueIds.length === 0) return [];
|
||||
|
||||
if (!pokedex.isFormDex) {
|
||||
const { data: entries, error: entryError } = await supabase
|
||||
.from('pokedex_entries')
|
||||
.select('id, pokemon, form')
|
||||
.in('id', uniqueIds);
|
||||
const entries: { id: number; isDefaultForm: boolean }[] = [];
|
||||
|
||||
if (entryError) {
|
||||
console.error('Error filtering base forms for dex scopes:', entryError);
|
||||
throw new Error(`Failed to filter base forms: ${entryError.message}`);
|
||||
for (const ids of chunk(uniqueIds, ID_CHUNK_SIZE)) {
|
||||
const { data, error } = await supabase
|
||||
.from('pokedex_entries')
|
||||
.select('id, isDefaultForm')
|
||||
.in('id', ids)
|
||||
.returns<{ id: number; isDefaultForm: boolean }[]>();
|
||||
|
||||
if (error) {
|
||||
console.error('Error filtering base forms for dex scopes:', error);
|
||||
throw new Error(`Failed to filter base forms: ${error.message}`);
|
||||
}
|
||||
|
||||
if (data) entries.push(...data);
|
||||
}
|
||||
|
||||
return (
|
||||
entries
|
||||
?.filter(
|
||||
(entry) =>
|
||||
entry.form === null ||
|
||||
entry.form === 'male' ||
|
||||
(entry.pokemon === 'Unown' && entry.form === 'A')
|
||||
)
|
||||
.map((entry) => entry.id) || []
|
||||
);
|
||||
// Gendered species (form='male') and Unown ('A') are also flagged isDefaultForm
|
||||
// in the pokemon table, so this single check covers them.
|
||||
return entries.filter((entry) => entry.isDefaultForm).map((entry) => entry.id);
|
||||
}
|
||||
|
||||
return uniqueIds;
|
||||
}
|
||||
|
||||
let query = supabase.from('pokedex_entries').select('id');
|
||||
|
||||
// Apply form filter: if isFormDex is false, only include base forms
|
||||
// Base forms have form IS NULL, except Unown which has no base form (use 'A')
|
||||
if (!pokedex.isFormDex) {
|
||||
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
|
||||
}
|
||||
|
||||
// Apply region filter: if gameScope is specified, filter by region
|
||||
// Resolve the region up front so each page can be built from scratch below. A PostgREST
|
||||
// builder is single-use, so it must be rebuilt per page rather than reused.
|
||||
let region: string | null = null;
|
||||
if (pokedex.gameScope) {
|
||||
// Determine region from gameScope using games table.
|
||||
// We keep games.region as a denormalized convenience column.
|
||||
@@ -78,24 +113,37 @@ export async function calculateExpectedEntries(
|
||||
);
|
||||
}
|
||||
|
||||
if (regionData?.region) {
|
||||
query = query.eq('regionToCatchIn', regionData.region);
|
||||
region = regionData?.region ?? null;
|
||||
}
|
||||
|
||||
const buildQuery = () => {
|
||||
let query = supabase.from('pokedex_entries').select('id');
|
||||
|
||||
// Apply form filter: if isFormDex is false, only include base forms. Gendered species
|
||||
// (form='male') and Unown ('A') are also flagged isDefaultForm, so this covers them too.
|
||||
if (!pokedex.isFormDex) {
|
||||
query = query.eq('isDefaultForm', true);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply game filter: if gameScope is specified, filter by gamesToCatchIn array
|
||||
if (pokedex.gameScope) {
|
||||
query = query.contains('gamesToCatchIn', [pokedex.gameScope]);
|
||||
}
|
||||
// Apply region filter: if gameScope is specified, filter by region
|
||||
if (region) {
|
||||
query = query.eq('regionToCatchIn', region);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
// Apply game filter: if gameScope is specified, filter by gamesToCatchIn array
|
||||
if (pokedex.gameScope) {
|
||||
query = query.contains('gamesToCatchIn', [pokedex.gameScope]);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error('Error calculating expected entries:', error);
|
||||
throw new Error(`Failed to calculate expected entries: ${error.message}`);
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
return data?.map((entry) => entry.id) || [];
|
||||
const entries = await fetchAllRows<{ id: number }>(
|
||||
(from, to) => buildQuery().order('id', { ascending: true }).range(from, to),
|
||||
'calculate expected entries'
|
||||
);
|
||||
|
||||
return entries.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user