perf: fix slow page loads and gate performance in CI

- Ship one hashed Tailwind stylesheet instead of two (one render-blocking)
- Compress responses in-app (brotli/gzip, streaming-safe) and precompress
  the node build so local and CI measurements match production
- Validate the Supabase session once per request
- Render the homepage immediately and stream public stats
- Stream a Pokedex's entries with the page instead of fetching after
  hydration, running the rows and count queries in parallel
- Shrink the avatar and offline placeholder images, fix layout shift,
  contrast, link names and missing meta descriptions
- Add Lighthouse CI (mobile + desktop) with score and metric budgets,
  bundle-size budgets in the build tests, and signed-in speed scenarios
This commit is contained in:
Josh Creek
2026-09-14 20:53:53 +01:00
parent 556f120f16
commit ff29095c47
30 changed files with 4166 additions and 487 deletions
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-1
View File
@@ -18,7 +18,6 @@
})();
</script>
%sveltekit.head%
<link rel="stylesheet" href="%sveltekit.assets%/output.css" />
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
+21 -13
View File
@@ -1,6 +1,7 @@
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { createServerClient } from '@supabase/ssr';
import type { Handle } from '@sveltejs/kit';
import { compressResponse } from '$lib/server/compression';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
@@ -24,24 +25,31 @@ export const handle: Handle = async ({ event, resolve }) => {
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.safeGetSession = async () => {
const {
data: { user },
error
} = await event.locals.supabase.auth.getUser();
if (error) {
return { session: null, user: null };
}
// getUser is a network round trip to Supabase Auth. Layout and page loads (and API routes) all
// ask for the session, so validate once per request and share the result.
let sessionPromise: ReturnType<App.Locals['safeGetSession']> | null = null;
event.locals.safeGetSession = () => {
sessionPromise ??= (async () => {
const {
data: { user },
error
} = await event.locals.supabase.auth.getUser();
if (error) {
return { session: null, user: null };
}
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
return { session, user };
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
return { session, user };
})();
return sessionPromise;
};
return resolve(event, {
const response = await resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range';
}
});
return compressResponse(event.request, response);
};
+73
View File
@@ -0,0 +1,73 @@
import { Readable } from 'node:stream';
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
import { constants, createBrotliCompress, createGzip } from 'node:zlib';
export type Encoding = 'br' | 'gzip';
// Text responses the app renders or returns from API routes. Images, fonts and other binary
// content is already compressed, so re-compressing it only costs CPU.
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json)|image\/svg\+xml)/i;
/** Picks the best encoding the client accepts, preferring brotli. Honours `q=0` refusals. */
export function pickEncoding(acceptEncoding: string | null): Encoding | null {
if (!acceptEncoding) return null;
const accepted = new Map<string, number>();
for (const part of acceptEncoding.split(',')) {
const [name, ...params] = part.trim().toLowerCase().split(';');
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
accepted.set(name, q ? Number(q.slice(2)) : 1);
}
const allows = (name: Encoding) => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0;
if (allows('br')) return 'br';
if (allows('gzip')) return 'gzip';
return null;
}
// adapter-netlify's Lambda handler serialises text responses with `response.text()`, which would
// corrupt a compressed body. Netlify compresses function responses itself, so skip it there.
// Everywhere else (the Node build, local preview, CI) the app compresses its own responses.
const serialisesBodiesAsText = () => Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
function shouldCompress(request: Request, response: Response): boolean {
if (serialisesBodiesAsText()) return false;
if (!response.body || request.method === 'HEAD') return false;
if (response.status < 200 || response.status === 204 || response.status === 304) return false;
if (response.headers.has('content-encoding')) return false;
return COMPRESSIBLE.test(response.headers.get('content-type') ?? '');
}
/**
* Compresses a rendered page or API response so its transfer size doesn't depend on the host.
* Every chunk is flushed as soon as it is written, so SvelteKit's streamed load data still reaches
* the browser progressively instead of waiting for the whole body.
*/
export function compressResponse(request: Request, response: Response): Response {
if (!shouldCompress(request, response)) return response;
const encoding = pickEncoding(request.headers.get('accept-encoding'));
const headers = new Headers(response.headers);
// Caches must key on the request encoding even when this response isn't compressed.
headers.append('vary', 'Accept-Encoding');
if (!encoding) return new Response(response.body, { status: response.status, headers });
const compressor =
encoding === 'br'
? createBrotliCompress({
flush: constants.BROTLI_OPERATION_FLUSH,
// Quality 11 is for build-time precompression; 5 is fast enough per request.
params: { [constants.BROTLI_PARAM_QUALITY]: 5 }
})
: createGzip({ flush: constants.Z_SYNC_FLUSH, level: 6 });
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream);
source.on('error', (error) => compressor.destroy(error));
source.pipe(compressor);
headers.set('content-encoding', encoding);
headers.delete('content-length');
return new Response(Readable.toWeb(compressor) as unknown as ReadableStream, {
status: response.status,
statusText: response.statusText,
headers
});
}
+42
View File
@@ -0,0 +1,42 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import type { Pokedex } from '$lib/models/Pokedex';
export type CombinedDataQuery = {
page: number;
limit: number;
enableForms: boolean;
region?: string;
game?: string;
};
/**
* Loads one page of a Pokédex's entries joined with the owner's catch records. Shared by the
* combined-data API and the Pokédex page's server load so both return exactly the same data.
* The caller must already have checked that `userId` owns `pokedex`.
*/
export async function loadCombinedDataPage(
supabase: SupabaseClient,
userId: string,
pokedex: Pokedex,
{ page, limit, enableForms, region = '', game = '' }: CombinedDataQuery
) {
// Use the pokédex's gameScope as the default filter if no manual game filter is set.
const effectiveGame = game || pokedex.gameScope || '';
const dexScopes = await resolveDexScopes(supabase, pokedex);
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
// The rows and the count are independent queries, so run them together.
const [combinedData, totalCount] = await Promise.all([
repo.findCombinedData(userId, page, limit, enableForms, region, effectiveGame, dexScopes),
repo.countCombinedData(enableForms, region, effectiveGame, dexScopes)
]);
return {
combinedData,
totalPages: Math.ceil(totalCount / limit),
currentPage: page,
totalCount
};
}
+17 -4
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import 'tailwindcss/tailwind.css';
// The only app stylesheet: Vite bundles, minifies and content-hashes it so it is cached for good.
// static/output.css is built separately for the credential-free offline.html page only.
import '../app.css';
import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js';
@@ -180,7 +182,7 @@
{#if localUser}
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<img alt="usericon" src="/OIG5.jpg" />
<img alt="Account menu" src="/avatar.webp" width="40" height="40" />
</div>
</div>
<ul
@@ -247,8 +249,14 @@
<footer class="footer items-center p-4 bg-neutral text-neutral-content bottom-0">
<aside class="items-center grid-flow-col">
<a href="https://github.com/jcreek/LivingDexTracker" target="_blank">
<a
href="https://github.com/jcreek/LivingDexTracker"
target="_blank"
rel="noopener noreferrer"
aria-label="Living Dex Tracker on GitHub"
>
<svg
aria-hidden="true"
width="36"
height="36"
fill-rule="evenodd"
@@ -269,8 +277,13 @@
</p>
</aside>
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
<a href="https://discord.gg/SQcJkaXDye" target="_blank"
<a
href="https://discord.gg/SQcJkaXDye"
target="_blank"
rel="noopener noreferrer"
aria-label="Living Dex Tracker Discord community"
><svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
+20 -9
View File
@@ -1,17 +1,28 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export type PublicStats = {
pokemonCaught: number;
users: number;
livingDexesCompleted: number;
};
/**
* 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.
* Signed-in users go straight to their Pokédexes. For everyone else the page renders at once and
* the public statistics stream in afterwards, so a slow stats query never delays the first paint.
*/
export const load: PageServerLoad = async ({ fetch }) => {
// Fetch stats from database
const statsResponse = await fetch('/api/stats');
const statsData = await statsResponse.json();
export const load: PageServerLoad = async ({ fetch, locals }) => {
const { user } = await locals.safeGetSession();
if (user) {
throw redirect(303, '/my-pokedexes');
}
return {
stats: statsData.error ? null : statsData
};
const stats: Promise<PublicStats | null> = fetch('/api/stats')
.then((response) => response.json())
.then((statsData) => (statsData.error ? null : statsData))
.catch(() => null);
return { stats };
};
+302 -338
View File
@@ -1,35 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import SignUp from '$lib/components/SignUp.svelte';
import { goto } from '$app/navigation';
// Signed-in visitors are redirected by the server load, so the page renders straight away.
export let data;
let { supabase, stats } = data;
$: ({ supabase, stats } = data);
// Redirection is decided from the live session below, so the user store is not needed here.
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');
}
@@ -44,16 +21,6 @@
}
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>
<svelte:head>
@@ -72,322 +39,319 @@
/>
</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>
<!-- 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>
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
<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
</a>
</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>
{: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>
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
<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
</a>
</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>
<!-- 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">
{#await stats}{:then value}{formatNumber(value?.pokemonCaught ?? 0)}{/await}
</div>
</div>
<div class="stat">
<div class="stat-title">Users</div>
<div class="stat-value text-primary">
{#await stats}{:then value}{formatNumber(value?.users ?? 0)}{/await}
</div>
</div>
<div class="stat">
<div class="stat-title">Living Dexes Completed</div>
<div class="stat-value text-primary">
{#await stats}{:then value}{formatNumber(value?.livingDexesCompleted ?? 0)}{/await}
</div>
</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">
<h2 class="card-title text-2xl mb-4">Get Started Free</h2>
<h3 class="card-title text-xl mb-4">Sign Up Now</h3>
<SignUp {supabase} on:signedUp={handleSignedUp} />
</div>
</div>
</div>
</div>
</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>
<div class="stat">
<div class="stat-title">Users</div>
<div class="stat-value text-primary">{users}</div>
</div>
<div class="stat">
<div class="stat-title">Living Dexes Completed</div>
<div class="stat-value text-primary">{livingDexesCompleted}</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>
<!-- 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}
</div>
@@ -1,9 +1,8 @@
import { json } from '@sveltejs/kit';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { getOptionalUserId } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
// GET: Get combined data (pokédex entries + catch records) for specific pokédex
export const GET = async (event: RequestEvent) => {
@@ -23,48 +22,29 @@ export const GET = async (event: RequestEvent) => {
const region = url.searchParams.get('region') || '';
const game = url.searchParams.get('game') || '';
// If authenticated, verify user owns this pokédex and get its gameScope
let pokedex;
if (userId) {
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
return json({ error: 'Pokedex not found' }, { status: 404 });
}
} else {
if (!userId) {
// Anonymous users cannot view pokédexes
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Use pokédex's gameScope as default filter if no manual game filter is set
const effectiveGame = game || pokedex.gameScope || '';
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
// Verify the user owns this pokédex and get its gameScope
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
const repo = new CombinedDataRepository(event.locals.supabase, userId, pokedexId);
if (!pokedex) {
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
return json({ error: 'Pokedex not found' }, { status: 404 });
}
// Get paginated combined data
const combinedData = await repo.findCombinedData(
userId!,
page,
limit,
enableForms,
region,
effectiveGame,
dexScopes
return json(
await loadCombinedDataPage(event.locals.supabase, userId, pokedex, {
page,
limit,
enableForms,
region,
game
})
);
// Get total count for pagination
const totalCount = await repo.countCombinedData(enableForms, region, effectiveGame, dexScopes);
const totalPages = Math.ceil(totalCount / limit);
return json({
combinedData,
totalPages,
currentPage: page,
totalCount
});
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
+4
View File
@@ -27,6 +27,10 @@ export const GET = async (event: RequestEvent) => {
}
const stats = data[0];
// The figures refresh at most daily, so let browsers and the CDN reuse them.
event.setHeaders({
'cache-control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400'
});
return json({
pokemonCaught: stats.pokemon_caught,
users: stats.total_users,
+4
View File
@@ -15,6 +15,10 @@
<svelte:head>
<title>Using Offline - Living Dex Tracker</title>
<meta
name="description"
content="How Living Dex Tracker keeps your Pokédexes and artwork available offline, and how to save everything before you lose signal."
/>
</svelte:head>
<div class="container mx-auto p-4 max-w-screen-lg">
+20 -1
View File
@@ -1,7 +1,11 @@
import { error, redirect } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
import type { PageServerLoad } from './$types';
// Must match the page's itemsPerPage: the box view needs the whole dex in one page.
const INITIAL_PAGE_SIZE = 9999;
export const load: PageServerLoad = async ({ locals, params }) => {
const { safeGetSession, supabase } = locals;
const { session, user } = await safeGetSession();
@@ -22,7 +26,22 @@ export const load: PageServerLoad = async ({ locals, params }) => {
throw error(404, 'Pokédex not found');
}
// Streamed rather than awaited: the page shell renders straight away and the entries arrive in
// the same response, instead of the browser requesting them after hydration. A failure resolves
// to null so the page falls back to fetching (and reporting) through the API.
const initialCombinedData = loadCombinedDataPage(supabase, user.id, pokedex, {
page: 1,
limit: INITIAL_PAGE_SIZE,
enableForms: pokedex.isFormDex
})
.then((result) => result.combinedData)
.catch((err) => {
console.error('Unable to preload combined data', err);
return null;
});
return {
pokedex
pokedex,
initialCombinedData
};
};
+29 -2
View File
@@ -502,8 +502,35 @@
});
}
// Fetch data whenever pagination controls change (client-side only)
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
// Show data whenever the dex or pagination changes (client-side only). The first page is streamed
// from the server load, so it only needs fetching when that failed or the page changes.
let shownKey = '';
function showPage(
id: string,
page: number,
perPage: number,
initial: Promise<CombinedData[] | null> | undefined
) {
const key = `${id}:${page}:${perPage}`;
if (key === shownKey) return;
shownKey = key;
if (page !== 1 || !initial) {
void getData({ page, perPage });
return;
}
combinedData = null;
void initial.then((rows) => {
if (shownKey !== key) return;
if (!rows) {
void getData({ page, perPage });
return;
}
combinedData = rows;
boxNumbers = calculateBoxNumbers(rows.length);
});
}
$: if (browser && pokedexId)
showPage(pokedexId, currentPage, itemsPerPage, data?.initialCombinedData);
onMount(() => {
if (!browser) return;
+7 -19
View File
@@ -22,8 +22,12 @@
<div class="min-h-[calc(100vh-16rem)] bg-base-100 py-8 md:py-16 px-4 sm:px-6 lg:px-8">
<div class="max-w-6xl mx-auto">
<!-- Success Badge -->
{#if showSuccess}
<div class="flex justify-center mb-8 animate-fade-in">
<!-- Always rendered (faded in) so the hero doesn't jump down when the badge appears. -->
<div
class="flex justify-center mb-8 transition-opacity duration-500"
class:opacity-0={!showSuccess}
>
<div class="flex justify-center">
<div class="badge badge-success badge-lg gap-2 p-6 shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -42,7 +46,7 @@
<span class="text-lg font-semibold">Account Created Successfully!</span>
</div>
</div>
{/if}
</div>
<!-- Hero Content -->
<div class="hero">
@@ -315,21 +319,6 @@
</div>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.5s ease-out forwards;
}
@keyframes pulse {
0%,
100% {
@@ -346,7 +335,6 @@
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-pulse {
animation: none;
}