Merge pull request #78 from jcreek/64-update-styling-on-the-about-page

feat(#64): Improve the about page
This commit is contained in:
Josh Creek
2026-01-10 19:29:49 +00:00
committed by GitHub
22 changed files with 996 additions and 558 deletions
+20 -6
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
let email = '';
let password = '';
@@ -17,13 +20,18 @@
});
if (error) {
throw error;
console.error('Sign up error:', error);
alert(`Sign up failed: ${error.message}`);
return;
}
// Handle success (optional)
} catch (error) {
console.error('Sign up error:', error.message);
// Handle error
if (data) {
// Emit signedUp event to notify parent component
dispatch('signedUp', {});
}
} catch (err) {
console.error('Sign up error:', err);
alert('Sign up failed');
}
}
</script>
@@ -54,6 +62,12 @@
/>
</div>
<div class="form-control mt-6">
<button class="btn btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
<button class="btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
</div>
</form>
<style>
.card-body {
padding: 0.5rem;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
import type { Pokedex } from '$lib/models/Pokedex';
import type { SupabaseClient } from '@supabase/supabase-js';
/**
* Calculate expected pokedex entries based on pokedex configuration
* Applies the same filtering logic as CombinedDataRepository
* @throws Error if the Supabase query fails
*/
export async function calculateExpectedEntries(
supabase: SupabaseClient,
pokedex: Pokedex
): Promise<number[]> {
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,and(pokemon.eq.Unown,form.eq.A)');
}
// Apply region filter: if gameScope is specified, filter by region
if (pokedex.gameScope) {
// Determine region from gameScope using region_game_mappings
const { data: regionData, error: regionError } = await supabase
.from('region_game_mappings')
.select('region')
.eq('game', pokedex.gameScope)
.maybeSingle();
if (regionError) {
console.error('Error looking up region for gameScope:', pokedex.gameScope, regionError);
throw new Error(
`Failed to lookup region for gameScope "${pokedex.gameScope}": ${regionError.message}`
);
}
if (regionData?.region) {
query = query.eq('regionToCatchIn', regionData.region);
}
}
// Apply game filter: if gameScope is specified, filter by gamesToCatchIn array
if (pokedex.gameScope) {
query = query.contains('gamesToCatchIn', [pokedex.gameScope]);
}
const { data, error } = await query;
if (error) {
console.error('Error calculating expected entries:', error);
throw new Error(`Failed to calculate expected entries: ${error.message}`);
}
return data?.map((entry) => entry.id) || [];
}
/**
* Populate pokedex_entries_mapping table for a pokedex
* Uses chunked upsert to avoid UNIQUE constraint violations and request size limits
* @throws Error if the Supabase upsert fails
*/
export async function populatePokedexMappings(
supabase: SupabaseClient,
pokedexId: string,
pokedex: Pokedex
): Promise<void> {
const expectedEntryIds = await calculateExpectedEntries(supabase, pokedex);
if (expectedEntryIds.length === 0) {
console.warn(`No expected entries calculated for pokedex ${pokedexId}`);
return;
}
const mappings = expectedEntryIds.map((pokedexEntryId) => ({
pokedexId,
pokedexEntryId
}));
// Process in chunks to avoid request size limits
const CHUNK_SIZE = 500;
for (let i = 0; i < mappings.length; i += CHUNK_SIZE) {
const chunk = mappings.slice(i, i + CHUNK_SIZE);
const { error } = await supabase.from('pokedex_entries_mapping').upsert(chunk, {
onConflict: 'pokedexId,pokedexEntryId',
ignoreDuplicates: true
});
if (error) {
console.error('Error populating pokedex mappings:', error);
throw new Error(`Failed to populate pokedex mappings: ${error.message}`);
}
}
}
/**
* Recalculate pokedex_entries_mapping table for a pokedex (used on update)
* Uses a single atomic RPC call to prevent orphaned pokedexes
* @throws Error if the Supabase RPC call fails
*/
export async function recalculatePokedexMappings(
supabase: SupabaseClient,
pokedexId: string,
pokedex: Pokedex
): Promise<void> {
const expectedEntryIds = await calculateExpectedEntries(supabase, pokedex);
if (expectedEntryIds.length === 0) {
console.warn(`No expected entries calculated for pokedex ${pokedexId}`);
return;
}
// Call the atomic RPC function to delete old mappings and insert new ones
const { error } = await supabase.rpc('recalculate_pokedex_mappings', {
p_pokedex_id: pokedexId,
p_entry_ids: expectedEntryIds
});
if (error) {
console.error('Error recalculating pokedex mappings:', error);
throw new Error(`Failed to recalculate pokedex mappings: ${error.message}`);
}
}
+19 -7
View File
@@ -24,6 +24,19 @@
onMount(async () => {
await getUser();
// Listen for auth state changes to keep the user store in sync
const {
data: { subscription }
} = supabase.auth.onAuthStateChange((event, session) => {
console.log('[Layout] Auth state changed:', event, session ? 'Session found' : 'No session');
if (session) {
localUser = session.user;
} else {
localUser = null;
}
user.set(localUser);
});
if (pwaInfo) {
const { registerSW } = await import('virtual:pwa-register');
registerSW({
@@ -41,6 +54,10 @@
}
});
}
return () => {
subscription.unsubscribe();
};
});
async function getUser() {
@@ -91,7 +108,7 @@
<header>
<div class="navbar bg-primary text-primary-content">
<div class="navbar-start">
<div class="dropdown">
<!-- <div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -110,12 +127,11 @@
<ul
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-primary text-primary-content rounded-box w-52"
>
<li><a href="/about">About</a></li>
<li>
<a href="https://github.com/jcreek/LivingDexTracker" target="_blank">Contribute</a>
</li>
</ul>
</div>
</div> -->
</div>
<div class="navbar-center">
<a class="btn btn-ghost text-xl" href="/">Living Dex Tracker</a>
@@ -151,10 +167,6 @@
<li>
<a href="/my-pokedexes"> My Pokédexes </a>
</li>
<li>
<a href="/profile"> Profile </a>
</li>
<li><a href="/settings">Settings</a></li>
<li><SignOut {supabase} on:signedOut={getUser} /></li>
{:else}
<li><SignIn {supabase} on:signedIn={getUser} /></li>
+17
View File
@@ -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
};
};
+369 -118
View File
@@ -1,141 +1,392 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js';
import SignUp from '$lib/components/SignUp.svelte';
import { goto } from '$app/navigation';
export let data;
let { supabase } = data;
$: ({ supabase } = data);
let { supabase, stats } = data;
$: ({ supabase, stats } = data);
let localUser: User | null;
const unsubscribe = user.subscribe((value) => {
localUser = value;
});
onDestroy(unsubscribe);
let isCheckingSession = true;
onMount(() => {
checkSessionAndRedirect();
});
async function checkSessionAndRedirect() {
try {
const {
data: { session }
} = await supabase.auth.getSession();
if (session) {
await goto('/my-pokedexes');
}
} catch (error) {
console.error('Error checking session:', error);
} finally {
isCheckingSession = false;
}
}
async function handleSignedUp() {
await goto('/welcome');
}
// Format large numbers (e.g., 1000 -> 1K, 1000000 -> 1M)
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(0) + 'K';
}
return num.toString();
}
// Get formatted stats or fallback to 0
let pokemonCaught = '0';
let users = '0';
let livingDexesCompleted = '0';
$: {
pokemonCaught = formatNumber(stats?.pokemonCaught ?? 0);
users = formatNumber(stats?.users ?? 0);
livingDexesCompleted = formatNumber(stats?.livingDexesCompleted ?? 0);
}
</script>
<div class="hero bg-base-100 my-36">
<div class="hero-content flex-col lg:flex-row-reverse">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold">Start Your Pokédex Journey!</h1>
</div>
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
<SignUp {supabase} />
<svelte:head>
<title>Living Dex Tracker - Track Your Pokédex Journey</title>
<meta
name="description"
content="A free, open source tool to track your Living Pokédex progress. Join thousands of trainers worldwide in completing their collection."
/>
</svelte:head>
{#if isCheckingSession}
<!-- Loading placeholder while checking session -->
<div class="hero bg-base-100 my-36">
<div class="hero-content flex-col">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
<div
class="md:container md:mx-auto justify-self-center flex justify-center items-center h-full flex-col mb-10"
>
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center">
<div class="stat">
<div class="stat-title">Pokémon caught</div>
<div class="stat-value">310K</div>
<div class="stat-desc">Since January 2024</div>
</div>
<div class="stat">
<div class="stat-title">New Users</div>
<div class="stat-value">4,200</div>
<div class="stat-desc">↗︎ 400 (22%)</div>
</div>
<div class="stat">
<div class="stat-title">Living Dexes Completed</div>
<div class="stat-value">1,200</div>
<div class="stat-desc">↘︎ 90 (14%)</div>
{:else}
<!-- Hero Section -->
<div class="hero bg-base-100 my-36">
<div class="hero-content flex-col lg:flex-row-reverse">
<div class="text-center lg:text-left">
<div class="flex flex-wrap gap-2 mb-4">
<div class="badge badge-primary badge-lg gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
Free
</div>
<div class="badge badge-secondary badge-lg gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
Open Source
</div>
<div class="badge badge-accent badge-lg gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clip-rule="evenodd"
/>
</svg>
Offline-friendly
</div>
</div>
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
<p class="text-xl mb-6 text-base-content/80">
Track your progress towards a complete Living Pokédex — one of every Pokémon, actively
maintained across your boxes. Join thousands of trainers worldwide.
</p>
<div class="flex flex-wrap gap-3 justify-center lg:justify-start">
<a
href="https://discord.gg/2ytj4pkUPY"
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary btn-lg"
aria-label="Join the Living Dex Tracker Discord community"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
Join Discord
</a>
<a
href="https://github.com/jcreek/LivingDexTracker"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-lg"
aria-label="View the Living Dex Tracker project on GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
Contribute on GitHub
</a>
</div>
</div>
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Get Started Free</h2>
<SignUp {supabase} on:signedUp={handleSignedUp} />
</div>
</div>
</div>
</div>
<div class="features mt-8">
<h1 class="flex justify-center text-4xl font-bold mb-5">Features</h1>
<ul class="space-y-6">
<li class="p-6 rounded-lg shadow-md bg-neutral">
<div class="flex items-start space-x-4">
<svg
class="w-8 h-8 text-green-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<div>
<h2 class="text-lg font-semibold">Social</h2>
<p>
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go it
alone, that's okay too!
</p>
</div>
<!-- Stats Section -->
<div class="bg-base-200 py-16">
<div class="container mx-auto px-4">
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
<div class="stat">
<div class="stat-title">Pokémon caught</div>
<div class="stat-value text-primary">{pokemonCaught}</div>
</div>
</li>
<li class="p-6 rounded-lg shadow-md bg-neutral">
<div class="flex items-start space-x-4">
<svg
class="w-8 h-8 text-green-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<div>
<h2 class="text-lg font-semibold">Free & Open Source</h2>
<p>
Completely open source and free to use, enabling the community to contribute updates
as soon as new Pokémon are released.
</p>
</div>
<div class="stat">
<div class="stat-title">Users</div>
<div class="stat-value text-primary">{users}</div>
</div>
</li>
<li class="p-6 rounded-lg shadow-md bg-neutral">
<div class="flex items-start space-x-4">
<svg
class="w-8 h-8 text-green-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<div>
<h2 class="text-lg font-semibold">Easy to use with advanced filtering</h2>
<p>
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.
</p>
</div>
<div class="stat">
<div class="stat-title">Living Dexes Completed</div>
<div class="stat-value text-primary">{livingDexesCompleted}</div>
</div>
</li>
<li class="p-6 rounded-lg shadow-md bg-neutral">
<div class="flex items-start space-x-4">
<svg
class="w-8 h-8 text-green-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<div>
<h2 class="text-lg font-semibold">100% free</h2>
<p>Did we mention it's completely free to use? Oh, we did? Good. Because it is.</p>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- What is a Living Dex Section -->
<div class="py-16 bg-base-100">
<div class="container mx-auto px-4 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-3xl mb-4">What is a Living Dex?</h2>
<p class="text-lg text-base-content/80">
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.
</p>
</div>
</div>
</div>
</div>
<!-- Features Section -->
<div class="py-16 bg-base-200">
<div class="container mx-auto px-4 max-w-6xl">
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
<div class="grid md:grid-cols-2 gap-6">
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Social & Shareable</h2>
<p class="text-base-content/80">
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go
it alone, that's okay too!
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
<p class="text-base-content/80">
Completely open source and free to use, enabling the community to contribute
updates as soon as new Pokémon are released.
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Advanced Filtering</h2>
<p class="text-base-content/80">
Track simple progress or tackle harder variants like a Living Origin Form Dex with
our powerful filtering options for targeted catching sessions.
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">100% free</h2>
<p class="text-base-content/80">
Did we mention it's completely free to use? Oh, we did? Good. Because it is.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CTA Section -->
<div class="py-16 bg-base-100">
<div class="container mx-auto px-4 text-center">
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
Get started today with tracking your Living Pokédex progress. It's free, open source, and
built with love for the Pokémon community.
</p>
<div class="flex flex-wrap gap-4 justify-center">
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
<div class="card-body">
<h3 class="card-title text-xl mb-4">Sign Up Now</h3>
<SignUp {supabase} on:signedUp={handleSignedUp} />
</div>
</div>
</div>
</div>
</div>
<!-- Legal Section -->
<div class="py-8 bg-base-200">
<div class="container mx-auto px-4 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-lg mb-2">Legal Disclaimer</h2>
<p class="text-sm text-base-content/70">
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.
</p>
</div>
</div>
</div>
</div>
{/if}
-327
View File
@@ -1,327 +0,0 @@
<svelte:head>
<title>About Us</title>
<meta name="description" content="About this app" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Staatliches&display=swap"
rel="stylesheet"
/>
</svelte:head>
<div class="content h-full bg-base inline-block">
<div id="grad">
<div id="banner-content">
<p class=" text-xs opacity-70 tracking-wide">Learn</p>
<h2 class=" mt-0 pt-0 text-5xl tracking-widest text-center font-bold p-24">
About<br /> Us
</h2>
</div>
</div>
<div id="introduction">
<p class="font-semibold text-xl mb-5 ml-5">
Introducing <span class="typewriter-text underline decoration-accent">Living Dex Tracker</span
>
</p>
<p class="text-base mt-10 mb-5 ml-5 mr-5">
<img src="/placeholder-bulb.png" alt="bulb" class="float-right w-28" />
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.
</p>
<p class="font-semibold text-xl mt-20 mb-5 ml-5">Features</p>
<!-- TODO (#41) - Make this a card carousel -->
<div class="card md:card-side bg-base-00 shadow-m">
<figure>
<img src="/OIG3.jpeg" alt="Album" />
</figure>
<div class="card-body">
<p class="card-title">Player Dex Search</p>
<p>Find and explore your friends' Pokémon collections effortlessly.</p>
</div>
</div>
<div class="bg-neutral pb-20">
<p class="font-semibold text-white text-xl mt-0 mb-5 ml-5">Meet The Team</p>
<div class="avatar flex items-center justify-end mb-20">
<p class="text-m mb-5 ml-5 mr-5">Team Member 1</p>
<div class="w-24 rounded-full flex justify-end">
<img src="/OIG5.jpg" alt="profile" />
</div>
</div>
<div class="avatar">
<div class="w-24 rounded-full">
<img src="/OIG5.jpg" alt="profile " />
</div>
<p class="text-m mb-5 ml-5 mr-5">Team Member 2</p>
</div>
</div>
<div id="project-details">
<p class="font-semibold text-xl mt-10 mb-5 ml-5">The Developement</p>
<p class="text-sm mb-5 ml-5 mr-5">
Share insights into the development process, challenges faced, and any interesting anecdotes
or milestones achieved during the project's journey.
</p>
<ul class="timeline timeline-vertical">
<li>
<div class="timeline-start timeline-box">Example</div>
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-primary"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/></svg
>
</div>
<hr class="bg-primary" />
</li>
<li>
<hr class="bg-primary" />
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-primary"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/></svg
>
</div>
<div class="timeline-end timeline-box">Example</div>
<hr class="bg-primary" />
</li>
<li>
<hr class="bg-primary" />
<div class="timeline-start timeline-box">Example</div>
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-primary"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/></svg
>
</div>
<hr />
</li>
<li>
<hr />
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/></svg
>
</div>
<div class="timeline-end timeline-box">Example</div>
<hr />
</li>
<li>
<hr />
<div class="timeline-start timeline-box">Example</div>
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/></svg
>
</div>
</li>
</ul>
</div>
<p class="font-semibold text-xl mt-10 mb-5 ml-5">Join Us On Discord!</p>
<div id="contact-information ">
<a href="https://discord.gg/2ytj4pkUPY">
<button
class="relative inline-flex items-center justify-center w-16 p-0.5 ml-5 mb-20 me-2 overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-cyan-500 to-blue-500 group-hover:from-cyan-500 group-hover:to-blue-500 hover:text-white dark:text-white focus:ring-4 focus:outline-none focus:ring-cyan-200 dark:focus:ring-cyan-800"
>
<img src="/discord1.svg" class="w-12" /></button
>
</a>
</div>
<div class="contact-form">
<p class="font-semibold text-xl mt-0 mb-5 text-white pt-10">Contact Form</p>
<input type="text" name="first" placeholder="First Name" autocomplete="off" required />
<input type="text" name="last" placeholder="Last Name" autocomplete="off" required />
<input type="email" name="Email" placeholder="Email Address" autocomplete="off" required />
<textarea
rows="5"
cols="50"
name="message"
placeholder="Enter text"
autocomplete="off"
required
/>
<button type="submit" id="submit-button">Send Message</button>
</div>
<div id="legal-information">
<p class="font-semibold text-xl mt-0 mb-5 ml-5 text-white pt-10">Legal</p>
<p class="text-sm mb-5 ml-5 mr-5">
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.
</p>
<p class="text-sm mb-5 ml-5 mr-5">
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.
</p>
</div>
<div id="Acknowledgments">
<p class="font-semibold text-xl mt-0 mb-5 ml-5 text-white pt-10">Acknowledgments</p>
<p class="text-sm mb-5 ml-5 mr-5">
Express gratitude to any individuals or organisations that have contributed to the project's
success, including sponsors, supporters, or open-source contributors.
</p>
</div>
</div>
</div>
<style>
#banner-content {
padding-top: 170px;
}
#grad {
font-family: 'Pixelify Sans', sans-serif;
font-weight: 400;
font-style: normal;
width: 100%;
height: 422px;
margin: 0 auto;
background: linear-gradient(45deg, #d2001a, #7462ff, #f48e21, #23d5ab);
color: #fff;
background-size: 300% 300%;
animation: color 12s ease-in-out infinite;
text-align: center;
}
@keyframes color {
0% {
background-position: 0 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
#introduction .font-semibold {
font-family: 'Staatliches', sans-serif;
font-weight: 400;
font-style: normal;
font-size: 25px;
padding-top: 20px;
}
.avatar {
margin-left: 10px;
margin-right: 10px;
}
.contact-form {
background-color: rgb(216, 224, 178);
width: 380px;
height: 590px;
border-radius: 20px;
margin-bottom: 40px;
margin-left: 10px;
margin-right: 10px;
}
.contact-form > p {
margin-left: 9px;
}
input {
background-color: rgb(156, 186, 139);
width: 70%;
margin-left: 12%;
padding: 16px 16px;
border-radius: 10px;
border-color: rgb(131, 167, 124);
border-width: 2px;
margin-bottom: 20px;
font-family: 'Staatliches', sans-serif;
}
textarea {
background-color: rgb(156, 186, 139);
width: 70%;
margin-left: 12%;
padding: 16px 16px;
border-radius: 10px;
border-color: rgb(83, 145, 50);
margin-bottom: 25px;
font-family: 'Staatliches', sans-serif;
}
input:focus-within {
outline: none;
border-color: rgb(169, 227, 157);
border-width: 3px;
font-family: 'Staatliches', sans-serif;
}
textarea:focus-within {
outline: none;
border-color: rgb(169, 227, 157);
border-width: 3px;
}
#submit-button {
background-color: rgb(185, 201, 135);
border-radius: 20px;
border-color: #fff;
padding: 10px 18px;
text-align: center;
display: flex;
margin: auto;
margin-bottom: 20px;
}
button:hover {
opacity: 80%;
cursor: pointer;
}
</style>
-9
View File
@@ -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;
+29
View File
@@ -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<Pokedex> = 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);
+19
View File
@@ -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);
+40
View File
@@ -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 });
}
};
+21
View File
@@ -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
};
};
+2 -11
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores/user';
import type { Pokedex } from '$lib/models/Pokedex';
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
let pokedexes: Pokedex[] = [];
export let data;
let { pokedexes } = data;
let showModal = false;
let editingPokedex: Pokedex | null = null;
let formData: Partial<Pokedex> = {
@@ -21,14 +20,6 @@
$: mode = editingPokedex ? ('edit' as const) : ('create' as const);
onMount(async () => {
if (!$user) {
goto('/signin');
return;
}
await loadPokedexes();
});
async function loadPokedexes() {
const response = await fetch('/api/pokedexes', { credentials: 'include' });
if (response.ok) {
-78
View File
@@ -1,78 +0,0 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js';
export let data;
let { supabase } = data;
$: ({ supabase } = data);
let localUser: User | null;
const unsubscribe = user.subscribe((value) => {
localUser = value;
});
onDestroy(unsubscribe);
</script>
<div class="h-screen">
{#if localUser}
<div id="banner">
<div id="avatar">
<div
class="avatar mt-10 ml-20 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2"
>
<div class="rounded-full"><img src="/OIG5.jpg" alt="profile icon" /></div>
</div>
<button class="btn btn-outline btn-primary">Upload new avatar</button>
</div>
<div id="personal-information">
<p class="text-white font-mono text-3xl font-bold">{localUser?.email}</p>
<p class="text-white font-mono text-3xl font-bold">Title</p>
<p class="text-white font-mono text-3xl font-bold">Level</p>
</div>
</div>
<div id="profile-content">
<div id="pokemon-collection-summary">
<div class="radial-progress top-0 left-0" style="--value:70;" role="progressbar">70%</div>
<div class="caught-text top-20 left-30 font-mono text-xl">
<p>Total Pokemon Caught</p>
</div>
<h2 class="">Types Collected</h2>
<ul>
<li class="type-list-item font-mono">Fire</li>
</ul>
<br />
any other relevant statistics or achievements
</div>
<div id="customisation-options">
theme selection
<br />
profile background
<br />
any other features that enhance user experience and personalisation
</div>
<div id="social-sharing">
options for users to share their profile or achievements on social media platforms,
encouraging engagement and interaction with other users
</div>
</div>
{:else}
<p>Please log in to view this page</p>
{/if}
</div>
<style>
#banner {
background: rgb(41, 106, 170);
background: linear-gradient(0deg, rgba(41, 106, 170, 1) 0%, rgba(123, 225, 255, 1) 100%);
}
#profile-content {
background-color: rgb(255, 247, 238);
color: black;
}
.ring {
box-shadow: 0 0 0 4px rgb(149, 175, 230);
}
</style>
+2 -2
View File
@@ -97,10 +97,10 @@
<p class="text-sm opacity-70 mt-2">
Still having trouble?
<a
href="mailto:support@example.com"
href="https://github.com/jcreek/LivingDexTracker/issues"
class="link link-primary font-semibold hover:underline"
>
Contact support
Raise an issue on our GitHub
</a> for assistance.
</p>
</div>
@@ -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
@@ -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;
$$;
@@ -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;
$$;
@@ -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;
$$;
@@ -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()
)
);
@@ -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.';
@@ -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;
$$;
@@ -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()
)
);