diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
new file mode 100644
index 0000000..30a885e
--- /dev/null
+++ b/src/routes/+page.server.ts
@@ -0,0 +1,17 @@
+import type { PageServerLoad } from './$types';
+
+/**
+ * Server-side load function for the homepage
+ *
+ * Fetches public statistics from the database and passes them to the page component.
+ * Stats are cached for 24 hours to improve performance.
+ */
+export const load: PageServerLoad = async ({ fetch }) => {
+ // Fetch stats from database
+ const statsResponse = await fetch('/api/stats');
+ const statsData = await statsResponse.json();
+
+ return {
+ stats: statsData.error ? null : statsData
+ };
+};
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index e7d9821..2c74681 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,141 +1,392 @@
-
-
-
-
Start Your Pokédex Journey!
-
-
-
+
+ Living Dex Tracker - Track Your Pokédex Journey
+
+
+
+{#if isCheckingSession}
+
+
+
+
-
-
-
-
-
-
Pokémon caught
-
310K
-
Since January 2024
-
-
-
-
New Users
-
4,200
-
↗︎ 400 (22%)
-
-
-
-
Living Dexes Completed
-
1,200
-
↘︎ 90 (14%)
+{:else}
+
+
+
+
+
+
+
+ Free
+
+
+
+ Open Source
+
+
+
+ Offline-friendly
+
+
+
Start Your Pokédex Journey!
+
+ Track your progress towards a complete Living Pokédex — one of every Pokémon, actively
+ maintained across your boxes. Join thousands of trainers worldwide.
+
- Easily share your Pokédex journey with friends, or find theirs. If you'd rather go it
- alone, that's okay too!
-
-
+
+
+
+
+
+
Pokémon caught
+
{pokemonCaught}
-
-
-
-
-
-
Free & Open Source
-
- Completely open source and free to use, enabling the community to contribute updates
- as soon as new Pokémon are released.
-
-
+
+
Users
+
{users}
-
-
-
-
-
-
Easy to use with advanced filtering
-
- If you just want to track your progress completing your Pokédex, you can do that. But
- if you are attempting a harder variant, like a Living Origin Forme Dex, you might find
- our advanced filtering options helpful for tracking target Pokémon for specific
- catching sessions.
-
-
+
+
Living Dexes Completed
+
{livingDexesCompleted}
-
-
-
-
-
-
100% free
-
Did we mention it's completely free to use? Oh, we did? Good. Because it is.
-
-
-
-
+
+
-
+
+
+
+
+
+
+
What is a Living Dex?
+
+ A Living Dex is a complete Pokédex where you keep one of every Pokémon in your boxes
+ (often including forms/variants). Living Dex Tracker helps you build and maintain that
+ collection with filters, notes, and progress tracking.
+
+
+
+
+
+
+
+
+
+
Why Choose Living Dex Tracker?
+
+
+
+
+
+
+
+
+
+
Social & Shareable
+
+ Easily share your Pokédex journey with friends, or find theirs. If you'd rather go
+ it alone, that's okay too!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Free & Open Source
+
+ Completely open source and free to use, enabling the community to contribute
+ updates as soon as new Pokémon are released.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Advanced Filtering
+
+ Track simple progress or tackle harder variants like a Living Origin Form Dex with
+ our powerful filtering options for targeted catching sessions.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
100% free
+
+ Did we mention it's completely free to use? Oh, we did? Good. Because it is.
+
+
+
+
+
+
+
+
+
+
+
+
+
Ready to Start Your Journey?
+
+ Get started today with tracking your Living Pokédex progress. It's free, open source, and
+ built with love for the Pokémon community.
+
+
+
+
+
Sign Up Now
+
+
+
+
+
+
+
+
+
+
+
+
+
Legal Disclaimer
+
+ Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
+ characters, images, or other content featured on this website. This project is not
+ affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak,
+ or The Pokémon Company.
+
-
- Living Dex Tracker is dolor sit amet, consectetur adipiscing elit. Proin dignissim risus eu quam
- feugiat malesuada. Vivamus arcu sapien, feugiat sed lacus ut, malesuada congue nibh. In lacinia
- lacus quis bibendum pulvinar. Aliquam in suscipit risus. Aliquam ut justo dolor.
-
-
-
Features
-
-
-
-
-
-
-
-
Player Dex Search
-
Find and explore your friends' Pokémon collections effortlessly.
-
-
-
-
-
Meet The Team
-
-
Team Member 1
-
-
-
-
-
-
-
-
-
-
Team Member 2
-
-
-
-
-
The Developement
-
- Share insights into the development process, challenges faced, and any interesting anecdotes
- or milestones achieved during the project's journey.
-
- Living Dex Tracker is a fan-made website dedicated to providing information about Pokemon
- creatures. We do not claim ownership of any Pokemon characters, images, or other content
- featured on this website.
-
-
- We do not generate revenue from the use of Pokemon content. Any advertisements or sponsored
- content on this website are unrelated to Pokemon and are used solely to support the
- maintenance and operation of the website.
-
-
-
-
-
Acknowledgments
-
- Express gratitude to any individuals or organisations that have contributed to the project's
- success, including sponsors, supporters, or open-source contributors.
-
-
-
-
-
-
diff --git a/src/routes/about/+page.ts b/src/routes/about/+page.ts
deleted file mode 100644
index 3e13462..0000000
--- a/src/routes/about/+page.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { dev } from '$app/environment';
-
-// we don't need any JS on this page, though we'll load
-// it in dev so that we get hot module replacement...
-export const csr = dev;
-
-// since there's no dynamic data here, we can prerender
-// it so that it gets served as a static asset in prod
-export const prerender = true;
diff --git a/src/routes/api/pokedexes/+server.ts b/src/routes/api/pokedexes/+server.ts
index 9365ab8..9851d0c 100644
--- a/src/routes/api/pokedexes/+server.ts
+++ b/src/routes/api/pokedexes/+server.ts
@@ -3,6 +3,7 @@ import type { Pokedex } from '$lib/models/Pokedex';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
+import { populatePokedexMappings } from '$lib/services/PokedexMappingService';
// GET: List all pokedexes for user
export const GET = async (event: RequestEvent) => {
@@ -23,6 +24,8 @@ export const GET = async (event: RequestEvent) => {
// POST: Create new pokedex
export const POST = async (event: RequestEvent) => {
let requestedName: string | undefined;
+ let createdPokedexId: string | undefined;
+ let createdPokedexName: string | undefined;
try {
const userId = await requireAuth(event);
const data: Partial = await event.request.json();
@@ -31,6 +34,32 @@ export const POST = async (event: RequestEvent) => {
const repo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await repo.create(data);
+ createdPokedexId = pokedex._id;
+ createdPokedexName = pokedex.name;
+
+ // Populate pokedex_entries_mapping table with expected entries
+ try {
+ await populatePokedexMappings(event.locals.supabase, pokedex._id, pokedex);
+ } catch (mappingError) {
+ // Rollback: delete the partially-initialised pokedex to prevent dangling records
+ console.error(
+ `Failed to populate pokedex mappings for pokedex id="${createdPokedexId}" name="${createdPokedexName}":`,
+ mappingError
+ );
+ try {
+ await repo.delete(pokedex._id);
+ console.log(
+ `Successfully rolled back pokedex id="${createdPokedexId}" name="${createdPokedexName}"`
+ );
+ } catch (deleteError) {
+ console.error(
+ `Failed to rollback pokedex id="${createdPokedexId}" name="${createdPokedexName}":`,
+ deleteError
+ );
+ }
+ return json({ error: 'Failed to initialise Pokédex. Please try again.' }, { status: 500 });
+ }
+
return json(pokedex);
} catch (err) {
console.error('Error in POST /api/pokedexes:', err);
diff --git a/src/routes/api/pokedexes/[id]/+server.ts b/src/routes/api/pokedexes/[id]/+server.ts
index c4b71e9..1e30041 100644
--- a/src/routes/api/pokedexes/[id]/+server.ts
+++ b/src/routes/api/pokedexes/[id]/+server.ts
@@ -3,6 +3,7 @@ import type { Pokedex } from '$lib/models/Pokedex';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
+import { recalculatePokedexMappings } from '$lib/services/PokedexMappingService';
// GET: Get single pokedex by ID
export const GET = async (event: RequestEvent) => {
@@ -57,12 +58,30 @@ export const PUT = async (event: RequestEvent) => {
}
const repo = new PokedexRepository(event.locals.supabase, userId);
+
+ // Fetch the existing pokedex before update to compare values
+ const existingPokedex = await repo.findById(id);
+ if (!existingPokedex) {
+ return json({ error: 'Pokedex not found' }, { status: 404 });
+ }
+
const pokedex = await repo.update(id, data);
if (!pokedex) {
return json({ error: 'Pokedex not found' }, { status: 404 });
}
+ // Recalculate pokedex_entries_mapping if configuration changed
+ // Only recalculate if isFormDex or gameScope actually changed (these are the only fields that affect mapping)
+ // Compare the persisted result (pokedex) to existingPokedex to catch changes that repo.update may normalize or default
+ const configChanged =
+ pokedex.isFormDex !== existingPokedex.isFormDex ||
+ pokedex.gameScope !== existingPokedex.gameScope;
+
+ if (configChanged) {
+ await recalculatePokedexMappings(event.locals.supabase, id, pokedex);
+ }
+
return json(pokedex);
} catch (err) {
console.error(err);
diff --git a/src/routes/api/stats/+server.ts b/src/routes/api/stats/+server.ts
new file mode 100644
index 0000000..4fa83f8
--- /dev/null
+++ b/src/routes/api/stats/+server.ts
@@ -0,0 +1,40 @@
+import { json } from '@sveltejs/kit';
+import type { RequestEvent } from '@sveltejs/kit';
+
+/**
+ * GET /api/stats
+ *
+ * Returns public statistics about the application:
+ * - pokemonCaught: Total count of caught Pokémon
+ * - users: Total count of users
+ * - livingDexesCompleted: Total count of completed pokedexes
+ *
+ * Stats are cached for 24 hours to improve performance.
+ * The first request after 24 hours will trigger a cache refresh.
+ */
+export const GET = async (event: RequestEvent) => {
+ try {
+ // Call the database function to get stats (with caching)
+ const { data, error } = await event.locals.supabase.rpc('get_public_stats');
+
+ if (error) {
+ console.error('Error fetching stats:', error);
+ return json({ error: 'Failed to fetch stats' }, { status: 500 });
+ }
+
+ if (!data || data.length === 0) {
+ return json({ error: 'No stats data found' }, { status: 404 });
+ }
+
+ const stats = data[0];
+ return json({
+ pokemonCaught: stats.pokemon_caught,
+ users: stats.total_users,
+ livingDexesCompleted: stats.completed_pokedexes,
+ updatedAt: stats.updated_at
+ });
+ } catch (err) {
+ console.error('Error in GET /api/stats:', err);
+ return json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+};
diff --git a/src/routes/my-pokedexes/+page.server.ts b/src/routes/my-pokedexes/+page.server.ts
new file mode 100644
index 0000000..54f22e2
--- /dev/null
+++ b/src/routes/my-pokedexes/+page.server.ts
@@ -0,0 +1,21 @@
+import { redirect } from '@sveltejs/kit';
+import PokedexRepository from '$lib/repositories/PokedexRepository';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const { safeGetSession, supabase } = locals;
+ const { session, user } = await safeGetSession();
+
+ // Require authentication
+ if (!session || !user) {
+ throw redirect(303, '/signin');
+ }
+
+ // Fetch pokedexes for the authenticated user
+ const repo = new PokedexRepository(supabase, user.id);
+ const pokedexes = await repo.findAll();
+
+ return {
+ pokedexes
+ };
+};
diff --git a/src/routes/my-pokedexes/+page.svelte b/src/routes/my-pokedexes/+page.svelte
index 9677d5f..6736e62 100644
--- a/src/routes/my-pokedexes/+page.svelte
+++ b/src/routes/my-pokedexes/+page.svelte
@@ -1,12 +1,11 @@
-
-
- {#if localUser}
-
-
-
-
-
-
-
-
-
{localUser?.email}
-
Title
-
Level
-
-
-
-
-
70%
-
-
Total Pokemon Caught
-
-
-
Types Collected
-
-
Fire
-
-
- any other relevant statistics or achievements
-
-
- theme selection
-
- profile background
-
- any other features that enhance user experience and personalisation
-
-
- options for users to share their profile or achievements on social media platforms,
- encouraging engagement and interaction with other users
-
diff --git a/supabase/migrations/20260110120000_create_stats_cache.sql b/supabase/migrations/20260110120000_create_stats_cache.sql
new file mode 100644
index 0000000..96c380a
--- /dev/null
+++ b/supabase/migrations/20260110120000_create_stats_cache.sql
@@ -0,0 +1,22 @@
+-- Create stats_cache table to store cached statistics
+-- This table will be updated once per day to improve performance
+
+CREATE TABLE stats_cache (
+ id BIGSERIAL PRIMARY KEY,
+ pokemon_caught BIGINT NOT NULL DEFAULT 0,
+ total_users BIGINT NOT NULL DEFAULT 0,
+ completed_pokedexes BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Create index for faster lookups
+CREATE INDEX idx_stats_cache_updated_at ON stats_cache(updated_at DESC);
+
+-- Enable RLS
+ALTER TABLE stats_cache ENABLE ROW LEVEL SECURITY;
+
+-- RLS policies: Only allow reads (stats are public)
+CREATE POLICY "Anyone can view stats cache" ON stats_cache
+ FOR SELECT USING (true);
+
+-- No insert/update/delete policies - only database functions can modify this table
diff --git a/supabase/migrations/20260110120001_create_update_stats_function.sql b/supabase/migrations/20260110120001_create_update_stats_function.sql
new file mode 100644
index 0000000..da368f6
--- /dev/null
+++ b/supabase/migrations/20260110120001_create_update_stats_function.sql
@@ -0,0 +1,53 @@
+-- Create function to calculate and update stats in the cache table
+-- This function runs the expensive queries and stores the results
+
+CREATE OR REPLACE FUNCTION update_and_get_stats()
+RETURNS TABLE (
+ pokemon_caught BIGINT,
+ total_users BIGINT,
+ completed_pokedexes BIGINT,
+ updated_at TIMESTAMPTZ
+)
+SECURITY DEFINER
+SET search_path = public, pg_temp
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_pokemon_caught BIGINT;
+ v_total_users BIGINT;
+ v_completed_pokedexes BIGINT;
+ v_updated_at TIMESTAMPTZ := NOW();
+BEGIN
+ -- Calculate current stats: count of caught Pokémon
+ SELECT COUNT(*) INTO v_pokemon_caught
+ FROM catch_records
+ WHERE caught = true;
+
+ -- Calculate current stats: count of total users
+ SELECT COUNT(*) INTO v_total_users
+ FROM auth.users;
+
+ -- Calculate current stats: count of completed pokedexes
+ -- A pokedex is completed when all its entries have caught = true
+ SELECT COUNT(*) INTO v_completed_pokedexes
+ FROM (
+ SELECT "pokedexId"
+ FROM catch_records
+ GROUP BY "pokedexId"
+ HAVING COUNT(*) = COUNT(*) FILTER (WHERE caught = true)
+ ) completed;
+
+ -- Update cache using UPSERT pattern
+ INSERT INTO stats_cache (id, pokemon_caught, total_users, completed_pokedexes, updated_at)
+ VALUES (1, v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at)
+ ON CONFLICT (id) DO UPDATE
+ SET pokemon_caught = EXCLUDED.pokemon_caught,
+ total_users = EXCLUDED.total_users,
+ completed_pokedexes = EXCLUDED.completed_pokedexes,
+ updated_at = v_updated_at;
+
+ -- Return the stats
+ RETURN QUERY
+ SELECT v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at AS updated_at;
+END;
+$$;
diff --git a/supabase/migrations/20260110120002_create_get_stats_function.sql b/supabase/migrations/20260110120002_create_get_stats_function.sql
new file mode 100644
index 0000000..55dfff8
--- /dev/null
+++ b/supabase/migrations/20260110120002_create_get_stats_function.sql
@@ -0,0 +1,36 @@
+-- Create function to get public stats with caching
+-- Returns cached stats if fresh (within 24 hours), otherwise refreshes cache
+
+CREATE OR REPLACE FUNCTION get_public_stats()
+RETURNS TABLE (
+ pokemon_caught BIGINT,
+ total_users BIGINT,
+ completed_pokedexes BIGINT,
+ updated_at TIMESTAMPTZ
+)
+SECURITY DEFINER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_cache_exists BOOLEAN;
+BEGIN
+ -- Check if cache exists and is fresh (within 24 hours)
+ SELECT EXISTS(
+ SELECT 1 FROM stats_cache
+ WHERE stats_cache.updated_at > NOW() - INTERVAL '24 hours'
+ ) INTO v_cache_exists;
+
+ IF v_cache_exists THEN
+ -- Return cached stats
+ RETURN QUERY
+ SELECT sc.pokemon_caught, sc.total_users, sc.completed_pokedexes, sc.updated_at
+ FROM stats_cache sc
+ ORDER BY sc.updated_at DESC
+ LIMIT 1;
+ ELSE
+ -- Cache is stale or doesn't exist, update it
+ RETURN QUERY
+ SELECT * FROM update_and_get_stats();
+ END IF;
+END;
+$$;
diff --git a/supabase/migrations/20260110144000_fix_stats_functions_ambiguity.sql b/supabase/migrations/20260110144000_fix_stats_functions_ambiguity.sql
new file mode 100644
index 0000000..a998986
--- /dev/null
+++ b/supabase/migrations/20260110144000_fix_stats_functions_ambiguity.sql
@@ -0,0 +1,95 @@
+-- Fix ambiguous column reference errors in stats functions.
+--
+-- If earlier migrations have already been applied, editing them will not update
+-- the deployed database. This migration re-defines the functions using
+-- CREATE OR REPLACE so the corrected definitions take effect.
+
+CREATE OR REPLACE FUNCTION update_and_get_stats()
+RETURNS TABLE (
+ pokemon_caught BIGINT,
+ total_users BIGINT,
+ completed_pokedexes BIGINT,
+ updated_at TIMESTAMPTZ
+)
+SECURITY DEFINER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_pokemon_caught BIGINT;
+ v_total_users BIGINT;
+ v_completed_pokedexes BIGINT;
+ v_updated_at TIMESTAMPTZ := NOW();
+BEGIN
+ -- Calculate current stats: count of caught Pokémon
+ SELECT COUNT(*) INTO v_pokemon_caught
+ FROM catch_records
+ WHERE caught = true;
+
+ -- Calculate current stats: count of total users
+ SELECT COUNT(*) INTO v_total_users
+ FROM auth.users;
+
+ -- Calculate current stats: count of completed pokedexes
+ -- A pokedex is completed when all its expected entries have caught = true
+ -- Join catch_records to pokedex_entries_mapping to validate against explicit expected entries
+ SELECT COUNT(*) INTO v_completed_pokedexes
+ FROM (
+ SELECT cr."pokedexId"
+ FROM catch_records cr
+ INNER JOIN pokedex_entries_mapping pem
+ ON cr."pokedexId" = pem."pokedexId"
+ AND cr."pokedexEntryId" = pem."pokedexEntryId"
+ GROUP BY cr."pokedexId"
+ HAVING COUNT(*) = COUNT(*) FILTER (WHERE cr.caught = true)
+ AND COUNT(*) = (SELECT COUNT(*) FROM pokedex_entries_mapping WHERE "pokedexId" = cr."pokedexId")
+ ) completed;
+
+ -- Update cache using UPSERT pattern
+ INSERT INTO stats_cache (id, pokemon_caught, total_users, completed_pokedexes, updated_at)
+ VALUES (1, v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at)
+ ON CONFLICT (id) DO UPDATE
+ SET pokemon_caught = EXCLUDED.pokemon_caught,
+ total_users = EXCLUDED.total_users,
+ completed_pokedexes = EXCLUDED.completed_pokedexes,
+ updated_at = v_updated_at;
+
+ -- Return stats
+ RETURN QUERY
+ SELECT v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at AS updated_at;
+END;
+$$;
+
+
+CREATE OR REPLACE FUNCTION get_public_stats()
+RETURNS TABLE (
+ pokemon_caught BIGINT,
+ total_users BIGINT,
+ completed_pokedexes BIGINT,
+ updated_at TIMESTAMPTZ
+)
+SECURITY DEFINER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_cache_exists BOOLEAN;
+BEGIN
+ -- Check if cache exists and is fresh (within 24 hours)
+ SELECT EXISTS(
+ SELECT 1 FROM stats_cache
+ WHERE stats_cache.updated_at > NOW() - INTERVAL '24 hours'
+ ) INTO v_cache_exists;
+
+ IF v_cache_exists THEN
+ -- Return cached stats (qualify updated_at to avoid ambiguity with output parameter)
+ RETURN QUERY
+ SELECT sc.pokemon_caught, sc.total_users, sc.completed_pokedexes, sc.updated_at
+ FROM stats_cache sc
+ ORDER BY sc.updated_at DESC
+ LIMIT 1;
+ ELSE
+ -- Cache is stale or doesn't exist, update it
+ RETURN QUERY
+ SELECT * FROM update_and_get_stats();
+ END IF;
+END;
+$$;
diff --git a/supabase/migrations/20260110150000_add_pokedex_entries_mapping.sql b/supabase/migrations/20260110150000_add_pokedex_entries_mapping.sql
new file mode 100644
index 0000000..dc8ca88
--- /dev/null
+++ b/supabase/migrations/20260110150000_add_pokedex_entries_mapping.sql
@@ -0,0 +1,46 @@
+-- Add pokedex_entries_mapping table to explicitly define which entries belong to each pokedex
+-- This fixes the stats functions ambiguity issue by providing an explicit definition of expected entries
+
+-- Create pokedex_entries_mapping table
+CREATE TABLE pokedex_entries_mapping (
+ id BIGSERIAL PRIMARY KEY,
+ "pokedexId" UUID NOT NULL REFERENCES pokedexes(id) ON DELETE CASCADE,
+ "pokedexEntryId" BIGINT NOT NULL REFERENCES pokedex_entries(id) ON DELETE CASCADE,
+ UNIQUE("pokedexId", "pokedexEntryId")
+);
+
+-- Create indexes for performance
+CREATE INDEX idx_pokedex_entries_mapping_pokedex_id ON pokedex_entries_mapping("pokedexId");
+CREATE INDEX idx_pokedex_entries_mapping_entry_id ON pokedex_entries_mapping("pokedexEntryId");
+
+-- Enable RLS
+ALTER TABLE pokedex_entries_mapping ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies
+
+-- Public read access (mapping is reference data)
+CREATE POLICY "Anyone can view pokedex entries mapping"
+ ON pokedex_entries_mapping
+ FOR SELECT USING (true);
+
+-- Users can only insert mappings for their own pokedexes
+CREATE POLICY "Users can insert own pokedex mappings"
+ ON pokedex_entries_mapping
+ FOR INSERT WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM pokedexes
+ WHERE pokedexes.id = pokedex_entries_mapping."pokedexId"
+ AND pokedexes."userId" = auth.uid()
+ )
+ );
+
+-- Users can only delete mappings for their own pokedexes
+CREATE POLICY "Users can delete own pokedex mappings"
+ ON pokedex_entries_mapping
+ FOR DELETE USING (
+ EXISTS (
+ SELECT 1 FROM pokedexes
+ WHERE pokedexes.id = pokedex_entries_mapping."pokedexId"
+ AND pokedexes."userId" = auth.uid()
+ )
+ );
diff --git a/supabase/migrations/20260110150001_populate_existing_pokedex_mappings.sql b/supabase/migrations/20260110150001_populate_existing_pokedex_mappings.sql
new file mode 100644
index 0000000..5d9594d
--- /dev/null
+++ b/supabase/migrations/20260110150001_populate_existing_pokedex_mappings.sql
@@ -0,0 +1,24 @@
+-- Populate pokedex_entries_mapping for existing pokedexes
+-- This is a one-time migration to backfill data for pokedexes created before the mapping table
+
+-- Insert mappings for all existing pokedexes
+-- Apply the same filtering logic that the application uses to determine expected entries
+INSERT INTO pokedex_entries_mapping ("pokedexId", "pokedexEntryId")
+SELECT
+ p.id AS "pokedexId",
+ pe.id AS "pokedexEntryId"
+FROM pokedexes p
+LEFT JOIN region_game_mappings rg ON rg.game = p."gameScope"
+CROSS JOIN pokedex_entries pe
+WHERE
+ -- 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')
+ (p."isFormDex" = true OR (pe.form IS NULL OR (pe.pokemon = 'Unown' AND pe.form = 'A')))
+ -- Game scope filter: if gameScope is specified, filter by game
+ AND (p."gameScope" IS NULL OR pe."gamesToCatchIn" @> ARRAY[p."gameScope"]::TEXT[])
+ -- Region filter: only enforce when a mapping exists
+ AND (p."gameScope" IS NULL OR rg.region IS NULL OR pe."regionToCatchIn" = rg.region)
+ON CONFLICT ("pokedexId", "pokedexEntryId") DO NOTHING;
+
+-- Add comment to document this migration
+COMMENT ON TABLE pokedex_entries_mapping IS 'Junction table defining which pokedex_entries belong to each pokedex. Used for accurate stats calculation.';
diff --git a/supabase/migrations/20260110180000_create_recalculate_pokedex_mappings_function.sql b/supabase/migrations/20260110180000_create_recalculate_pokedex_mappings_function.sql
new file mode 100644
index 0000000..d728040
--- /dev/null
+++ b/supabase/migrations/20260110180000_create_recalculate_pokedex_mappings_function.sql
@@ -0,0 +1,44 @@
+-- Create function to atomically recalculate pokedex entries mapping
+-- This function replaces the two-step delete+insert process with a single atomic operation
+-- to prevent orphaned pokedexes if repopulation fails or yields an empty list
+
+CREATE OR REPLACE FUNCTION recalculate_pokedex_mappings(
+ p_pokedex_id UUID,
+ p_entry_ids BIGINT[]
+)
+RETURNS VOID
+SECURITY DEFINER
+SET search_path = public, pg_temp
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_user_id UUID;
+BEGIN
+ -- Validate that entry_ids is not empty to avoid accidental deletion
+ IF p_entry_ids IS NULL OR array_length(p_entry_ids, 1) IS NULL THEN
+ RAISE EXCEPTION 'entry_ids cannot be null or empty';
+ END IF;
+
+ -- Verify that the caller owns the target pokedex
+ SELECT "userId" INTO v_user_id
+ FROM pokedexes
+ WHERE id = p_pokedex_id;
+
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'pokedex not found';
+ END IF;
+
+ IF auth.uid() IS NULL OR v_user_id IS DISTINCT FROM auth.uid() THEN
+ RAISE EXCEPTION 'not authorized: you do not own this pokedex';
+ END IF;
+
+ -- Delete old mappings and insert new mappings in a single transaction
+ -- This ensures atomicity - either both operations succeed or both fail
+ DELETE FROM pokedex_entries_mapping
+ WHERE "pokedexId" = p_pokedex_id;
+
+ INSERT INTO pokedex_entries_mapping ("pokedexId", "pokedexEntryId")
+ SELECT p_pokedex_id, unnest(p_entry_ids)
+ ON CONFLICT ("pokedexId", "pokedexEntryId") DO NOTHING;
+END;
+$$;
diff --git a/supabase/migrations/20260110190000_fix_pokedex_entries_mapping_select_policy.sql b/supabase/migrations/20260110190000_fix_pokedex_entries_mapping_select_policy.sql
new file mode 100644
index 0000000..5024057
--- /dev/null
+++ b/supabase/migrations/20260110190000_fix_pokedex_entries_mapping_select_policy.sql
@@ -0,0 +1,16 @@
+-- Fix RLS SELECT policy on pokedex_entries_mapping to restrict reads to owning user
+-- This prevents exposing all user mappings and ensures consistency with INSERT/DELETE policies
+
+-- Drop the overly permissive public SELECT policy
+DROP POLICY IF EXISTS "Anyone can view pokedex entries mapping" ON pokedex_entries_mapping;
+
+-- Create a new SELECT policy that restricts reads to the owning user
+CREATE POLICY "Users can view own pokedex entries mapping"
+ ON pokedex_entries_mapping
+ FOR SELECT USING (
+ EXISTS (
+ SELECT 1 FROM pokedexes
+ WHERE pokedexes.id = pokedex_entries_mapping."pokedexId"
+ AND pokedexes."userId" = auth.uid()
+ )
+ );