feat(#67): Add support for multiple pokedexes per user

This commit is contained in:
Josh Creek
2026-01-04 22:06:36 +00:00
parent 578df57a45
commit 19a571e315
28 changed files with 1401 additions and 215 deletions
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Pokedex } from '$lib/models/Pokedex';
export let pokedex: Pokedex;
export let onEdit: () => void;
export let onDelete: () => void;
export let onView: () => void;
// Get active type badges
$: typeBadges = [
pokedex.isLivingDex && 'Living',
pokedex.isShinyDex && 'Shiny',
pokedex.isOriginDex && 'Origin',
pokedex.isFormDex && 'Form'
].filter(Boolean);
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
{pokedex.name}
</h2>
{#if pokedex.description}
<p class="text-sm">{pokedex.description}</p>
{/if}
<div class="flex flex-wrap gap-2 my-2">
{#each typeBadges as badge}
<span class="badge badge-primary">{badge}</span>
{/each}
</div>
{#if pokedex.gameScope}
<div class="text-sm text-base-content/70">
<span class="font-semibold">Game:</span>
{pokedex.gameScope}
</div>
{:else}
<div class="text-sm text-base-content/70">
<span class="font-semibold">Scope:</span> All Games
</div>
{/if}
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-ghost" on:click={onEdit}>Edit</button>
<button class="btn btn-sm btn-error btn-outline" on:click={onDelete}>Delete</button>
<button class="btn btn-sm btn-primary" on:click={onView}>View</button>
</div>
</div>
</div>
@@ -10,6 +10,7 @@
export let showForms: boolean;
export let showShiny: boolean;
export let userId: string | null = null;
export let pokedexId: string;
// Create a default catch record if none exists
$: if (!catchRecord) {
@@ -17,6 +18,7 @@
_id: '', // Empty string, not temp ID - will be created by server
userId: userId || '',
pokedexEntryId: pokedexEntry._id,
pokedexId: pokedexId,
haveToEvolve: false,
caught: false,
inHome: false,
@@ -83,74 +85,76 @@
</div>
</div>
<div class="dex-column catch-record-container bg-white text-black rounded-lg p-4 mb-4 md:mb-0">
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Caught:</span>
<input
type="checkbox"
bind:checked={catchRecord.caught}
class="checkbox checkbox-primary border-black"
on:change={updateCatchRecord}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Needs to evolve:</span>
<input
type="checkbox"
bind:checked={catchRecord.haveToEvolve}
class="checkbox checkbox-primary border-black"
on:change={updateCatchRecord}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">In home:</span>
<input
type="checkbox"
bind:checked={catchRecord.inHome}
class="checkbox checkbox-primary border-black"
on:change={updateCatchRecord}
/>
</label>
</div>
</div>
{#if pokedexEntry.canGigantamax && showForms}
{#if catchRecord}
<div class="dex-column catch-record-container bg-white text-black rounded-lg p-4 mb-4 md:mb-0">
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
<span class="block font-bold mr-2">Caught:</span>
<input
type="checkbox"
bind:checked={catchRecord.hasGigantamaxed}
bind:checked={catchRecord.caught}
class="checkbox checkbox-primary border-black"
on:change={updateCatchRecord}
/>
</label>
</div>
</div>
{/if}
<p>
<label
class="block font-bold mb-1"
for={`personalNotesInput-${catchRecord?._id || pokedexEntry._id}`}>Notes:</label
>
<textarea
bind:value={catchRecord.personalNotes}
id={`personalNotesInput-${catchRecord?._id || pokedexEntry._id}`}
class="form-textarea w-full p-2 border rounded"
on:change={updateCatchRecord}
></textarea>
</p>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Needs to evolve:</span>
<input
type="checkbox"
bind:checked={catchRecord.haveToEvolve}
class="checkbox checkbox-primary border-black"
on:change={updateCatchRecord}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">In home:</span>
<input
type="checkbox"
bind:checked={catchRecord.inHome}
class="checkbox checkbox-primary border-black"
on:change={updateCatchRecord}
/>
</label>
</div>
</div>
{#if pokedexEntry.canGigantamax && showForms}
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
<input
type="checkbox"
bind:checked={catchRecord.hasGigantamaxed}
class="checkbox checkbox-primary border-black"
on:change={updateCatchRecord}
/>
</label>
</div>
</div>
{/if}
<p>
<label
class="block font-bold mb-1"
for={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}>Notes:</label
>
<textarea
bind:value={catchRecord.personalNotes}
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
class="form-textarea w-full p-2 border rounded"
on:change={updateCatchRecord}
></textarea>
</p>
</div>
{/if}
<div class="dex-column additional-details-container">
{#if showOrigins}
@@ -0,0 +1,103 @@
<script lang="ts">
import type { Pokedex } from '$lib/models/Pokedex';
export let pokedex: Partial<Pokedex> = {
name: '',
description: '',
isLivingDex: false,
isShinyDex: false,
isOriginDex: false,
isFormDex: false,
gameScope: null
};
export let mode: 'create' | 'edit' = 'create';
export let onSubmit: () => void;
export let onCancel: () => void;
// Validation
$: hasAtLeastOneType =
pokedex.isLivingDex || pokedex.isShinyDex || pokedex.isOriginDex || pokedex.isFormDex;
$: canSubmit = pokedex.name && pokedex.name.trim() !== '' && hasAtLeastOneType;
function handleSubmit() {
if (canSubmit) {
onSubmit();
}
}
</script>
<div class="form-control w-full">
<label class="label" for="pokedex-name">
<span class="label-text">Name</span>
</label>
<input
id="pokedex-name"
type="text"
placeholder="My Living Dex"
class="input input-bordered w-full"
bind:value={pokedex.name}
required
/>
</div>
<div class="form-control w-full">
<label class="label" for="pokedex-description">
<span class="label-text">Description (optional)</span>
</label>
<textarea
id="pokedex-description"
class="textarea textarea-bordered h-24"
placeholder="Describe your pokédex..."
bind:value={pokedex.description}
></textarea>
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Type(s)</span>
<span class="label-text-alt text-error"
>{!hasAtLeastOneType ? 'At least one type required' : ''}</span
>
</label>
<div class="flex flex-col gap-2">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox" bind:checked={pokedex.isLivingDex} />
<span class="label-text">Living Dex (one of each Pokémon)</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox" bind:checked={pokedex.isShinyDex} />
<span class="label-text">Shiny Dex (shiny variants only)</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox" bind:checked={pokedex.isOriginDex} />
<span class="label-text">Origin Dex (caught in native regions)</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox" bind:checked={pokedex.isFormDex} />
<span class="label-text">Form Dex (all forms included)</span>
</label>
</div>
</div>
<div class="form-control w-full">
<label class="label" for="game-scope">
<span class="label-text">Game Scope</span>
</label>
<select id="game-scope" class="select select-bordered" bind:value={pokedex.gameScope}>
<option value={null}>All Games</option>
<option value="Scarlet">Scarlet</option>
<option value="Violet">Violet>
<option value="Sword">Sword</option>
<option value="Shield">Shield</option>
<option value="Brilliant Diamond">Brilliant Diamond</option>
<option value="Shining Pearl">Shining Pearl</option>
<option value="Legends: Arceus">Legends: Arceus</option>
</select>
</div>
<div class="modal-action">
<button class="btn btn-ghost" type="button" on:click={onCancel}>Cancel</button>
<button class="btn btn-primary" type="button" on:click={handleSubmit} disabled={!canSubmit}>
{mode === 'create' ? 'Create' : 'Save'}
</button>
</div>
@@ -1,6 +1,8 @@
<script lang="ts">
import Pagination from '$lib/components/Pagination.svelte';
import type { Pokedex } from '$lib/models/Pokedex';
export let pokedex: Pokedex | null = null;
export let viewAsBoxes = false;
export let currentPage = 1;
export let itemsPerPage = 20;
@@ -15,11 +17,36 @@
export let toggleShiny = () => {};
export let getData = () => {};
export let toggleViewAsBoxes = () => {};
// Get active type badges
$: typeBadges = pokedex
? [
pokedex.isLivingDex && 'Living',
pokedex.isShinyDex && 'Shiny',
pokedex.isOriginDex && 'Origin',
pokedex.isFormDex && 'Form'
].filter(Boolean)
: [];
</script>
<aside
class="menu bg-gray-800 text-white min-h-full w-64 p-4 lg:fixed xs:top-0 lg:left-0 h-full lg:h-5/6"
>
<!-- Pokédex Info Section -->
{#if pokedex}
<div class="mb-6 pb-4 border-b border-gray-700">
<h2 class="text-xl font-semibold mb-2">{pokedex.name}</h2>
<div class="flex flex-wrap gap-1 mb-2">
{#each typeBadges as badge}
<span class="badge badge-sm badge-primary">{badge}</span>
{/each}
</div>
{#if pokedex.gameScope}
<p class="text-sm text-gray-400">Game: {pokedex.gameScope}</p>
{/if}
<a href="/my-pokedexes" class="btn btn-ghost btn-sm mt-2 w-full"> Manage Pokédexes </a>
</div>
{/if}
<h2 class="text-2xl font-semibold mb-4">Views</h2>
<ul>
<li class="mb-2">
@@ -6,8 +6,9 @@
import Tooltip from '$lib/components/Tooltip.svelte';
export let showShiny = false;
export let showForms = true;
export let combinedData: CombinedData[] | null;
export let boxNumbers = Array<number>;
export let boxNumbers: number[] = [];
export let currentPlacement = 'boxPlacementForms';
export let creatingRecords = false;
export let totalRecordsCreated = 0;
@@ -33,7 +34,8 @@
if (catchRecord?.caught || catchRecord?.haveToEvolve) {
return '';
} else {
if (pokedexEntry[currentPlacement].column % 2 === 0) {
const placement = showForms ? pokedexEntry.boxPlacementForms : pokedexEntry.boxPlacement;
if (placement.column % 2 === 0) {
return 'background-color: #ffffff';
} else {
return 'background-color: #f9f9f9;';
@@ -67,11 +69,13 @@
>
<div class="grid grid-cols-6">
{#each combinedData as { pokedexEntry, catchRecord }}
{#if pokedexEntry[currentPlacement].box === boxNumber}
{@const placement = showForms
? pokedexEntry.boxPlacementForms
: pokedexEntry.boxPlacement}
{#if placement.box === boxNumber}
<div
class="pokemon-box {cellBackgroundColourClass(catchRecord)}"
style="grid-column-start: {pokedexEntry[currentPlacement]
.column}; grid-row-start: {pokedexEntry[currentPlacement].row};
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
{cellBackgroundColourStyle(pokedexEntry, catchRecord)}"
>
<Tooltip>
@@ -12,6 +12,7 @@
export let updateACatch = (event: any) => {};
export let createCatchRecords = () => {};
export let userId: string | null = null;
export let pokedexId: string;
</script>
<main class="flex-1 p-4 w-full">
@@ -24,6 +25,7 @@
{showForms}
{showShiny}
{userId}
{pokedexId}
bind:catchRecord
on:updateCatch={updateACatch}
/>
+2
View File
@@ -3,6 +3,7 @@ export interface CatchRecord {
_id: string; // Maps to Supabase 'id' field for frontend compatibility
userId: string;
pokedexEntryId: string; // String representation of the foreign key
pokedexId: string; // NEW: Foreign key to pokedexes table
haveToEvolve: boolean;
caught: boolean;
inHome: boolean;
@@ -15,6 +16,7 @@ export interface CatchRecordDB {
id: string;
userId: string;
pokedexEntryId: number; // Numeric foreign key in database
pokedexId: string; // NEW: Foreign key to pokedexes table
haveToEvolve: boolean;
caught: boolean;
inHome: boolean;
+25
View File
@@ -0,0 +1,25 @@
export interface Pokedex {
_id: string;
userId: string;
name: string;
description: string;
isLivingDex: boolean;
isShinyDex: boolean;
isOriginDex: boolean;
isFormDex: boolean;
gameScope: string | null;
}
export interface PokedexDB {
id: string;
userId: string;
name: string;
description: string;
isLivingDex: boolean;
isShinyDex: boolean;
isOriginDex: boolean;
isFormDex: boolean;
gameScope: string | null;
createdAt: string;
updatedAt: string;
}
+34
View File
@@ -12,6 +12,23 @@ export interface PokedexEntry {
regionToEvolveIn: string;
evolutionInformation: string;
catchInformation: string[];
// Regional dex numbers
kantoDexNumber?: number;
johtoDexNumber?: number;
hoennDexNumber?: number;
sinnohDexNumber?: number;
unovaBwDexNumber?: number;
unovaB2w2DexNumber?: number;
kalosCentralDexNumber?: number;
kalosCoastalDexNumber?: number;
kalosMountainDexNumber?: number;
alolaSmDexNumber?: number;
alolaUsumDexNumber?: number;
galarDexNumber?: number;
galarIsleOfArmorDexNumber?: number;
galarCrownTundraDexNumber?: number;
hisuiDexNumber?: number;
paldeaDexNumber?: number;
}
// Database record type for Supabase
@@ -32,6 +49,23 @@ export interface PokedexEntryDB {
boxPlacementBox: number | null;
boxPlacementRow: number | null;
boxPlacementColumn: number | null;
// Regional dex numbers - snake_case to match database
kanto_dex_number: number | null;
johto_dex_number: number | null;
hoenn_dex_number: number | null;
sinnoh_dex_number: number | null;
unova_bw_dex_number: number | null;
unova_b2w2_dex_number: number | null;
kalos_central_dex_number: number | null;
kalos_coastal_dex_number: number | null;
kalos_mountain_dex_number: number | null;
alola_sm_dex_number: number | null;
alola_usum_dex_number: number | null;
galar_dex_number: number | null;
galar_isle_of_armor_dex_number: number | null;
galar_crown_tundra_dex_number: number | null;
hisui_dex_number: number | null;
paldea_dex_number: number | null;
createdAt: string;
updatedAt: string;
}
+17 -7
View File
@@ -4,7 +4,8 @@ import type { SupabaseClient } from '@supabase/supabase-js';
class CatchRecordRepository {
constructor(
private supabase: SupabaseClient,
private userId: string
private userId: string,
private pokedexId: string
) {}
// Transform Supabase data to frontend format (minimal transformation)
@@ -13,6 +14,7 @@ class CatchRecordRepository {
_id: record.id,
userId: record.userId,
pokedexEntryId: record.pokedexEntryId.toString(),
pokedexId: record.pokedexId,
haveToEvolve: record.haveToEvolve,
caught: record.caught,
inHome: record.inHome,
@@ -31,6 +33,7 @@ class CatchRecordRepository {
}
dbData.pokedexEntryId = numericId;
}
if (data.pokedexId !== undefined) dbData.pokedexId = data.pokedexId;
if (data.haveToEvolve !== undefined) dbData.haveToEvolve = data.haveToEvolve;
if (data.caught !== undefined) dbData.caught = data.caught;
if (data.inHome !== undefined) dbData.inHome = data.inHome;
@@ -46,6 +49,7 @@ class CatchRecordRepository {
.select('*')
.eq('id', id)
.eq('userId', this.userId)
.eq('pokedexId', this.pokedexId)
.single();
if (error || !data) return null;
@@ -56,7 +60,8 @@ class CatchRecordRepository {
const { data, error } = await this.supabase
.from('catch_records')
.select('*')
.eq('userId', this.userId);
.eq('userId', this.userId)
.eq('pokedexId', this.pokedexId);
if (error || !data) return [];
return data.map((record) => this.transformCatchRecord(record));
@@ -66,7 +71,8 @@ class CatchRecordRepository {
const { data, error } = await this.supabase
.from('catch_records')
.select('*')
.eq('userId', userId);
.eq('userId', userId)
.eq('pokedexId', this.pokedexId);
if (error || !data) return [];
return data.map((record) => this.transformCatchRecord(record));
@@ -75,6 +81,7 @@ class CatchRecordRepository {
async create(data: Partial<CatchRecord>): Promise<CatchRecord> {
const dbData = {
userId: this.userId,
pokedexId: this.pokedexId,
...this.transformToDatabase(data)
};
@@ -104,6 +111,7 @@ class CatchRecordRepository {
.update(dbData)
.eq('id', id)
.eq('userId', this.userId)
.eq('pokedexId', this.pokedexId)
.select()
.single();
@@ -116,7 +124,8 @@ class CatchRecordRepository {
.from('catch_records')
.delete()
.eq('id', id)
.eq('userId', this.userId);
.eq('userId', this.userId)
.eq('pokedexId', this.pokedexId);
if (error) {
console.error('Error deleting catch record:', error);
@@ -128,8 +137,8 @@ class CatchRecordRepository {
if (data._id) {
return this.update(data._id, data);
} else if (data.pokedexEntryId) {
// Try to find existing record for this user and pokemon
const existing = await this.findByUserAndPokemon(this.userId, data.pokedexEntryId);
// Try to find existing record for this user, pokemon, and pokedex
const existing = await this.findByUserAndPokemon(this.userId, data.pokedexEntryId, this.pokedexId);
if (existing) {
return this.update(existing._id, data);
} else {
@@ -139,7 +148,7 @@ class CatchRecordRepository {
return this.create(data);
}
async findByUserAndPokemon(userId: string, pokedexEntryId: string): Promise<CatchRecord | null> {
async findByUserAndPokemon(userId: string, pokedexEntryId: string, pokedexId: string): Promise<CatchRecord | null> {
const numericId = Number(pokedexEntryId);
if (isNaN(numericId)) {
return null;
@@ -149,6 +158,7 @@ class CatchRecordRepository {
.select('*')
.eq('userId', userId)
.eq('pokedexEntryId', numericId)
.eq('pokedexId', pokedexId)
.single();
if (error || !data) return null;
+116 -26
View File
@@ -2,11 +2,13 @@ import { type PokedexEntry, type PokedexEntryDB } from '$lib/models/PokedexEntry
import { type CatchRecord, type CatchRecordDB } from '$lib/models/CatchRecord';
import { type CombinedData } from '$lib/models/CombinedData';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getRegionalDexColumnName } from '$lib/utils/regionalDexMapping';
class CombinedDataRepository {
constructor(
private supabase: SupabaseClient,
private userId: string
private userId: string | null,
private pokedexId: string | null
) {}
// Transform Supabase data to match frontend expectations (minimal transformation)
@@ -31,7 +33,23 @@ class CombinedDataRepository {
box: entry.boxPlacementBox || 0,
row: entry.boxPlacementRow || 0,
column: entry.boxPlacementColumn || 0
}
},
kantoDexNumber: entry.kanto_dex_number ?? undefined,
johtoDexNumber: entry.johto_dex_number ?? undefined,
hoennDexNumber: entry.hoenn_dex_number ?? undefined,
sinnohDexNumber: entry.sinnoh_dex_number ?? undefined,
unovaBwDexNumber: entry.unova_bw_dex_number ?? undefined,
unovaB2w2DexNumber: entry.unova_b2w2_dex_number ?? undefined,
kalosCentralDexNumber: entry.kalos_central_dex_number ?? undefined,
kalosCoastalDexNumber: entry.kalos_coastal_dex_number ?? undefined,
kalosMountainDexNumber: entry.kalos_mountain_dex_number ?? undefined,
alolaSmDexNumber: entry.alola_sm_dex_number ?? undefined,
alolaUsumDexNumber: entry.alola_usum_dex_number ?? undefined,
galarDexNumber: entry.galar_dex_number ?? undefined,
galarIsleOfArmorDexNumber: entry.galar_isle_of_armor_dex_number ?? undefined,
galarCrownTundraDexNumber: entry.galar_crown_tundra_dex_number ?? undefined,
hisuiDexNumber: entry.hisui_dex_number ?? undefined,
paldeaDexNumber: entry.paldea_dex_number ?? undefined
};
}
@@ -40,6 +58,7 @@ class CombinedDataRepository {
_id: record.id,
userId: record.userId,
pokedexEntryId: record.pokedexEntryId.toString(),
pokedexId: record.pokedexId,
haveToEvolve: record.haveToEvolve,
caught: record.caught,
inHome: record.inHome,
@@ -69,17 +88,31 @@ class CombinedDataRepository {
query = query.contains('gamesToCatchIn', [game]);
}
// Order by box placement
if (enableForms) {
query = query
.order('boxPlacementFormsBox', { ascending: true })
.order('boxPlacementFormsRow', { ascending: true })
.order('boxPlacementFormsColumn', { ascending: true });
// Determine ordering strategy based on game filter
if (game) {
const regionalColumn = getRegionalDexColumnName(game);
if (regionalColumn) {
// Order by regional dex with national dex fallback for NULLs
query = query
.order(regionalColumn, { ascending: true, nullsFirst: false })
.order('pokedexNumber', { ascending: true });
} else {
// Game specified but has no regional dex mapping
query = query.order('pokedexNumber', { ascending: true });
}
} else {
query = query
.order('boxPlacementBox', { ascending: true })
.order('boxPlacementRow', { ascending: true })
.order('boxPlacementColumn', { ascending: true });
// No game filter - use pre-calculated box placement (national dex order)
if (enableForms) {
query = query
.order('boxPlacementFormsBox', { ascending: true })
.order('boxPlacementFormsRow', { ascending: true })
.order('boxPlacementFormsColumn', { ascending: true });
} else {
query = query
.order('boxPlacementBox', { ascending: true })
.order('boxPlacementRow', { ascending: true })
.order('boxPlacementColumn', { ascending: true });
}
}
const { data: entries, error: entriesError } = await query;
@@ -95,12 +128,13 @@ class CombinedDataRepository {
// Get catch records for all entries
let catchRecords: CatchRecordDB[] = [];
if (userId) {
if (userId && this.pokedexId) {
const entryIds = entries.map((entry) => entry.id);
const { data: records, error: recordsError } = await this.supabase
.from('catch_records')
.select('*')
.eq('userId', userId)
.eq('pokedexId', this.pokedexId)
.in('pokedexEntryId', entryIds);
if (!recordsError && records) {
@@ -109,7 +143,7 @@ class CombinedDataRepository {
}
// Combine the data exactly like master branch
return entries.map((entry) => {
const combinedData = entries.map((entry) => {
const userCatchRecord =
catchRecords.find((record) => record.pokedexEntryId === entry.id) || null;
@@ -123,6 +157,12 @@ class CombinedDataRepository {
catchRecord: transformedCatchRecord
};
});
// Recalculate box placement if using regional dex ordering
if (game && getRegionalDexColumnName(game)) {
return this.recalculateBoxPlacement(combinedData, enableForms);
}
return combinedData;
}
async findCombinedData(
@@ -151,17 +191,31 @@ class CombinedDataRepository {
query = query.contains('gamesToCatchIn', [game]);
}
// Order by box placement
if (enableForms) {
query = query
.order('boxPlacementFormsBox', { ascending: true })
.order('boxPlacementFormsRow', { ascending: true })
.order('boxPlacementFormsColumn', { ascending: true });
// Determine ordering strategy based on game filter
if (game) {
const regionalColumn = getRegionalDexColumnName(game);
if (regionalColumn) {
// Order by regional dex with national dex fallback for NULLs
query = query
.order(regionalColumn, { ascending: true, nullsFirst: false })
.order('pokedexNumber', { ascending: true });
} else {
// Game specified but has no regional dex mapping
query = query.order('pokedexNumber', { ascending: true });
}
} else {
query = query
.order('boxPlacementBox', { ascending: true })
.order('boxPlacementRow', { ascending: true })
.order('boxPlacementColumn', { ascending: true });
// No game filter - use pre-calculated box placement (national dex order)
if (enableForms) {
query = query
.order('boxPlacementFormsBox', { ascending: true })
.order('boxPlacementFormsRow', { ascending: true })
.order('boxPlacementFormsColumn', { ascending: true });
} else {
query = query
.order('boxPlacementBox', { ascending: true })
.order('boxPlacementRow', { ascending: true })
.order('boxPlacementColumn', { ascending: true });
}
}
const { data: entries, error: entriesError } = await query;
@@ -177,12 +231,13 @@ class CombinedDataRepository {
// Get catch records for these entries
let catchRecords: CatchRecordDB[] = [];
if (userId) {
if (userId && this.pokedexId) {
const entryIds = entries.map((entry) => entry.id);
const { data: records, error: recordsError } = await this.supabase
.from('catch_records')
.select('*')
.eq('userId', userId)
.eq('pokedexId', this.pokedexId)
.in('pokedexEntryId', entryIds);
if (!recordsError && records) {
@@ -191,7 +246,7 @@ class CombinedDataRepository {
}
// Combine the data exactly like master branch
return entries.map((entry) => {
const combinedData = entries.map((entry) => {
const userCatchRecord =
catchRecords.find((record) => record.pokedexEntryId === entry.id) || null;
@@ -205,6 +260,12 @@ class CombinedDataRepository {
catchRecord: transformedCatchRecord
};
});
// Recalculate box placement if using regional dex ordering
if (game && getRegionalDexColumnName(game)) {
return this.recalculateBoxPlacement(combinedData, enableForms);
}
return combinedData;
}
async countCombinedData(
@@ -236,6 +297,35 @@ class CombinedDataRepository {
return count || 0;
}
/**
* Recalculates box placement based on the current order of data.
* Used when ordering by regional dex instead of pre-calculated national dex placement.
*/
private recalculateBoxPlacement(
data: CombinedData[],
useFormsPlacement: boolean
): CombinedData[] {
return data.map((item, index) => {
const placementIndex = index + 1; // 1-based indexing
const box = Math.ceil(placementIndex / 30);
const rowColumnIndex = (placementIndex - 1) % 30;
const row = Math.floor(rowColumnIndex / 6) + 1;
const column = (rowColumnIndex % 6) + 1;
const newPlacement = { box, row, column };
return {
...item,
pokedexEntry: {
...item.pokedexEntry,
...(useFormsPlacement
? { boxPlacementForms: newPlacement }
: { boxPlacement: newPlacement })
}
};
});
}
}
export default CombinedDataRepository;
+19 -1
View File
@@ -26,7 +26,23 @@ class PokedexEntryRepository {
box: entry.boxPlacementBox || 0,
row: entry.boxPlacementRow || 0,
column: entry.boxPlacementColumn || 0
}
},
kantoDexNumber: entry.kanto_dex_number ?? undefined,
johtoDexNumber: entry.johto_dex_number ?? undefined,
hoennDexNumber: entry.hoenn_dex_number ?? undefined,
sinnohDexNumber: entry.sinnoh_dex_number ?? undefined,
unovaBwDexNumber: entry.unova_bw_dex_number ?? undefined,
unovaB2w2DexNumber: entry.unova_b2w2_dex_number ?? undefined,
kalosCentralDexNumber: entry.kalos_central_dex_number ?? undefined,
kalosCoastalDexNumber: entry.kalos_coastal_dex_number ?? undefined,
kalosMountainDexNumber: entry.kalos_mountain_dex_number ?? undefined,
alolaSmDexNumber: entry.alola_sm_dex_number ?? undefined,
alolaUsumDexNumber: entry.alola_usum_dex_number ?? undefined,
galarDexNumber: entry.galar_dex_number ?? undefined,
galarIsleOfArmorDexNumber: entry.galar_isle_of_armor_dex_number ?? undefined,
galarCrownTundraDexNumber: entry.galar_crown_tundra_dex_number ?? undefined,
hisuiDexNumber: entry.hisui_dex_number ?? undefined,
paldeaDexNumber: entry.paldea_dex_number ?? undefined
};
}
@@ -74,6 +90,7 @@ class PokedexEntryRepository {
if (data.boxPlacement?.row !== undefined) dbData.boxPlacementRow = data.boxPlacement.row;
if (data.boxPlacement?.column !== undefined)
dbData.boxPlacementColumn = data.boxPlacement.column;
if (data.regionalDexNumbers !== undefined) dbData.regionalDexNumbers = data.regionalDexNumbers;
const { data: result, error } = await this.supabase
.from('pokedex_entries')
@@ -108,6 +125,7 @@ class PokedexEntryRepository {
if (data.boxPlacement?.row !== undefined) dbData.boxPlacementRow = data.boxPlacement.row;
if (data.boxPlacement?.column !== undefined)
dbData.boxPlacementColumn = data.boxPlacement.column;
if (data.regionalDexNumbers !== undefined) dbData.regionalDexNumbers = data.regionalDexNumbers;
const { data: result, error } = await this.supabase
.from('pokedex_entries')
+108
View File
@@ -0,0 +1,108 @@
import type { Pokedex, PokedexDB } from '$lib/models/Pokedex';
import type { SupabaseClient } from '@supabase/supabase-js';
class PokedexRepository {
constructor(
private supabase: SupabaseClient,
private userId: string
) {}
private transform(db: PokedexDB): Pokedex {
return {
_id: db.id,
userId: db.userId,
name: db.name,
description: db.description || '',
isLivingDex: db.isLivingDex,
isShinyDex: db.isShinyDex,
isOriginDex: db.isOriginDex,
isFormDex: db.isFormDex,
gameScope: db.gameScope
};
}
private transformToDatabase(data: Partial<Pokedex>): Partial<PokedexDB> {
const dbData: Partial<PokedexDB> = {};
if (data.name !== undefined) dbData.name = data.name;
if (data.description !== undefined) dbData.description = data.description;
if (data.isLivingDex !== undefined) dbData.isLivingDex = data.isLivingDex;
if (data.isShinyDex !== undefined) dbData.isShinyDex = data.isShinyDex;
if (data.isOriginDex !== undefined) dbData.isOriginDex = data.isOriginDex;
if (data.isFormDex !== undefined) dbData.isFormDex = data.isFormDex;
if (data.gameScope !== undefined) dbData.gameScope = data.gameScope;
return dbData;
}
async findById(id: string): Promise<Pokedex | null> {
const { data, error } = await this.supabase
.from('pokedexes')
.select('*')
.eq('id', id)
.eq('userId', this.userId)
.single();
if (error || !data) return null;
return this.transform(data);
}
async findAll(): Promise<Pokedex[]> {
const { data, error } = await this.supabase
.from('pokedexes')
.select('*')
.eq('userId', this.userId)
.order('createdAt', { ascending: true });
if (error || !data) return [];
return data.map((d) => this.transform(d));
}
async create(data: Partial<Pokedex>): Promise<Pokedex> {
const dbData = {
userId: this.userId,
...this.transformToDatabase(data)
};
const { data: result, error } = await this.supabase
.from('pokedexes')
.insert(dbData)
.select()
.single();
if (error) {
console.error('Error creating pokedex:', error);
throw new Error(`Failed to create pokedex: ${error.message}`);
}
if (!result) {
throw new Error('Failed to create pokedex: No result returned');
}
return this.transform(result);
}
async update(id: string, data: Partial<Pokedex>): Promise<Pokedex | null> {
const dbData = this.transformToDatabase(data);
const { data: result, error } = await this.supabase
.from('pokedexes')
.update(dbData)
.eq('id', id)
.eq('userId', this.userId)
.select()
.single();
if (error || !result) return null;
return this.transform(result);
}
async delete(id: string): Promise<void> {
const { error } = await this.supabase.from('pokedexes').delete().eq('id', id).eq('userId', this.userId);
if (error) {
console.error('Error deleting pokedex:', error);
throw new Error(`Failed to delete pokedex: ${error.message}`);
}
}
}
export default PokedexRepository;
+141
View File
@@ -0,0 +1,141 @@
/**
* Maps game names to their corresponding regional Pokédex identifier.
* This mapping is used to determine which regional dex number to use when
* filtering and ordering Pokémon for a specific game.
*/
type RegionalDexKey =
| 'kanto'
| 'johto'
| 'hoenn'
| 'sinnoh'
| 'unova_bw'
| 'unova_b2w2'
| 'kalos_central'
| 'kalos_coastal'
| 'kalos_mountain'
| 'alola_sm'
| 'alola_usum'
| 'galar'
| 'galar_isle_of_armor'
| 'galar_crown_tundra'
| 'hisui'
| 'paldea';
const gameToRegionalDexMap: Record<string, RegionalDexKey> = {
// Kanto region
'Red': 'kanto',
'Blue': 'kanto',
'Yellow': 'kanto',
'FireRed': 'kanto',
'LeafGreen': 'kanto',
'LG: Pikachu': 'kanto',
'LG: Eevee': 'kanto',
// Johto region
'Gold': 'johto',
'Silver': 'johto',
'Crystal': 'johto',
'HeartGold': 'johto',
'SoulSilver': 'johto',
// Hoenn region
'Ruby': 'hoenn',
'Sapphire': 'hoenn',
'Emerald': 'hoenn',
'OmegaRuby': 'hoenn',
'AlphaSapphire': 'hoenn',
// Sinnoh region
'Diamond': 'sinnoh',
'Pearl': 'sinnoh',
'Platinum': 'sinnoh',
'BrilliantDiamond': 'sinnoh',
'ShiningPearl': 'sinnoh',
// Unova region
'Black': 'unova_bw',
'White': 'unova_bw',
'Black2': 'unova_b2w2',
'White2': 'unova_b2w2',
// Kalos region - Note: All XY use all three sub-dexes
// We default to Central for simplicity
'X': 'kalos_central',
'Y': 'kalos_central',
// Alola region
'Sun': 'alola_sm',
'Moon': 'alola_sm',
'UltraSun': 'alola_usum',
'UltraMoon': 'alola_usum',
// Galar region
'Sword': 'galar',
'Shield': 'galar',
// Hisui region
'LegendsArceus': 'hisui',
// Paldea region
'Scarlet': 'paldea',
'Violet': 'paldea'
};
/**
* Gets the regional dex key for a given game name.
* Returns undefined if the game doesn't have a regional dex mapping.
*/
export function getRegionalDexKey(gameName: string): RegionalDexKey | undefined {
return gameToRegionalDexMap[gameName];
}
/**
* Checks if a game has a regional dex mapping.
*/
export function hasRegionalDex(gameName: string): boolean {
return gameName in gameToRegionalDexMap;
}
/**
* Gets the database column name for a game's regional dex.
* Maps game name → database column name (e.g., 'Scarlet' → 'paldea_dex_number').
* Returns undefined if the game doesn't have a regional dex mapping.
*/
export function getRegionalDexColumnName(gameName: string): string | undefined {
const regionalKey = getRegionalDexKey(gameName);
if (!regionalKey) return undefined;
return `${regionalKey}_dex_number`;
}
/**
* Gets the frontend field name for a game's regional dex.
* Maps game name → PokedexEntry field name (e.g., 'Scarlet' → 'paldeaDexNumber').
* Returns undefined if the game doesn't have a regional dex mapping.
*/
export function getRegionalDexFieldName(gameName: string): string | undefined {
const regionalKey = getRegionalDexKey(gameName);
if (!regionalKey) return undefined;
// Map regional key to actual PokedexEntry field name (camelCase)
const fieldMap: Record<string, string> = {
'kanto': 'kantoDexNumber',
'johto': 'johtoDexNumber',
'hoenn': 'hoennDexNumber',
'sinnoh': 'sinnohDexNumber',
'unova_bw': 'unovaBwDexNumber',
'unova_b2w2': 'unovaB2w2DexNumber',
'kalos_central': 'kalosCentralDexNumber',
'kalos_coastal': 'kalosCoastalDexNumber',
'kalos_mountain': 'kalosMountainDexNumber',
'alola_sm': 'alolaSmDexNumber',
'alola_usum': 'alolaUsumDexNumber',
'galar': 'galarDexNumber',
'galar_isle_of_armor': 'galarIsleOfArmorDexNumber',
'galar_crown_tundra': 'galarCrownTundraDexNumber',
'hisui': 'hisuiDexNumber',
'paldea': 'paldeaDexNumber'
};
return fieldMap[regionalKey];
}