diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 36f50c2..32bb592 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -83,6 +83,10 @@ jobs:
bdd:
runs-on: ubuntu-latest
timeout-minutes: 30
+ # Serve the committed sprites so offline sync doesn't download every Living Dex sprite from
+ # GitHub on each run.
+ env:
+ PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
diff --git a/package-lock.json b/package-lock.json
index ff78751..7d733c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0",
- "nanoid": "^5.0.4"
+ "nanoid": "^5.0.4",
+ "sharp": "^0.33.4"
},
"devDependencies": {
"@playwright/test": "1.55.1",
@@ -36,7 +37,6 @@
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
- "sharp": "^0.33.4",
"supabase": "2.72.7",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
@@ -46,7 +46,7 @@
"vitest": "^1.0.4"
},
"engines": {
- "node": ">=18.13.0"
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -2118,7 +2118,6 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2698,7 +2697,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2721,7 +2719,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2744,7 +2741,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2761,7 +2757,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2778,7 +2773,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2795,7 +2789,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2812,7 +2805,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2829,7 +2821,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2846,7 +2837,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2863,7 +2853,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2880,7 +2869,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2903,7 +2891,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2926,7 +2913,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2949,7 +2935,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2972,7 +2957,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2995,7 +2979,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3018,7 +3001,6 @@
"cpu": [
"wasm32"
],
- "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -3038,7 +3020,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3058,7 +3039,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5315,7 +5295,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
- "dev": true,
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
@@ -5328,7 +5307,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -5339,14 +5317,12 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
- "dev": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
@@ -5742,7 +5718,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -7140,8 +7115,7 @@
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
- "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
- "dev": true
+ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
},
"node_modules/is-bigint": {
"version": "1.0.4",
@@ -9484,7 +9458,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -9547,7 +9520,6 @@
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
- "dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -9703,7 +9675,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
- "dev": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
diff --git a/package.json b/package.json
index de48747..850b3d9 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,6 @@
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
- "sharp": "^0.33.4",
"supabase": "2.72.7",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
@@ -81,9 +80,10 @@
"dependencies": {
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0",
- "nanoid": "^5.0.4"
+ "nanoid": "^5.0.4",
+ "sharp": "^0.33.4"
},
"engines": {
- "node": ">=18.13.0"
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}
}
diff --git a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
index ffc43d1..a991184 100644
--- a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
+++ b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
@@ -1,5 +1,6 @@
+
+
+ {shared.name} - Living Dex Tracker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{shared.name}
+ Read-only shared Pokédex
+
+
+ {#each typeBadges as badge}
+ {badge}
+ {/each}
+ {shared.gameScope || 'All Games'}
+
+ {#if shared.description}
{shared.description}
{/if}
+
+ {shared.caught} of {shared.total} Pokémon caught · {shared.completionPercentage}% complete
+
+
+
+
+
+
+
+
+{#if showModal && selectedPokemon}
+
+
+
+{/if}
diff --git a/src/routes/shared/[token]/preview.png/+server.ts b/src/routes/shared/[token]/preview.png/+server.ts
new file mode 100644
index 0000000..d3d17d9
--- /dev/null
+++ b/src/routes/shared/[token]/preview.png/+server.ts
@@ -0,0 +1,18 @@
+import { error } from '@sveltejs/kit';
+import { loadSharedPokedex } from '$lib/services/SharedPokedexService';
+import { renderSharePreview } from '$lib/services/SharePreviewService';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ locals, params }) => {
+ const shared = await loadSharedPokedex(locals.supabase, params.token);
+ if (!shared) throw error(404, 'Shared Pokédex not found');
+
+ const image = await renderSharePreview(shared);
+ return new Response(image, {
+ headers: {
+ 'Content-Type': 'image/png',
+ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
+ 'Content-Length': String(image.byteLength)
+ }
+ });
+};
diff --git a/static/offline-worker.js b/static/offline-worker.js
index 90c1941..6619a3c 100644
--- a/static/offline-worker.js
+++ b/static/offline-worker.js
@@ -1,6 +1,7 @@
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
const OFFLINE_META_URL = '/__offline/current';
+const ARTWORK_FETCH_TIMEOUT_MS = 15_000;
let offlineEpoch = 0;
let offlineOperation = Promise.resolve();
let claimedUserId = null;
@@ -15,8 +16,15 @@ function dataCacheName(userId, generation) {
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
}
-function artworkCacheName(userId, generation) {
- return `${OFFLINE_CACHE_PREFIX}art-v1-${userId}-${generation}`;
+// Sprites never change at a given URL, so each user keeps one artwork cache that is topped up
+// incrementally instead of being rebuilt on every sync.
+function artworkCacheName(userId) {
+ return `${OFFLINE_CACHE_PREFIX}art-v2-${userId}`;
+}
+
+// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
+function isSpriteUrl(url) {
+ return /\/sprites(-small)?\//.test(url.pathname) && url.pathname.endsWith('.webp');
}
async function currentOfflineMeta() {
@@ -38,22 +46,39 @@ async function notifyOfflineDataCleared() {
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
}
-async function cacheArtwork(cache, urls) {
+async function cacheArtwork(cache, urls, isCurrent) {
+ const wanted = new Set(urls.map((url) => new URL(url, self.location.origin).href));
+ const cachedRequests = await cache.keys();
+ const cached = new Set(cachedRequests.map((request) => request.url));
+ await Promise.all(
+ cachedRequests
+ .filter((request) => !wanted.has(request.url))
+ .map((request) => cache.delete(request))
+ );
+ const missing = [...wanted].filter((url) => !cached.has(url));
+
let next = 0;
let failed = 0;
- const workers = Array.from({ length: Math.min(6, urls.length) }, async () => {
+ const workers = Array.from({ length: Math.min(6, missing.length) }, async () => {
for (;;) {
const index = next++;
- if (index >= urls.length) return;
- const url = urls[index];
+ if (index >= missing.length || !isCurrent()) return;
+ const url = missing[index];
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
try {
+ // CORS rather than no-cors: opaque responses are padded to several MB each for storage
+ // quota, which a full Living Dex of artwork would exhaust.
const response = await fetch(url, {
- mode: url.startsWith(self.location.origin) ? 'same-origin' : 'no-cors'
+ mode: url.startsWith(self.location.origin) ? 'same-origin' : 'cors',
+ signal: controller.signal
});
- if (!response.ok && response.type !== 'opaque') throw new Error(`HTTP ${response.status}`);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
await cache.put(url, response);
} catch {
failed++;
+ } finally {
+ clearTimeout(timeout);
}
}
});
@@ -98,11 +123,12 @@ self.addEventListener('message', (event) => {
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
const syncEpoch = offlineEpoch;
const syncUserId = claimedUserId;
+ const isCurrent = () => syncEpoch === offlineEpoch;
event.waitUntil(
queueOfflineOperation(async () => {
let nextData;
- let nextArtwork;
+ let committed = false;
try {
const snapshot = event.data.snapshot;
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
@@ -114,6 +140,13 @@ self.addEventListener('message', (event) => {
const previousMeta = await currentOfflineMeta();
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
await clearOfflineData();
+ // Per-sync artwork caches of opaque responses could fill the whole storage quota, so drop
+ // them before writing anything new.
+ await Promise.all(
+ (await caches.keys())
+ .filter((name) => name.startsWith(`${OFFLINE_CACHE_PREFIX}art-v1-`))
+ .map((name) => caches.delete(name))
+ );
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
const generation = `${timestamp}-${crypto.randomUUID()}`;
@@ -125,18 +158,12 @@ self.addEventListener('message', (event) => {
headers: { 'Content-Type': 'application/json' }
})
);
-
- nextArtwork = artworkCacheName(snapshot.userId, generation);
- const artworkCache = await caches.open(nextArtwork);
- const failedArtwork = await cacheArtwork(
- artworkCache,
- Array.from(new Set(event.data.artworkUrls ?? []))
- );
- if (syncEpoch !== offlineEpoch) {
- await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]);
+ if (!isCurrent())
throw new Error('Offline synchronization was superseded by an account change');
- }
+ // Commit the collection before any artwork so a slow or failing sprite download can never
+ // prevent the offline copy from being saved.
+ const artworkCache = artworkCacheName(snapshot.userId);
const metaCache = await caches.open(OFFLINE_META_CACHE);
await metaCache.put(
OFFLINE_META_URL,
@@ -145,40 +172,41 @@ self.addEventListener('message', (event) => {
userId: snapshot.userId,
generatedAt: snapshot.generatedAt,
dataCache: nextData,
- artworkCache: nextArtwork
+ artworkCache
}),
{
headers: { 'Content-Type': 'application/json' }
}
)
);
- if (syncEpoch !== offlineEpoch) {
+ if (!isCurrent()) {
const current = await currentOfflineMeta();
if (current?.dataCache === nextData) await metaCache.delete(OFFLINE_META_URL);
- await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]);
throw new Error('Offline synchronization was superseded by an account change');
}
- if (previousMeta?.artworkCache && previousMeta.artworkCache !== nextArtwork) {
- await caches.delete(previousMeta.artworkCache);
- }
- if (previousMeta?.dataCache && previousMeta.dataCache !== nextData) {
- await caches.delete(previousMeta.dataCache);
- }
+ committed = true;
+
const currentCaches = await caches.keys();
await Promise.all(
currentCaches
.filter(
(name) =>
name.startsWith(OFFLINE_CACHE_PREFIX) &&
- ![OFFLINE_META_CACHE, nextData, nextArtwork].includes(name)
+ ![OFFLINE_META_CACHE, nextData, artworkCache].includes(name)
)
.map((name) => caches.delete(name))
);
+
+ const failedArtwork = await cacheArtwork(
+ await caches.open(artworkCache),
+ Array.from(new Set(event.data.artworkUrls ?? [])),
+ isCurrent
+ );
+ if (!isCurrent())
+ throw new Error('Offline synchronization was superseded by an account change');
reply({ ok: true, failedArtwork });
} catch (error) {
- await Promise.allSettled(
- [nextData, nextArtwork].filter(Boolean).map((name) => caches.delete(name))
- );
+ if (!committed && nextData) await caches.delete(nextData).catch(() => undefined);
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
})
@@ -203,15 +231,22 @@ self.addEventListener('fetch', (event) => {
event.respondWith(
(async () => {
const meta = await currentOfflineMeta();
- if (meta?.artworkCache) {
- const cached = await (
- await caches.open(meta.artworkCache)
- ).match(event.request, {
- ignoreSearch: true
+ if (!meta?.artworkCache) return fetch(event.request);
+ const cache = await caches.open(meta.artworkCache);
+ const cached = await cache.match(event.request, { ignoreSearch: true });
+ if (cached) return cached;
+ if (!isSpriteUrl(url)) return fetch(event.request);
+ // Cache-first with fill-on-miss: a sprite shown online is stored once and served from the
+ // cache from then on, so the bulk sync never has to download it again.
+ try {
+ const response = await fetch(url.href, {
+ mode: url.origin === self.location.origin ? 'same-origin' : 'cors'
});
- if (cached) return cached;
+ if (response.ok) event.waitUntil(cache.put(url.href, response.clone()));
+ return response;
+ } catch {
+ return fetch(event.request);
}
- return fetch(event.request);
})()
);
});
diff --git a/supabase/migrations/20260914000000_add_pokedex_sharing.sql b/supabase/migrations/20260914000000_add_pokedex_sharing.sql
new file mode 100644
index 0000000..f850752
--- /dev/null
+++ b/supabase/migrations/20260914000000_add_pokedex_sharing.sql
@@ -0,0 +1,73 @@
+-- Stable capability links for read-only Pokédex sharing.
+ALTER TABLE pokedexes
+ ADD COLUMN "shareToken" UUID NOT NULL DEFAULT gen_random_uuid();
+
+ALTER TABLE pokedexes
+ ADD CONSTRAINT pokedexes_share_token_key UNIQUE ("shareToken");
+
+CREATE OR REPLACE FUNCTION prevent_pokedex_share_token_update()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = public, pg_temp
+AS $$
+BEGIN
+ IF NEW."shareToken" IS DISTINCT FROM OLD."shareToken" THEN
+ RAISE EXCEPTION 'shareToken is immutable';
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER prevent_pokedex_share_token_update
+ BEFORE UPDATE OF "shareToken" ON pokedexes
+ FOR EACH ROW EXECUTE FUNCTION prevent_pokedex_share_token_update();
+
+-- This is the only anonymous path into user-owned Pokédex data. Keep the returned
+-- shape deliberately narrow: no owner identity, row IDs, timestamps, or personal notes.
+CREATE OR REPLACE FUNCTION get_shared_pokedex(p_share_token UUID)
+RETURNS JSONB
+SECURITY DEFINER
+STABLE
+SET search_path = public, pg_temp
+LANGUAGE sql
+AS $$
+ SELECT jsonb_build_object(
+ 'name', p.name,
+ 'description', COALESCE(p.description, ''),
+ 'isLivingDex', COALESCE(p."isLivingDex", false),
+ 'isShinyDex', COALESCE(p."isShinyDex", false),
+ 'isOriginDex', COALESCE(p."isOriginDex", false),
+ 'isFormDex', COALESCE(p."isFormDex", false),
+ 'gameScope', p."gameScope",
+ 'dexScopes', COALESCE(
+ (
+ SELECT jsonb_agg(pds."dexId" ORDER BY pds."dexId")
+ FROM pokedex_dex_scopes pds
+ WHERE pds."pokedexId" = p.id
+ ),
+ '[]'::jsonb
+ ),
+ 'catchStatuses', COALESCE(
+ (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'pokemonId', cr."pokemonId"::text,
+ 'caught', COALESCE(cr.caught, false),
+ 'haveToEvolve', COALESCE(cr."haveToEvolve", false),
+ 'inHome', COALESCE(cr."inHome", false),
+ 'hasGigantamaxed', COALESCE(cr."hasGigantamaxed", false)
+ )
+ ORDER BY cr."pokemonId"
+ )
+ FROM catch_records cr
+ WHERE cr."pokedexId" = p.id
+ ),
+ '[]'::jsonb
+ )
+ )
+ FROM pokedexes p
+ WHERE p."shareToken" = p_share_token;
+$$;
+
+REVOKE ALL ON FUNCTION get_shared_pokedex(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION get_shared_pokedex(UUID) TO anon, authenticated;
diff --git a/tests/bdd/features/pokedex-sharing.feature b/tests/bdd/features/pokedex-sharing.feature
new file mode 100644
index 0000000..9b650bf
--- /dev/null
+++ b/tests/bdd/features/pokedex-sharing.feature
@@ -0,0 +1,19 @@
+Feature: Share a Pokédex
+ As a trainer
+ I want to share my progress without granting edit access
+ So that friends can follow my collection safely
+
+ Background:
+ Given I am signed in
+
+ Scenario: Share a live read-only Pokédex
+ Given I have a Living Dex named "Public Journey"
+ When I mark the first Pokémon as caught
+ And I add the note "share-secret-note" to the first Pokémon
+ When I open the Pokédex share dialog
+ Then I receive an unguessable read-only link
+ When I visit the shared link while signed out
+ Then I can browse the shared Pokédex without editing it
+ And the shared page does not expose the private note
+ And the shared page advertises a social progress image
+ And the social progress image is a PNG
diff --git a/tests/bdd/fixtures.ts b/tests/bdd/fixtures.ts
index 0c23723..dc647e7 100644
--- a/tests/bdd/fixtures.ts
+++ b/tests/bdd/fixtures.ts
@@ -12,6 +12,7 @@ export type ScenarioState = {
lastMessage: string | null;
caughtEntryLabel: string | null;
legacyServiceWorkerRequested: boolean;
+ shareUrl: string | null;
};
type Fixtures = { state: ScenarioState; providerMock: void };
@@ -53,7 +54,8 @@ export const test = base.extend({
lastResponseStatus: null,
lastMessage: null,
caughtEntryLabel: null,
- legacyServiceWorkerRequested: false
+ legacyServiceWorkerRequested: false,
+ shareUrl: null
});
}
});
diff --git a/tests/bdd/steps/pwa.steps.ts b/tests/bdd/steps/pwa.steps.ts
index e38cafe..4ec42f6 100644
--- a/tests/bdd/steps/pwa.steps.ts
+++ b/tests/bdd/steps/pwa.steps.ts
@@ -67,16 +67,19 @@ When('I go offline and reload the sign-in page', async ({ page }) => {
Given('my offline copy is synchronized', async ({ page, state }) => {
await waitForServiceWorker(page);
+ // A full Living Dex snapshot plus its artwork can take longer than the default poll window on CI.
await expect
- .poll(() =>
- page.evaluate(async (userId) => {
- const meta = await (
- await caches.open('livingdex-offline-meta-v1')
- ).match('/__offline/current');
- if (!meta) return false;
- const value = await meta.json();
- return value.userId === userId;
- }, state.userId)
+ .poll(
+ () =>
+ page.evaluate(async (userId) => {
+ const meta = await (
+ await caches.open('livingdex-offline-meta-v1')
+ ).match('/__offline/current');
+ if (!meta) return false;
+ const value = await meta.json();
+ return value.userId === userId;
+ }, state.userId),
+ { timeout: 30_000 }
)
.toBe(true);
const serializedSnapshot = await page.evaluate(async () => {
diff --git a/tests/bdd/steps/sharing.steps.ts b/tests/bdd/steps/sharing.steps.ts
new file mode 100644
index 0000000..82ea817
--- /dev/null
+++ b/tests/bdd/steps/sharing.steps.ts
@@ -0,0 +1,65 @@
+import { createBdd } from 'playwright-bdd';
+import { test, expect } from '../fixtures';
+
+const { When, Then } = createBdd(test);
+
+When('I open the Pokédex share dialog', async ({ page, state }) => {
+ const detailsDialog = page.getByRole('dialog').filter({ hasText: 'Notes:' });
+ if (await detailsDialog.count()) {
+ await detailsDialog.getByRole('button', { name: 'Close', exact: true }).click();
+ }
+ await page.getByRole('button', { name: 'Share', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: /Share Public Journey/ });
+ await expect(dialog).toBeVisible();
+ state.shareUrl = await dialog.getByLabel('Read-only link').inputValue();
+});
+
+Then('I receive an unguessable read-only link', async ({ state }) => {
+ expect(state.shareUrl).toMatch(
+ /^https?:\/\/[^/]+\/shared\/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+ );
+});
+
+When('I visit the shared link while signed out', async ({ page, context, state }) => {
+ if (!state.shareUrl) throw new Error('A share URL is required');
+ await context.clearCookies();
+ await page.goto(state.shareUrl);
+});
+
+Then('I can browse the shared Pokédex without editing it', async ({ page }) => {
+ await expect(page.getByRole('heading', { name: 'Public Journey' })).toBeVisible();
+ await expect(page.getByText('Read-only shared Pokédex')).toBeVisible();
+ await expect(page.getByLabel('Choose box view layout density')).toBeVisible();
+ await expect(page.getByText('Filters:', { exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Open bulk actions menu' })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: /Create Pokédex data/ })).toHaveCount(0);
+ await expect(page.getByText('Personal notes')).toHaveCount(0);
+});
+
+Then('the shared page does not expose the private note', async ({ page }) => {
+ await expect(page.getByText('share-secret-note')).toHaveCount(0);
+});
+
+Then('the shared page advertises a social progress image', async ({ page }) => {
+ const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
+ const image = await page.locator('meta[property="og:image"]').getAttribute('content');
+ expect(canonical).toBe(page.url());
+ expect(image).toBe(`${page.url()}/preview.png`);
+ await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute(
+ 'content',
+ 'summary_large_image'
+ );
+ await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, nofollow');
+ await expect(page.locator('meta[name="referrer"]')).toHaveAttribute('content', 'no-referrer');
+});
+
+Then('the social progress image is a PNG', async ({ page }) => {
+ const image = await page.locator('meta[property="og:image"]').getAttribute('content');
+ if (!image) throw new Error('Open Graph image URL is required');
+ const response = await page.request.get(image);
+ expect(response.status()).toBe(200);
+ expect(response.headers()['content-type']).toBe('image/png');
+ expect(response.headers()['cache-control']).toContain('max-age=300');
+ const body = await response.body();
+ expect([...body.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
+});
diff --git a/tests/integration/sharing.integration.test.ts b/tests/integration/sharing.integration.test.ts
new file mode 100644
index 0000000..5afd914
--- /dev/null
+++ b/tests/integration/sharing.integration.test.ts
@@ -0,0 +1,138 @@
+import { createClient } from '@supabase/supabase-js';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { requireLoopbackUrl } from '../support/loopback';
+
+const url = requireLoopbackUrl(
+ process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
+ 'TEST_SUPABASE_URL'
+);
+const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
+const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
+
+describe('read-only Pokédex sharing', () => {
+ const createdUserIds: string[] = [];
+
+ beforeAll(() => {
+ if (!anonKey || !serviceKey) {
+ throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY');
+ }
+ });
+
+ afterAll(async () => {
+ if (!serviceKey) return;
+ const admin = createClient(url, serviceKey);
+ await Promise.all(createdUserIds.map((id) => admin.auth.admin.deleteUser(id)));
+ });
+
+ it('generates a stable token and exposes only sanitized data through the RPC', async () => {
+ const admin = createClient(url, serviceKey!);
+ const email = `integration-sharing-${Date.now()}@example.test`;
+ const password = 'Integration123!';
+ const created = await admin.auth.admin.createUser({ email, password, email_confirm: true });
+ expect(created.error).toBeNull();
+ const userId = created.data.user!.id;
+ createdUserIds.push(userId);
+
+ const { data: dex, error: dexError } = await admin
+ .from('pokedexes')
+ .insert({
+ userId,
+ name: 'Shared & Safe',
+ description: 'Public description',
+ isLivingDex: true
+ })
+ .select('id, shareToken')
+ .single();
+ expect(dexError).toBeNull();
+ expect(dex!.shareToken).toMatch(/^[0-9a-f-]{36}$/i);
+
+ const { data: pokemon } = await admin
+ .from('pokemon')
+ .select('id')
+ .order('id')
+ .limit(1)
+ .single();
+ expect(
+ (
+ await admin.from('catch_records').insert({
+ userId,
+ pokedexId: dex!.id,
+ pokemonId: pokemon!.id,
+ caught: true,
+ inHome: true,
+ personalNotes: 'This must remain private'
+ })
+ ).error
+ ).toBeNull();
+
+ const anonymous = createClient(url, anonKey!);
+ const direct = await anonymous.from('pokedexes').select('*').eq('id', dex!.id);
+ expect(direct.error).toBeNull();
+ expect(direct.data).toEqual([]);
+ const directCatchRecords = await anonymous
+ .from('catch_records')
+ .select('*')
+ .eq('pokedexId', dex!.id);
+ expect(directCatchRecords.error).toBeNull();
+ expect(directCatchRecords.data).toEqual([]);
+
+ const result = await anonymous.rpc('get_shared_pokedex', {
+ p_share_token: dex!.shareToken
+ });
+ expect(result.error).toBeNull();
+ expect(result.data).toMatchObject({
+ name: 'Shared & Safe',
+ description: 'Public description',
+ catchStatuses: [
+ {
+ pokemonId: String(pokemon!.id),
+ caught: true,
+ inHome: true
+ }
+ ]
+ });
+ const serialized = JSON.stringify(result.data);
+ expect(serialized).not.toContain(userId);
+ expect(serialized).not.toContain(dex!.shareToken);
+ expect(serialized).not.toContain('This must remain private');
+ expect(serialized).not.toContain('personalNotes');
+
+ const attemptedWrite = await anonymous
+ .from('catch_records')
+ .update({ caught: false })
+ .eq('pokedexId', dex!.id)
+ .eq('pokemonId', pokemon!.id)
+ .select();
+ expect(attemptedWrite.error).toBeNull();
+ expect(attemptedWrite.data).toEqual([]);
+ const unchanged = await admin
+ .from('catch_records')
+ .select('caught')
+ .eq('pokedexId', dex!.id)
+ .eq('pokemonId', pokemon!.id)
+ .single();
+ expect(unchanged.data?.caught).toBe(true);
+
+ const owner = createClient(url, anonKey!);
+ expect((await owner.auth.signInWithPassword({ email, password })).error).toBeNull();
+ const ownerDex = await owner.from('pokedexes').select('shareToken').eq('id', dex!.id).single();
+ expect(ownerDex.data?.shareToken).toBe(dex!.shareToken);
+ expect(
+ (await owner.from('pokedexes').update({ shareToken: crypto.randomUUID() }).eq('id', dex!.id))
+ .error
+ ).not.toBeNull();
+
+ const missing = await anonymous.rpc('get_shared_pokedex', {
+ p_share_token: crypto.randomUUID()
+ });
+ expect(missing.error).toBeNull();
+ expect(missing.data).toBeNull();
+
+ expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull();
+ const deleted = await anonymous.rpc('get_shared_pokedex', {
+ p_share_token: dex!.shareToken
+ });
+ expect(deleted.error).toBeNull();
+ expect(deleted.data).toBeNull();
+ });
+});
diff --git a/tests/unit/sharePreviewService.test.ts b/tests/unit/sharePreviewService.test.ts
new file mode 100644
index 0000000..001beb8
--- /dev/null
+++ b/tests/unit/sharePreviewService.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+import sharp from 'sharp';
+import {
+ buildSharePreviewSvg,
+ escapeXml,
+ renderSharePreview,
+ SHARE_PREVIEW_HEIGHT,
+ SHARE_PREVIEW_WIDTH,
+ truncatePreviewText
+} from '$lib/services/SharePreviewService';
+import type { SharedPokedexData } from '$lib/models/SharedPokedex';
+
+const shared: SharedPokedexData = {
+ name: 'Johto & ',
+ description: 'A shared collection',
+ isLivingDex: true,
+ isShinyDex: false,
+ isOriginDex: false,
+ isFormDex: false,
+ gameScope: null,
+ dexScopes: [],
+ combinedData: [],
+ total: 100,
+ caught: 42,
+ completionPercentage: 42
+};
+
+describe('share preview rendering', () => {
+ it('escapes XML and truncates normalized user text', () => {
+ expect(escapeXml(`Tom & Jerry's`)).toBe(
+ '<tag attr="x">Tom & Jerry's</tag>'
+ );
+ expect(truncatePreviewText(' lots of\nspace ', 20)).toBe('lots of space');
+ expect(truncatePreviewText('abcdefghij', 6)).toBe('abcde…');
+ });
+
+ it('builds a branded progress card without raw user markup', () => {
+ const svg = buildSharePreviewSvg(shared);
+ expect(svg).toContain('Johto & <Friends>');
+ expect(svg).not.toContain('Johto & ');
+ expect(svg).toContain('42 of 100 Pokémon caught');
+ expect(svg).toContain('42%');
+ });
+
+ it('lists every enabled dex badge and omits an empty description', () => {
+ const svg = buildSharePreviewSvg({
+ ...shared,
+ description: '',
+ isShinyDex: true,
+ isOriginDex: true,
+ isFormDex: true,
+ gameScope: 'Scarlet'
+ });
+ for (const badge of ['Living', 'Shiny', 'Origin', 'Form', 'Scarlet']) {
+ expect(svg).toContain(`class="badge">${badge}`);
+ }
+ expect(svg).not.toContain('All Games');
+ expect(svg).not.toContain('class="description"');
+ });
+
+ it('renders a valid 1200 by 630 PNG', async () => {
+ const png = await renderSharePreview(shared);
+ const metadata = await sharp(png).metadata();
+ expect(metadata.format).toBe('png');
+ expect(metadata.width).toBe(SHARE_PREVIEW_WIDTH);
+ expect(metadata.height).toBe(SHARE_PREVIEW_HEIGHT);
+ });
+});
diff --git a/tests/unit/sharedPokedexService.test.ts b/tests/unit/sharedPokedexService.test.ts
new file mode 100644
index 0000000..5b5cc49
--- /dev/null
+++ b/tests/unit/sharedPokedexService.test.ts
@@ -0,0 +1,126 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { SupabaseClient } from '@supabase/supabase-js';
+import type { PokedexEntry } from '$lib/models/PokedexEntry';
+import type { SharedPokedexRpcData } from '$lib/models/SharedPokedex';
+
+const { findAllCombinedData } = vi.hoisted(() => ({ findAllCombinedData: vi.fn() }));
+vi.mock('$lib/repositories/CombinedDataRepository', () => ({
+ default: vi.fn().mockImplementation(() => ({ findAllCombinedData }))
+}));
+
+import {
+ calculateSharedProgress,
+ isShareToken,
+ loadSharedPokedex
+} from '$lib/services/SharedPokedexService';
+
+const TOKEN = '123e4567-e89b-42d3-a456-426614174000';
+
+function supabaseWithRpc(result: { data: unknown; error: unknown }) {
+ const rpc = vi.fn().mockResolvedValue(result);
+ return { supabase: { rpc } as unknown as SupabaseClient, rpc };
+}
+
+describe('shared Pokédex helpers', () => {
+ it('accepts UUID capability tokens and rejects malformed route values', () => {
+ expect(isShareToken('123e4567-e89b-42d3-a456-426614174000')).toBe(true);
+ expect(isShareToken('not-a-token')).toBe(false);
+ expect(isShareToken('123e4567-e89b-12d3-a456-426614174000/extra')).toBe(false);
+ });
+
+ it('counts caught and needs-to-evolve entries as progress', () => {
+ const progress = calculateSharedProgress(
+ [
+ {
+ pokemonId: '1',
+ caught: true,
+ haveToEvolve: false,
+ inHome: false,
+ hasGigantamaxed: false
+ },
+ {
+ pokemonId: '2',
+ caught: false,
+ haveToEvolve: true,
+ inHome: false,
+ hasGigantamaxed: false
+ }
+ ],
+ 4
+ );
+ expect(progress).toEqual({ caught: 2, completionPercentage: 50 });
+ expect(calculateSharedProgress([], 0)).toEqual({ caught: 0, completionPercentage: 0 });
+ });
+});
+
+describe('loadSharedPokedex', () => {
+ beforeEach(() => {
+ findAllCombinedData.mockReset();
+ });
+
+ it('rejects malformed tokens without querying', async () => {
+ const { supabase, rpc } = supabaseWithRpc({ data: null, error: null });
+ expect(await loadSharedPokedex(supabase, 'not-a-token')).toBeNull();
+ expect(rpc).not.toHaveBeenCalled();
+ });
+
+ it('returns null when the token matches nothing or the RPC fails', async () => {
+ expect(
+ await loadSharedPokedex(supabaseWithRpc({ data: null, error: null }).supabase, TOKEN)
+ ).toBeNull();
+ expect(
+ await loadSharedPokedex(
+ supabaseWithRpc({ data: null, error: { message: 'boom' } }).supabase,
+ TOKEN
+ )
+ ).toBeNull();
+ expect(findAllCombinedData).not.toHaveBeenCalled();
+ });
+
+ it('maps shared catch statuses onto the scoped dex entries', async () => {
+ const rpcData: SharedPokedexRpcData = {
+ name: 'Kanto',
+ description: 'Gen 1',
+ isLivingDex: true,
+ isShinyDex: false,
+ isOriginDex: false,
+ isFormDex: true,
+ gameScope: null,
+ dexScopes: ['kanto'],
+ catchStatuses: [
+ { pokemonId: '1', caught: true, haveToEvolve: false, inHome: true, hasGigantamaxed: false },
+ {
+ pokemonId: '99',
+ caught: true,
+ haveToEvolve: false,
+ inHome: false,
+ hasGigantamaxed: false
+ }
+ ]
+ };
+ const entries = ['1', '2', '3', '4'].map((id) => ({
+ pokedexEntry: { _id: id } as PokedexEntry,
+ catchRecord: null
+ }));
+ findAllCombinedData.mockResolvedValue(entries);
+ const { supabase, rpc } = supabaseWithRpc({ data: rpcData, error: null });
+
+ const shared = await loadSharedPokedex(supabase, TOKEN);
+
+ expect(rpc).toHaveBeenCalledWith('get_shared_pokedex', { p_share_token: TOKEN });
+ expect(findAllCombinedData).toHaveBeenCalledWith('', true, '', '', ['kanto']);
+ expect(shared?.combinedData.map((entry) => entry.catchRecord?.pokemonId ?? null)).toEqual([
+ '1',
+ null,
+ null,
+ null
+ ]);
+ // Statuses for Pokémon outside the dex scope must not inflate progress.
+ expect(shared).toMatchObject({
+ name: 'Kanto',
+ total: 4,
+ caught: 1,
+ completionPercentage: 25
+ });
+ });
+});