mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 18:42:03 +00:00
fix: remediate branch review findings
This commit is contained in:
@@ -9,6 +9,9 @@ concurrency:
|
|||||||
group: tests-${{ github.ref }}
|
group: tests-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
# `$env/static/public` is resolved at build time, so every variable imported from it must be
|
# `$env/static/public` is resolved at build time, so every variable imported from it must be
|
||||||
# present for `vite build` and `svelte-check` to succeed on a clean checkout. These are the
|
# present for `vite build` and `svelte-check` to succeed on a clean checkout. These are the
|
||||||
# local-stack defaults already published in .env.local.example - never real credentials.
|
# local-stack defaults already published in .env.local.example - never real credentials.
|
||||||
@@ -20,16 +23,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
quality:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- name: Check committed whitespace
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
git diff --check "${{ github.event.pull_request.base.sha }}...HEAD"
|
||||||
|
else
|
||||||
|
git diff --check "HEAD^...HEAD"
|
||||||
|
fi
|
||||||
- run: npm run check
|
- run: npm run check
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run test:fast
|
- run: npm run test:fast
|
||||||
|
- name: Verify tests leave the checkout clean
|
||||||
|
run: test -z "$(git status --porcelain --untracked-files=all)"
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
@@ -39,6 +54,7 @@ jobs:
|
|||||||
|
|
||||||
integration:
|
integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -54,6 +70,7 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -65,6 +82,7 @@ jobs:
|
|||||||
|
|
||||||
bdd:
|
bdd:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ yarn.lock
|
|||||||
# changes under tens of thousands of lines of churn.
|
# changes under tens of thousands of lines of churn.
|
||||||
src/lib/helpers/pokeapi-pokemon.json
|
src/lib/helpers/pokeapi-pokemon.json
|
||||||
static/sprites/pokemon.json
|
static/sprites/pokemon.json
|
||||||
|
src/lib/helpers/pokedex.json
|
||||||
|
src/lib/helpers/sprites.json
|
||||||
|
|
||||||
# Machine-local editor and tool settings.
|
# Machine-local editor and tool settings.
|
||||||
**/*.local.json
|
**/*.local.json
|
||||||
|
|||||||
@@ -87,10 +87,14 @@ teardown deletes the users each run creates, so repeated local runs do not need
|
|||||||
Chromium is the only configured browser project. Playwright traces and screenshots are retained on
|
Chromium is the only configured browser project. Playwright traces and screenshots are retained on
|
||||||
failure under `test-results`.
|
failure under `test-results`.
|
||||||
|
|
||||||
Known gap: the password-reset scenarios use an ordinary signed-in session rather than a recovery link,
|
Password-reset scenarios follow recovery links generated by the local Supabase stack and verify both
|
||||||
because following a real recovery link currently bounces to `/signin` - the browser client in
|
the rejected old password and accepted replacement password. The application waits for Supabase to
|
||||||
`src/routes/+layout.ts` has no cookie `set`/`remove` method, so it cannot persist the session it parses
|
confirm the recovery session before enabling the replacement form.
|
||||||
out of the URL. `createRecoveryLink` in `tests/bdd/support/app.ts` is ready for when that is fixed.
|
|
||||||
|
After sign-in, the application automatically stores a versioned, per-user read-only copy of every
|
||||||
|
Pokédex and its referenced artwork. Offline navigation opens a static viewer; all mutation and
|
||||||
|
authentication controls remain unavailable until connectivity returns. A successful sign-out removes
|
||||||
|
the user-specific snapshot and artwork caches from the device.
|
||||||
|
|
||||||
The current National Dex maximum is deliberately asserted as 1025. When adding a new generation,
|
The current National Dex maximum is deliberately asserted as 1025. When adding a new generation,
|
||||||
update that expectation together with Pokémon data, the corresponding game/dex files, database seed,
|
update that expectation together with Pokémon data, the corresponding game/dex files, database seed,
|
||||||
|
|||||||
@@ -56,6 +56,34 @@ if (!anonKey || !serviceRoleKey) {
|
|||||||
fail(`Supabase status did not return an anonymous and service-role key.\n${SETUP_HINT}`);
|
fail(`Supabase status did not return an anonymous and service-role key.\n${SETUP_HINT}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLoopbackUrl(value) {
|
||||||
|
try {
|
||||||
|
return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(value).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackUrl(apiUrl)) {
|
||||||
|
fail(`Refusing to run local-stack tests against non-loopback Supabase URL: ${apiUrl}`);
|
||||||
|
}
|
||||||
|
const providerUrlVariables = [
|
||||||
|
'MOCK_PROVIDER_URL',
|
||||||
|
'GOOGLE_OAUTH_AUTHORIZE_URL',
|
||||||
|
'GOOGLE_OAUTH_TOKEN_URL',
|
||||||
|
'GOOGLE_DRIVE_API_URL',
|
||||||
|
'GOOGLE_DRIVE_UPLOAD_URL',
|
||||||
|
'DROPBOX_OAUTH_AUTHORIZE_URL',
|
||||||
|
'DROPBOX_OAUTH_TOKEN_URL',
|
||||||
|
'DROPBOX_UPLOAD_URL'
|
||||||
|
];
|
||||||
|
for (const name of providerUrlVariables) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value && !isLoopbackUrl(value)) {
|
||||||
|
fail(`Refusing to run provider tests with non-loopback ${name}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A running-but-unseeded database is the most common broken state, and it surfaces downstream as
|
// A running-but-unseeded database is the most common broken state, and it surfaces downstream as
|
||||||
// a confusing assertion failure. Check it here instead.
|
// a confusing assertion failure. Check it here instead.
|
||||||
const probe = await fetch(`${apiUrl}/rest/v1/pokedex_entries?select=id&limit=1`, {
|
const probe = await fetch(`${apiUrl}/rest/v1/pokedex_entries?select=id&limit=1`, {
|
||||||
@@ -79,12 +107,13 @@ const child = spawnSync(command, args, {
|
|||||||
shell: useShell,
|
shell: useShell,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
PUBLIC_SUPABASE_URL: process.env.PUBLIC_SUPABASE_URL ?? apiUrl,
|
// Never allow an exported production value to redirect a local integration run.
|
||||||
PUBLIC_SUPABASE_ANON_KEY: process.env.PUBLIC_SUPABASE_ANON_KEY ?? anonKey,
|
PUBLIC_SUPABASE_URL: apiUrl,
|
||||||
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY ?? serviceRoleKey,
|
PUBLIC_SUPABASE_ANON_KEY: anonKey,
|
||||||
TEST_SUPABASE_URL: process.env.TEST_SUPABASE_URL ?? apiUrl,
|
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
||||||
TEST_SUPABASE_ANON_KEY: process.env.TEST_SUPABASE_ANON_KEY ?? anonKey,
|
TEST_SUPABASE_URL: apiUrl,
|
||||||
E2E_SERVICE_ROLE_KEY: process.env.E2E_SERVICE_ROLE_KEY ?? serviceRoleKey
|
TEST_SUPABASE_ANON_KEY: anonKey,
|
||||||
|
E2E_SERVICE_ROLE_KEY: serviceRoleKey
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||||
import { inView } from '$lib/actions/inView';
|
import { inView } from '$lib/actions/inView';
|
||||||
|
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
export let pokemonName: string;
|
export let pokemonName: string;
|
||||||
export let pokedexNumber: string | number;
|
export let pokedexNumber: string | number;
|
||||||
@@ -12,46 +13,7 @@
|
|||||||
let imagePath = null as string | null;
|
let imagePath = null as string | null;
|
||||||
let isInView = false;
|
let isInView = false;
|
||||||
|
|
||||||
function isFemaleForm(value?: string) {
|
|
||||||
return /^female\b/i.test((value ?? '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFallbackKey() {
|
|
||||||
const strippedPokedexNumber = pokedexNumber.toString().replace(/^0+/, '') || '0';
|
|
||||||
if (!form) return strippedPokedexNumber;
|
|
||||||
|
|
||||||
let formValue = form.trim();
|
|
||||||
formValue = formValue.replace(/^female[-\s]*/i, '');
|
|
||||||
formValue = formValue
|
|
||||||
.replace(/\s*\(.*?\)/g, '')
|
|
||||||
.replace(/\s*\[.*?\]/g, '')
|
|
||||||
.trim();
|
|
||||||
if (!formValue || formValue.toLowerCase() === 'male') return strippedPokedexNumber;
|
|
||||||
|
|
||||||
formValue = formValue
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/%/g, '')
|
|
||||||
.replace(/\balolan\b/g, 'alola')
|
|
||||||
.replace(/\bgalarian\b/g, 'galar')
|
|
||||||
.replace(/\bhisuian\b/g, 'hisui')
|
|
||||||
.replace(/\bpaldean\b/g, 'paldea')
|
|
||||||
.replace(/\bform(e)?$/, '')
|
|
||||||
.replace(/\bability$/, '')
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/(^-|-$)/g, '')
|
|
||||||
.replace(/2/g, 'two')
|
|
||||||
.replace(/3/g, 'three')
|
|
||||||
.replace(/4/g, 'four');
|
|
||||||
|
|
||||||
return formValue ? `${strippedPokedexNumber}-${formValue}` : strippedPokedexNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
const rootFolderBase =
|
|
||||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
|
||||||
? '/sprites-small/home'
|
|
||||||
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
|
||||||
const resolvedSpriteKey = spriteKey?.trim() || buildFallbackKey();
|
|
||||||
if (!spriteKey?.trim()) {
|
if (!spriteKey?.trim()) {
|
||||||
console.warn('Missing sprite key for pokemon entry', {
|
console.warn('Missing sprite key for pokemon entry', {
|
||||||
pokemonName,
|
pokemonName,
|
||||||
@@ -60,14 +22,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootFolder = rootFolderBase;
|
imagePath = resolveSpriteUrl(
|
||||||
if (shiny) {
|
{ pokedexNumber: Number(pokedexNumber), form, spriteKey },
|
||||||
rootFolder += '/shiny';
|
!!shiny,
|
||||||
}
|
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||||
if (isFemaleForm(form)) {
|
);
|
||||||
rootFolder += '/female';
|
|
||||||
}
|
|
||||||
imagePath = `${rootFolder}/${resolvedSpriteKey}.webp`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (loadingStrategy !== 'inView') {
|
$: if (loadingStrategy !== 'inView') {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { clearOfflineData } from '$lib/stores/offlineSync';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
let errorMessage = '';
|
||||||
|
let isSigningOut = false;
|
||||||
|
|
||||||
function emitSignedOutEvent() {
|
function emitSignedOutEvent() {
|
||||||
dispatch('signedOut', {});
|
dispatch('signedOut', {});
|
||||||
@@ -12,15 +15,32 @@
|
|||||||
export let supabase: SupabaseClient;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
// `.then(() => {...})` resolved to undefined, so destructuring `error` off it threw a
|
errorMessage = '';
|
||||||
// TypeError on every sign-out - after the event had already been emitted.
|
isSigningOut = true;
|
||||||
const { error } = await supabase.auth.signOut();
|
try {
|
||||||
if (error) console.error('Sign out failed', error);
|
const { error } = await supabase.auth.signOut();
|
||||||
emitSignedOutEvent();
|
if (error) {
|
||||||
// Signing out used to leave the user sitting on the protected page they were on, still
|
errorMessage = `Sign out failed: ${error.message || 'Please try again.'}`;
|
||||||
// showing its content. Send them to the public home page and re-run the server loads.
|
dispatch('signOutFailed', { message: errorMessage });
|
||||||
await goto('/', { invalidateAll: true });
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await clearOfflineData();
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.error('Signed out, but failed to clear offline data', cacheError);
|
||||||
|
}
|
||||||
|
emitSignedOutEvent();
|
||||||
|
await goto('/', { invalidateAll: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign out failed', error);
|
||||||
|
errorMessage = 'Sign out failed. Please try again.';
|
||||||
|
dispatch('signOutFailed', { message: errorMessage });
|
||||||
|
} finally {
|
||||||
|
isSigningOut = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button on:click={signOut}>Sign Out</button>
|
<button on:click={signOut} disabled={isSigningOut}>
|
||||||
|
{isSigningOut ? 'Signing Out…' : 'Sign Out'}
|
||||||
|
</button>
|
||||||
|
|||||||
+35489
-29190
File diff suppressed because it is too large
Load Diff
+29197
-29101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
import type { CombinedData } from './CombinedData';
|
||||||
|
import type { Pokedex } from './Pokedex';
|
||||||
|
|
||||||
|
export const OFFLINE_SNAPSHOT_VERSION = 1;
|
||||||
|
|
||||||
|
export type OfflinePokedexSnapshot = {
|
||||||
|
pokedex: Pokedex;
|
||||||
|
entries: CombinedData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OfflineSnapshot = {
|
||||||
|
version: typeof OFFLINE_SNAPSHOT_VERSION;
|
||||||
|
generatedAt: string;
|
||||||
|
userId: string;
|
||||||
|
pokedexes: OfflinePokedexSnapshot[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||||
|
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||||
|
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
|
export type OfflineSyncStatus = {
|
||||||
|
state: 'idle' | 'syncing' | 'ready' | 'partial' | 'error';
|
||||||
|
generatedAt: string | null;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const offlineSyncStatus = writable<OfflineSyncStatus>({
|
||||||
|
state: 'idle',
|
||||||
|
generatedAt: null,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const SYNC_EVENT = 'livingdex:offline-sync';
|
||||||
|
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
|
||||||
|
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
|
||||||
|
const OFFLINE_META_URL = '/__offline/current';
|
||||||
|
|
||||||
|
async function workerMessage(
|
||||||
|
message: unknown,
|
||||||
|
timeoutMs = 120_000,
|
||||||
|
waitForReady = true
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (!('serviceWorker' in navigator)) throw new Error('Service workers are unavailable');
|
||||||
|
const registration = waitForReady
|
||||||
|
? await navigator.serviceWorker.ready
|
||||||
|
: await navigator.serviceWorker.getRegistration();
|
||||||
|
if (!registration?.active) throw new Error('Offline worker is not active');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
const timeout = window.setTimeout(
|
||||||
|
() => reject(new Error('Offline worker timed out')),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
const result = event.data as Record<string, unknown>;
|
||||||
|
if (result?.ok) resolve(result);
|
||||||
|
else reject(new Error(String(result?.error ?? 'Offline worker failed')));
|
||||||
|
};
|
||||||
|
registration.active?.postMessage(message, [channel.port2]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestOfflineSync(): void {
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOfflineCaches(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined' || !('caches' in window)) return;
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimOfflineData(userId: string): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if ('caches' in window) {
|
||||||
|
const names = await caches.keys();
|
||||||
|
if (names.includes(OFFLINE_META_CACHE)) {
|
||||||
|
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||||
|
const meta = await response?.json().catch(() => null);
|
||||||
|
if (typeof meta?.userId !== 'string' || meta.userId !== userId) await deleteOfflineCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||||
|
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearOfflineData(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
await deleteOfflineCaches();
|
||||||
|
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||||
|
await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false);
|
||||||
|
}
|
||||||
|
offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||||
|
let timer: number | null = null;
|
||||||
|
let stopped = false;
|
||||||
|
let running = false;
|
||||||
|
let rerun = false;
|
||||||
|
let lastGeneratedAt: string | null = null;
|
||||||
|
|
||||||
|
const synchronize = async () => {
|
||||||
|
if (stopped || !navigator.onLine) return;
|
||||||
|
if (running) {
|
||||||
|
rerun = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = getUserId();
|
||||||
|
if (!userId || !('serviceWorker' in navigator)) return;
|
||||||
|
running = true;
|
||||||
|
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
|
||||||
|
try {
|
||||||
|
await claimOfflineData(userId);
|
||||||
|
const response = await fetch('/api/offline-snapshot', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Snapshot request failed (${response.status})`);
|
||||||
|
const snapshot = (await response.json()) as OfflineSnapshot;
|
||||||
|
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
|
||||||
|
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
|
||||||
|
const artworkUrls = Array.from(
|
||||||
|
new Set(
|
||||||
|
snapshot.pokedexes.flatMap(({ pokedex, entries }) =>
|
||||||
|
entries.map(({ pokedexEntry }) => {
|
||||||
|
const url = resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
|
||||||
|
(
|
||||||
|
pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }
|
||||||
|
).offlineSpriteUrl = url;
|
||||||
|
return url;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const result = await workerMessage({
|
||||||
|
type: 'SYNC_OFFLINE_SNAPSHOT',
|
||||||
|
snapshot,
|
||||||
|
artworkUrls
|
||||||
|
});
|
||||||
|
const failed = Number(result.failedArtwork ?? 0);
|
||||||
|
offlineSyncStatus.set({
|
||||||
|
state: failed > 0 ? 'partial' : 'ready',
|
||||||
|
generatedAt: snapshot.generatedAt,
|
||||||
|
message: failed > 0 ? `${failed} artwork files could not be cached` : null
|
||||||
|
});
|
||||||
|
lastGeneratedAt = snapshot.generatedAt;
|
||||||
|
} catch (error) {
|
||||||
|
offlineSyncStatus.set({
|
||||||
|
state: 'error',
|
||||||
|
generatedAt: lastGeneratedAt,
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
if (rerun && !stopped) {
|
||||||
|
rerun = false;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
void synchronize();
|
||||||
|
}, 1_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(SYNC_EVENT, schedule);
|
||||||
|
window.addEventListener('online', schedule);
|
||||||
|
schedule();
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
window.removeEventListener(SYNC_EVENT, schedule);
|
||||||
|
window.removeEventListener('online', schedule);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export function resolveSpriteUrl(
|
||||||
|
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
|
||||||
|
shiny: boolean,
|
||||||
|
useLocalSprites: boolean
|
||||||
|
): string {
|
||||||
|
const form = entry.form?.trim() ?? '';
|
||||||
|
const strippedNumber = String(entry.pokedexNumber).replace(/^0+/, '') || '0';
|
||||||
|
let key = entry.spriteKey?.trim();
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
let formKey = form
|
||||||
|
.replace(/^female[-\s]*/i, '')
|
||||||
|
.replace(/\s*\(.*?\)/g, '')
|
||||||
|
.replace(/\s*\[.*?\]/g, '')
|
||||||
|
.trim();
|
||||||
|
if (!formKey || formKey.toLowerCase() === 'male') {
|
||||||
|
key = strippedNumber;
|
||||||
|
} else {
|
||||||
|
formKey = formKey
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/%/g, '')
|
||||||
|
.replace(/\balolan\b/g, 'alola')
|
||||||
|
.replace(/\bgalarian\b/g, 'galar')
|
||||||
|
.replace(/\bhisuian\b/g, 'hisui')
|
||||||
|
.replace(/\bpaldean\b/g, 'paldea')
|
||||||
|
.replace(/\bform(e)?$/, '')
|
||||||
|
.replace(/\bability$/, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.replace(/2/g, 'two')
|
||||||
|
.replace(/3/g, 'three')
|
||||||
|
.replace(/4/g, 'four');
|
||||||
|
key = formKey ? `${strippedNumber}-${formKey}` : strippedNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = useLocalSprites
|
||||||
|
? '/sprites-small/home'
|
||||||
|
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
||||||
|
if (shiny) root += '/shiny';
|
||||||
|
if (/^female\b/i.test(form)) root += '/female';
|
||||||
|
return `${root}/${key}.webp`;
|
||||||
|
}
|
||||||
+5
-35
@@ -2,26 +2,18 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
/// <reference no-default-lib="true"/>
|
/// <reference no-default-lib="true"/>
|
||||||
/// <reference lib="esnext" />
|
/// <reference lib="esnext" />
|
||||||
import {
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
|
||||||
cleanupOutdatedCaches,
|
|
||||||
// createHandlerBoundToURL,
|
|
||||||
precacheAndRoute,
|
|
||||||
precache
|
|
||||||
} from 'workbox-precaching';
|
|
||||||
import { registerRoute } from 'workbox-routing';
|
|
||||||
import { CacheFirst } from 'workbox-strategies';
|
|
||||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
|
||||||
import { ExpirationPlugin } from 'workbox-expiration';
|
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope;
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// Kept as a static script so the same snapshot, artwork and navigation behavior can be imported
|
||||||
|
// by Workbox's generateSW output too.
|
||||||
|
self.importScripts('/offline-worker.js');
|
||||||
|
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add root route to precache manifest
|
|
||||||
precache([{ url: '/', revision: null }]);
|
|
||||||
|
|
||||||
// self.__WB_MANIFEST is default injection point
|
// self.__WB_MANIFEST is default injection point
|
||||||
// Handle the case where __WB_MANIFEST might be undefined in development
|
// Handle the case where __WB_MANIFEST might be undefined in development
|
||||||
const manifest = self.__WB_MANIFEST || [];
|
const manifest = self.__WB_MANIFEST || [];
|
||||||
@@ -29,26 +21,4 @@ if (Array.isArray(manifest)) {
|
|||||||
precacheAndRoute(manifest);
|
precacheAndRoute(manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean old assets
|
|
||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
registerRoute(
|
|
||||||
({ request }) => request.destination === 'image',
|
|
||||||
new CacheFirst({
|
|
||||||
cacheName: 'image-cache',
|
|
||||||
plugins: [
|
|
||||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
|
||||||
new ExpirationPlugin({
|
|
||||||
maxEntries: 3000,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
|
||||||
purgeOnQuotaError: true
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// let allowlist: undefined | RegExp[];
|
|
||||||
// if (import.meta.env.DEV) allowlist = [/^\/$/];
|
|
||||||
|
|
||||||
// // to allow work offline
|
|
||||||
// registerRoute(new NavigationRoute(createHandlerBoundToURL('/'), { allowlist }));
|
|
||||||
|
|||||||
+92
-22
@@ -6,6 +6,13 @@
|
|||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import SignOut from '$lib/components/SignOut.svelte';
|
import SignOut from '$lib/components/SignOut.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
import {
|
||||||
|
claimOfflineData,
|
||||||
|
clearOfflineData,
|
||||||
|
offlineSyncStatus,
|
||||||
|
requestOfflineSync,
|
||||||
|
startOfflineSync
|
||||||
|
} from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
import { pwaInfo } from 'virtual:pwa-info';
|
import { pwaInfo } from 'virtual:pwa-info';
|
||||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||||
@@ -24,9 +31,35 @@
|
|||||||
onDestroy(unsubscribe);
|
onDestroy(unsubscribe);
|
||||||
|
|
||||||
let authSubscription: { unsubscribe: () => void } | null = null;
|
let authSubscription: { unsubscribe: () => void } | null = null;
|
||||||
|
let stopOfflineSync: (() => void) | null = null;
|
||||||
|
let isOnline = true;
|
||||||
|
let signOutError = '';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void getUser();
|
isOnline = navigator.onLine;
|
||||||
|
const updateOnlineState = () => {
|
||||||
|
isOnline = navigator.onLine;
|
||||||
|
document.documentElement.classList.toggle('offline-readonly', !isOnline);
|
||||||
|
};
|
||||||
|
const blockOfflineMutation = (event: Event) => {
|
||||||
|
if (navigator.onLine) return;
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (!target?.closest('button, input, textarea, select, form')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
window.addEventListener('online', updateOnlineState);
|
||||||
|
window.addEventListener('offline', updateOnlineState);
|
||||||
|
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
|
||||||
|
window.addEventListener(name, blockOfflineMutation, true);
|
||||||
|
}
|
||||||
|
updateOnlineState();
|
||||||
|
void getUser()
|
||||||
|
.then(async () => {
|
||||||
|
if (localUser) await claimOfflineData(localUser.id);
|
||||||
|
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
||||||
|
})
|
||||||
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
|
|
||||||
// Listen for auth state changes to keep the user store in sync
|
// Listen for auth state changes to keep the user store in sync
|
||||||
const {
|
const {
|
||||||
@@ -34,35 +67,26 @@
|
|||||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
if (session) {
|
if (session) {
|
||||||
localUser = session.user;
|
localUser = session.user;
|
||||||
|
void claimOfflineData(session.user.id)
|
||||||
|
.then(requestOfflineSync)
|
||||||
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
} else {
|
} else {
|
||||||
localUser = null;
|
localUser = null;
|
||||||
|
if (event === 'SIGNED_OUT') void clearOfflineData();
|
||||||
}
|
}
|
||||||
user.set(localUser);
|
user.set(localUser);
|
||||||
});
|
});
|
||||||
authSubscription = subscription;
|
authSubscription = subscription;
|
||||||
|
|
||||||
if (pwaInfo) {
|
|
||||||
void (async () => {
|
|
||||||
const { registerSW } = await import('virtual:pwa-register');
|
|
||||||
registerSW({
|
|
||||||
immediate: true,
|
|
||||||
onRegistered(r) {
|
|
||||||
// uncomment following code if you want check for updates
|
|
||||||
// r && setInterval(() => {
|
|
||||||
// console.log('Checking for sw update')
|
|
||||||
// r.update()
|
|
||||||
// }, 20000 /* 20s for testing purposes */)
|
|
||||||
console.log(`SW Registered: ${r}`);
|
|
||||||
},
|
|
||||||
onRegisterError(error) {
|
|
||||||
console.log('SW registration error', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
authSubscription?.unsubscribe();
|
authSubscription?.unsubscribe();
|
||||||
|
stopOfflineSync?.();
|
||||||
|
window.removeEventListener('online', updateOnlineState);
|
||||||
|
window.removeEventListener('offline', updateOnlineState);
|
||||||
|
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
|
||||||
|
window.removeEventListener(name, blockOfflineMutation, true);
|
||||||
|
}
|
||||||
|
document.documentElement.classList.remove('offline-readonly');
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,7 +186,16 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/backup-settings"> Backup Settings </a>
|
<a href="/backup-settings"> Backup Settings </a>
|
||||||
</li>
|
</li>
|
||||||
<li><SignOut {supabase} on:signedOut={getUser} /></li>
|
<li>
|
||||||
|
<SignOut
|
||||||
|
{supabase}
|
||||||
|
on:signedOut={() => {
|
||||||
|
signOutError = '';
|
||||||
|
void getUser();
|
||||||
|
}}
|
||||||
|
on:signOutFailed={(event) => (signOutError = event.detail.message)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -174,6 +207,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{#if signOutError}
|
||||||
|
<div class="alert alert-error rounded-none" role="alert">
|
||||||
|
<span>{signOutError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !isOnline}
|
||||||
|
<div class="alert rounded-none" role="status">
|
||||||
|
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
||||||
|
</div>
|
||||||
|
{:else if localUser && $offlineSyncStatus.state === 'error'}
|
||||||
|
<div class="alert alert-warning rounded-none" role="status">
|
||||||
|
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||||
|
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if localUser && $offlineSyncStatus.state === 'partial'}
|
||||||
|
<div class="alert alert-warning rounded-none" role="status">
|
||||||
|
<span>Offline data is saved, but {$offlineSyncStatus.message}.</span>
|
||||||
|
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
||||||
|
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">Updating offline copy…</p>
|
||||||
|
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
||||||
|
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
||||||
|
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<main class="flex-grow">
|
<main class="flex-grow">
|
||||||
<slot />
|
<slot />
|
||||||
@@ -223,3 +283,13 @@
|
|||||||
<ReloadPrompt />
|
<ReloadPrompt />
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.offline-readonly button),
|
||||||
|
:global(.offline-readonly input),
|
||||||
|
:global(.offline-readonly textarea),
|
||||||
|
:global(.offline-readonly select) {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+16
-2
@@ -1,9 +1,15 @@
|
|||||||
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
|
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr';
|
import { createBrowserClient, isBrowser, parse, serialize } from '@supabase/ssr';
|
||||||
|
import type { CookieSerializeOptions } from 'cookie';
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||||
depends('supabase:auth');
|
depends('supabase:auth');
|
||||||
|
const recoveryIntent =
|
||||||
|
isBrowser() &&
|
||||||
|
window.location.pathname === '/reset-password' &&
|
||||||
|
(new URLSearchParams(window.location.hash.slice(1)).get('type') === 'recovery' ||
|
||||||
|
new URL(window.location.href).searchParams.has('code'));
|
||||||
|
|
||||||
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
global: {
|
global: {
|
||||||
@@ -17,6 +23,14 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
|||||||
|
|
||||||
const cookie = parse(document.cookie);
|
const cookie = parse(document.cookie);
|
||||||
return cookie[key];
|
return cookie[key];
|
||||||
|
},
|
||||||
|
set(key: string, value: string, options: CookieSerializeOptions) {
|
||||||
|
if (isBrowser()) document.cookie = serialize(key, value, { ...options, path: '/' });
|
||||||
|
},
|
||||||
|
remove(key: string, options: CookieSerializeOptions) {
|
||||||
|
if (isBrowser()) {
|
||||||
|
document.cookie = serialize(key, '', { ...options, path: '/', maxAge: 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -30,5 +44,5 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
|||||||
data: { session }
|
data: { session }
|
||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
return { supabase, session };
|
return { supabase, session, recoveryIntent };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
|
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||||
|
import { OFFLINE_SNAPSHOT_VERSION, type OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||||
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
|
|
||||||
|
export const GET = async (event: RequestEvent) => {
|
||||||
|
try {
|
||||||
|
const userId = await requireAuth(event);
|
||||||
|
const pokedexes = await new PokedexRepository(event.locals.supabase, userId).findAll();
|
||||||
|
const snapshots = await Promise.all(
|
||||||
|
pokedexes.map(async (pokedex) => {
|
||||||
|
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
|
||||||
|
const entries = await new CombinedDataRepository(
|
||||||
|
event.locals.supabase,
|
||||||
|
userId,
|
||||||
|
pokedex._id
|
||||||
|
).findAllCombinedData(userId, pokedex.isFormDex, '', pokedex.gameScope ?? '', dexScopes);
|
||||||
|
return { pokedex: { ...pokedex, dexScopes }, entries };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot: OfflineSnapshot = {
|
||||||
|
version: OFFLINE_SNAPSHOT_VERSION,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
userId,
|
||||||
|
pokedexes: snapshots
|
||||||
|
};
|
||||||
|
|
||||||
|
return json(snapshot, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
Vary: 'Cookie'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unable to build offline snapshot:', error);
|
||||||
|
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||||
|
return json({ error: 'Unable to build offline snapshot' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
|
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
|
||||||
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
|
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
|
||||||
|
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { pokedexes } = data;
|
let { pokedexes } = data;
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Create new pokédex
|
// Create new pokédex
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
|
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
|
||||||
closeModal();
|
closeModal();
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
|
|
||||||
// If the user's total pokédex count is now 1, this newly created one is their first.
|
// If the user's total pokédex count is now 1, this newly created one is their first.
|
||||||
if (pokedexes.length === 1) {
|
if (pokedexes.length === 1) {
|
||||||
@@ -149,6 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting pokédex:', error);
|
console.error('Error deleting pokédex:', error);
|
||||||
alert('An error occurred');
|
alert('An error occurred');
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
lastFlushAttemptAt: null,
|
lastFlushAttemptAt: null,
|
||||||
lastSuccessfulFlushAt: null
|
lastSuccessfulFlushAt: null
|
||||||
};
|
};
|
||||||
|
let lastOfflineSyncFlush: number | null = null;
|
||||||
let exportAfterFlush = false;
|
let exportAfterFlush = false;
|
||||||
let exportInFlight = false;
|
let exportInFlight = false;
|
||||||
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -179,6 +181,15 @@
|
|||||||
|
|
||||||
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
||||||
catchWriteStatus = s;
|
catchWriteStatus = s;
|
||||||
|
if (
|
||||||
|
s.lastSuccessfulFlushAt &&
|
||||||
|
s.lastSuccessfulFlushAt !== lastOfflineSyncFlush &&
|
||||||
|
s.pending === 0 &&
|
||||||
|
s.inFlight === 0
|
||||||
|
) {
|
||||||
|
lastOfflineSyncFlush = s.lastSuccessfulFlushAt;
|
||||||
|
requestOfflineSync();
|
||||||
|
}
|
||||||
if (s.pending > 0 || s.inFlight > 0) {
|
if (s.pending > 0 || s.inFlight > 0) {
|
||||||
if (exportTimer) {
|
if (exportTimer) {
|
||||||
clearTimeout(exportTimer);
|
clearTimeout(exportTimer);
|
||||||
|
|||||||
@@ -1,42 +1,74 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { user } from '$lib/stores/user.js';
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { User } from '@supabase/auth-js';
|
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { supabase } = data;
|
let { supabase } = data;
|
||||||
$: ({ supabase } = data);
|
$: ({ supabase } = data);
|
||||||
|
|
||||||
let localUser: User | null = null;
|
|
||||||
const unsubscribe = user.subscribe((value) => {
|
|
||||||
localUser = value;
|
|
||||||
});
|
|
||||||
onDestroy(unsubscribe);
|
|
||||||
|
|
||||||
let showAnimation = false;
|
let showAnimation = false;
|
||||||
let hasCheckedAuth = false;
|
let authReady = false;
|
||||||
|
let authChecking = true;
|
||||||
|
let authError = '';
|
||||||
|
const recoveryMarkerKey = 'livingdex:password-recovery';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Start animation
|
const animationTimer = setTimeout(() => {
|
||||||
setTimeout(() => {
|
|
||||||
showAnimation = true;
|
showAnimation = true;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
let settled = false;
|
||||||
// Check authentication after a short delay to allow Supabase to process the token
|
const settle = (hasSession: boolean) => {
|
||||||
setTimeout(() => {
|
if (settled && !hasSession) return;
|
||||||
hasCheckedAuth = true;
|
settled = hasSession;
|
||||||
if (!localUser) {
|
authReady = hasSession;
|
||||||
goto('/signin');
|
authChecking = false;
|
||||||
|
authError = hasSession
|
||||||
|
? ''
|
||||||
|
: 'This password-reset link is invalid or expired. Request a new link and try again.';
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
data: { subscription }
|
||||||
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
if (event === 'PASSWORD_RECOVERY' && session) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
recoveryMarkerKey,
|
||||||
|
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
|
||||||
|
);
|
||||||
|
settle(true);
|
||||||
}
|
}
|
||||||
}, 500);
|
});
|
||||||
|
void supabase.auth.getSession().then(({ data: { session }, error }) => {
|
||||||
|
if (error) {
|
||||||
|
authChecking = false;
|
||||||
|
authError = error.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let marker: { userId?: string; expiresAt?: number } | null = null;
|
||||||
|
try {
|
||||||
|
marker = JSON.parse(sessionStorage.getItem(recoveryMarkerKey) ?? 'null');
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
|
}
|
||||||
|
const suppliedRecoveryIntent = data.recoveryIntent === true;
|
||||||
|
const isRecoverySession =
|
||||||
|
!!session &&
|
||||||
|
(suppliedRecoveryIntent ||
|
||||||
|
(marker?.userId === session.user.id && Number(marker.expiresAt) > Date.now()));
|
||||||
|
if (session && suppliedRecoveryIntent) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
recoveryMarkerKey,
|
||||||
|
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecoverySession) sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
|
settle(isRecoverySession);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(animationTimer);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reactive: redirect if user becomes null after initial check
|
|
||||||
$: if (hasCheckedAuth && !localUser) {
|
|
||||||
goto('/signin');
|
|
||||||
}
|
|
||||||
|
|
||||||
let password = '';
|
let password = '';
|
||||||
let confirmPassword = '';
|
let confirmPassword = '';
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
@@ -44,6 +76,7 @@
|
|||||||
let successMessage = '';
|
let successMessage = '';
|
||||||
|
|
||||||
async function updatePassword() {
|
async function updatePassword() {
|
||||||
|
if (!authReady) return;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
@@ -74,11 +107,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
successMessage = 'Password updated successfully! Redirecting to sign in...';
|
successMessage = 'Password updated successfully! Redirecting to sign in...';
|
||||||
|
sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
// Redirect to sign in after a short delay
|
setTimeout(async () => {
|
||||||
setTimeout(() => {
|
await supabase.auth.signOut();
|
||||||
goto('/signin');
|
await goto('/signin');
|
||||||
}, 2000);
|
}, 1_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Update password error:', err);
|
console.error('Update password error:', err);
|
||||||
errorMessage = 'An unexpected error occurred. Please try again.';
|
errorMessage = 'An unexpected error occurred. Please try again.';
|
||||||
@@ -132,6 +165,11 @@
|
|||||||
<!-- Reset Password Card -->
|
<!-- Reset Password Card -->
|
||||||
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
|
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
|
||||||
<div class="card-body p-6 md:p-8">
|
<div class="card-body p-6 md:p-8">
|
||||||
|
{#if authChecking}
|
||||||
|
<div class="alert"><span>Validating your password-reset link…</span></div>
|
||||||
|
{:else if authError}
|
||||||
|
<div class="alert alert-error" role="alert"><span>{authError}</span></div>
|
||||||
|
{/if}
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<div class="alert alert-error text-sm">
|
<div class="alert alert-error text-sm">
|
||||||
@@ -185,7 +223,7 @@
|
|||||||
class="input input-bordered w-full pl-10"
|
class="input input-bordered w-full pl-10"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
on:keypress={handleKeyPress}
|
on:keypress={handleKeyPress}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !authReady}
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -215,7 +253,7 @@
|
|||||||
class="input input-bordered w-full pl-10"
|
class="input input-bordered w-full pl-10"
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
on:keypress={handleKeyPress}
|
on:keypress={handleKeyPress}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !authReady}
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -237,7 +275,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary w-full"
|
class="btn btn-primary w-full"
|
||||||
on:click={updatePassword}
|
on:click={updatePassword}
|
||||||
disabled={isLoading || !password || !confirmPassword}
|
disabled={isLoading || !authReady || !password || !confirmPassword}
|
||||||
>
|
>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const META_CACHE = 'livingdex-offline-meta-v1';
|
||||||
|
const META_URL = '/__offline/current';
|
||||||
|
|
||||||
|
function element(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(record) {
|
||||||
|
if (!record) return 'Not caught';
|
||||||
|
const values = [];
|
||||||
|
if (record.caught) values.push('Caught');
|
||||||
|
if (record.haveToEvolve) values.push('Needs evolution');
|
||||||
|
if (record.inHome) values.push('In HOME');
|
||||||
|
return values.join(' · ') || 'Not caught';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSnapshot(snapshot) {
|
||||||
|
const content = document.querySelector('#offline-content');
|
||||||
|
for (const { pokedex, entries } of snapshot.pokedexes) {
|
||||||
|
const section = element('section', 'card bg-base-100 mb-6 shadow');
|
||||||
|
const body = element('div', 'card-body');
|
||||||
|
body.append(element('h2', 'card-title text-2xl', pokedex.name));
|
||||||
|
body.append(element('p', 'text-sm opacity-70', `${entries.length} entries`));
|
||||||
|
const grid = element(
|
||||||
|
'div',
|
||||||
|
'grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6'
|
||||||
|
);
|
||||||
|
for (const { pokedexEntry: entry, catchRecord } of entries) {
|
||||||
|
const card = element('article', 'rounded border border-base-300 p-2');
|
||||||
|
const image = element('img', 'mx-auto h-20 w-20 object-contain');
|
||||||
|
image.alt = `${entry.pokemon}${entry.form ? ` — ${entry.form}` : ''}`;
|
||||||
|
image.src = entry.offlineSpriteUrl ?? '/placeholder-bulb.png';
|
||||||
|
image.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
image.src = '/placeholder-bulb.png';
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
card.append(image);
|
||||||
|
card.append(element('h3', 'font-semibold', `#${entry.pokedexNumber} ${entry.pokemon}`));
|
||||||
|
if (entry.form) card.append(element('p', 'text-xs opacity-70', entry.form));
|
||||||
|
card.append(element('p', 'text-sm', statusText(catchRecord)));
|
||||||
|
if (catchRecord?.personalNotes)
|
||||||
|
card.append(element('p', 'mt-1 whitespace-pre-wrap text-xs', catchRecord.personalNotes));
|
||||||
|
grid.append(card);
|
||||||
|
}
|
||||||
|
body.append(grid);
|
||||||
|
section.append(body);
|
||||||
|
content.append(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const status = document.querySelector('#offline-status');
|
||||||
|
try {
|
||||||
|
const meta = await (await (await caches.open(META_CACHE)).match(META_URL))?.json();
|
||||||
|
if (!meta?.userId) throw new Error('No collection has been synchronized on this device.');
|
||||||
|
if (!meta.dataCache) throw new Error('The saved collection metadata is incomplete.');
|
||||||
|
const dataCache = await caches.open(meta.dataCache);
|
||||||
|
const response = await dataCache.match(
|
||||||
|
`/__offline/snapshot/${encodeURIComponent(meta.userId)}`
|
||||||
|
);
|
||||||
|
if (!response)
|
||||||
|
throw new Error('The saved collection is incomplete. Reconnect and synchronize again.');
|
||||||
|
const snapshot = await response.json();
|
||||||
|
if (snapshot.version !== 1 || snapshot.userId !== meta.userId)
|
||||||
|
throw new Error('The saved collection is incompatible with this app version.');
|
||||||
|
status.textContent = `Saved ${new Date(snapshot.generatedAt).toLocaleString()}.`;
|
||||||
|
renderSnapshot(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'alert alert-warning';
|
||||||
|
status.textContent = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
navigator.serviceWorker?.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type !== 'OFFLINE_DATA_CLEARED') return;
|
||||||
|
const content = document.querySelector('#offline-content');
|
||||||
|
if (content) content.replaceChildren();
|
||||||
|
const status = document.querySelector('#offline-status');
|
||||||
|
if (status) {
|
||||||
|
status.className = 'alert alert-warning';
|
||||||
|
status.textContent =
|
||||||
|
'The saved collection was removed because the account changed or signed out.';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
|
||||||
|
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
|
||||||
|
const OFFLINE_META_URL = '/__offline/current';
|
||||||
|
let offlineEpoch = 0;
|
||||||
|
let offlineOperation = Promise.resolve();
|
||||||
|
|
||||||
|
function queueOfflineOperation(operation) {
|
||||||
|
const result = offlineOperation.then(operation, operation);
|
||||||
|
offlineOperation = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataCacheName(userId, generation) {
|
||||||
|
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artworkCacheName(userId, generation) {
|
||||||
|
return `${OFFLINE_CACHE_PREFIX}art-v1-${userId}-${generation}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentOfflineMeta() {
|
||||||
|
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||||
|
if (!response) return null;
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return typeof data?.userId === 'string' ? data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearOfflineData() {
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyOfflineDataCleared() {
|
||||||
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheArtwork(cache, urls) {
|
||||||
|
let next = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const workers = Array.from({ length: Math.min(6, urls.length) }, async () => {
|
||||||
|
for (;;) {
|
||||||
|
const index = next++;
|
||||||
|
if (index >= urls.length) return;
|
||||||
|
const url = urls[index];
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
mode: url.startsWith(self.location.origin) ? 'same-origin' : 'no-cors'
|
||||||
|
});
|
||||||
|
if (!response.ok && response.type !== 'opaque') throw new Error(`HTTP ${response.status}`);
|
||||||
|
await cache.put(url, response);
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
const reply = (value) => event.ports[0]?.postMessage(value);
|
||||||
|
if (event.data?.type === 'CLEAR_OFFLINE_DATA') {
|
||||||
|
offlineEpoch++;
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
await clearOfflineData();
|
||||||
|
await notifyOfflineDataCleared();
|
||||||
|
})
|
||||||
|
.then(() => reply({ ok: true }))
|
||||||
|
.catch((error) => reply({ ok: false, error: String(error) }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data?.type === 'CLAIM_OFFLINE_USER') {
|
||||||
|
offlineEpoch++;
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
if (typeof event.data.userId !== 'string') throw new Error('Invalid offline cache owner');
|
||||||
|
const meta = await currentOfflineMeta();
|
||||||
|
if (meta?.userId && meta.userId !== event.data.userId) {
|
||||||
|
await clearOfflineData();
|
||||||
|
await notifyOfflineDataCleared();
|
||||||
|
}
|
||||||
|
reply({ ok: true });
|
||||||
|
}).catch((error) => reply({ ok: false, error: String(error) }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
|
||||||
|
const syncEpoch = offlineEpoch;
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
let nextData;
|
||||||
|
let nextArtwork;
|
||||||
|
try {
|
||||||
|
const snapshot = event.data.snapshot;
|
||||||
|
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
|
||||||
|
throw new Error('Unsupported offline snapshot');
|
||||||
|
}
|
||||||
|
const previousMeta = await currentOfflineMeta();
|
||||||
|
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
|
||||||
|
await clearOfflineData();
|
||||||
|
|
||||||
|
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
|
||||||
|
const generation = `${timestamp}-${crypto.randomUUID()}`;
|
||||||
|
nextData = dataCacheName(snapshot.userId, generation);
|
||||||
|
const dataCache = await caches.open(nextData);
|
||||||
|
await dataCache.put(
|
||||||
|
`/__offline/snapshot/${encodeURIComponent(snapshot.userId)}`,
|
||||||
|
new Response(JSON.stringify(snapshot), {
|
||||||
|
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)]);
|
||||||
|
throw new Error('Offline synchronization was superseded by an account change');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaCache = await caches.open(OFFLINE_META_CACHE);
|
||||||
|
await metaCache.put(
|
||||||
|
OFFLINE_META_URL,
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
userId: snapshot.userId,
|
||||||
|
generatedAt: snapshot.generatedAt,
|
||||||
|
dataCache: nextData,
|
||||||
|
artworkCache: nextArtwork
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (syncEpoch !== offlineEpoch) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const currentCaches = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
currentCaches
|
||||||
|
.filter(
|
||||||
|
(name) =>
|
||||||
|
name.startsWith(OFFLINE_CACHE_PREFIX) &&
|
||||||
|
![OFFLINE_META_CACHE, nextData, nextArtwork].includes(name)
|
||||||
|
)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
reply({ ok: true, failedArtwork });
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
[nextData, nextArtwork].filter(Boolean).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) {
|
||||||
|
const fallback = async () =>
|
||||||
|
(await caches.match('/offline', { ignoreSearch: true })) ??
|
||||||
|
(await caches.match('/offline.html', { ignoreSearch: true })) ??
|
||||||
|
Response.error();
|
||||||
|
event.respondWith(
|
||||||
|
self.navigator.onLine
|
||||||
|
? fetch(new Request(event.request, { cache: 'no-store' })).catch(fallback)
|
||||||
|
: fallback()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.request.destination !== 'image') return;
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const meta = await currentOfflineMeta();
|
||||||
|
if (meta?.artworkCache) {
|
||||||
|
const cached = await (
|
||||||
|
await caches.open(meta.artworkCache)
|
||||||
|
).match(event.request, {
|
||||||
|
ignoreSearch: true
|
||||||
|
});
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
return fetch(event.request);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#f00000" />
|
||||||
|
<title>Living Dex Tracker — Offline</title>
|
||||||
|
<link rel="stylesheet" href="/output.css" />
|
||||||
|
<script type="module" src="/offline-viewer.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-base-200 text-base-content">
|
||||||
|
<header class="navbar bg-primary text-primary-content">
|
||||||
|
<div class="mx-auto w-full max-w-screen-2xl px-4 text-xl font-semibold">
|
||||||
|
Living Dex Tracker
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-screen-2xl p-4">
|
||||||
|
<div class="alert mb-4" role="status">
|
||||||
|
<span
|
||||||
|
>You are offline. This is a read-only copy; changes, exports, and account actions are
|
||||||
|
disabled.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p id="offline-status" class="mb-4">Loading the saved collection…</p>
|
||||||
|
<div id="offline-content"></div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+3
-1
@@ -13,7 +13,9 @@ const config = {
|
|||||||
// Netlify by default, or the node adapter when NODE_ADAPTER=true. See adapter.mjs.
|
// Netlify by default, or the node adapter when NODE_ADAPTER=true. See adapter.mjs.
|
||||||
adapter,
|
adapter,
|
||||||
serviceWorker: {
|
serviceWorker: {
|
||||||
register: true
|
// VitePWA owns registration. Registering here as well requests SvelteKit's default
|
||||||
|
// /service-worker.js even though the inject-manifest output is /prompt-sw.js.
|
||||||
|
register: false
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
// you don't need to do this if you're using generateSW strategy in your app
|
// you don't need to do this if you're using generateSW strategy in your app
|
||||||
|
|||||||
@@ -26,22 +26,36 @@ Feature: Account access
|
|||||||
|
|
||||||
Scenario: Sign out
|
Scenario: Sign out
|
||||||
Given I am signed in
|
Given I am signed in
|
||||||
|
And my offline copy is synchronized
|
||||||
When I sign out
|
When I sign out
|
||||||
Then I return to the public home page
|
Then I return to the public home page
|
||||||
|
And my offline copy is removed
|
||||||
|
|
||||||
|
Scenario: Keep the session when sign out fails
|
||||||
|
Given I am signed in
|
||||||
|
And my offline copy is synchronized
|
||||||
|
When the sign-out request fails
|
||||||
|
Then I remain signed in with an error
|
||||||
|
And my offline copy remains
|
||||||
|
|
||||||
Scenario: Request a password reset
|
Scenario: Request a password reset
|
||||||
Given I have a confirmed account
|
Given I have a confirmed account
|
||||||
When I request a password reset
|
When I request a password reset
|
||||||
Then a password reset email is captured locally
|
Then a password reset email is captured locally
|
||||||
|
|
||||||
|
Scenario: Reject a normal session on the recovery page
|
||||||
|
Given I am signed in
|
||||||
|
When I visit the password recovery page directly
|
||||||
|
Then the replacement password form is unavailable
|
||||||
|
|
||||||
@product-review
|
@product-review
|
||||||
Scenario: Reject mismatched replacement passwords
|
Scenario: Reject mismatched replacement passwords
|
||||||
Given I am signed in on the password reset page
|
Given I follow a valid password reset link
|
||||||
When I enter two different replacement passwords
|
When I enter two different replacement passwords
|
||||||
Then I am told that the passwords do not match
|
Then I am told that the passwords do not match
|
||||||
|
|
||||||
Scenario: Complete a password reset
|
Scenario: Complete a password reset
|
||||||
Given I am signed in on the password reset page
|
Given I follow a valid password reset link
|
||||||
When I enter a valid replacement password
|
When I enter a valid replacement password
|
||||||
Then I am told that my password was updated
|
Then I am told that my password was updated
|
||||||
|
And only the replacement password signs me in
|
||||||
|
|||||||
@@ -40,4 +40,3 @@ Feature: Backup and export
|
|||||||
When I update collection progress
|
When I update collection progress
|
||||||
Then the catch remains marked caught
|
Then the catch remains marked caught
|
||||||
And the provider failure is shown in backup settings
|
And the provider failure is shown in backup settings
|
||||||
|
|
||||||
|
|||||||
@@ -29,4 +29,3 @@ Feature: Pokédex composition
|
|||||||
Given I have a Form Dex named "Black Forms" scoped to game "Black" and dex "Unova"
|
Given I have a Form Dex named "Black Forms" scoped to game "Black" and dex "Unova"
|
||||||
When I inspect its entries with forms
|
When I inspect its entries with forms
|
||||||
Then Rotom includes its named default form without duplicate forms
|
Then Rotom includes its named default form without duplicate forms
|
||||||
|
|
||||||
|
|||||||
@@ -54,4 +54,3 @@ Feature: Pokédex lifecycle
|
|||||||
Given another trainer has a Pokédex
|
Given another trainer has a Pokédex
|
||||||
When I request the other trainer's Pokédex
|
When I request the other trainer's Pokédex
|
||||||
Then the Pokédex is not disclosed
|
Then the Pokédex is not disclosed
|
||||||
|
|
||||||
|
|||||||
@@ -35,4 +35,3 @@ Feature: Progress tracking
|
|||||||
When I select the "Compact" box layout
|
When I select the "Compact" box layout
|
||||||
And I reload the Pokédex
|
And I reload the Pokédex
|
||||||
Then the "Compact" box layout remains selected
|
Then the "Compact" box layout remains selected
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,24 @@ Feature: Offline-friendly application
|
|||||||
When I open the built application
|
When I open the built application
|
||||||
Then a service worker controls the page
|
Then a service worker controls the page
|
||||||
And the application shell is precached
|
And the application shell is precached
|
||||||
|
And no legacy service worker is requested
|
||||||
|
|
||||||
Scenario: Reload the home page while offline
|
Scenario: Reload the home page while offline
|
||||||
Given I have opened the built application online
|
Given I have opened the built application online
|
||||||
When I go offline and reload the home page
|
When I go offline and reload the home page
|
||||||
Then the application remains available
|
Then the read-only offline viewer is available
|
||||||
|
|
||||||
Scenario: Navigate to another route while offline
|
Scenario: Reload a nested route while offline
|
||||||
Given I have opened the built application online
|
Given I have opened the built application online
|
||||||
When I go offline and navigate to the sign-in page
|
When I go offline and reload the sign-in page
|
||||||
Then the sign-in form is available offline
|
Then the read-only offline viewer is available
|
||||||
|
|
||||||
|
Scenario: Read a synchronized collection offline
|
||||||
|
Given I am signed in
|
||||||
|
And I have a Living Dex named "Offline Collection"
|
||||||
|
And my offline copy is synchronized
|
||||||
|
When I go offline and reload the current Pokédex
|
||||||
|
Then the offline copy contains "Offline Collection"
|
||||||
|
|
||||||
Scenario: Restore network access
|
Scenario: Restore network access
|
||||||
Given I have opened the built application online
|
Given I have opened the built application online
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type ScenarioState = {
|
|||||||
lastResponseStatus: number | null;
|
lastResponseStatus: number | null;
|
||||||
lastMessage: string | null;
|
lastMessage: string | null;
|
||||||
caughtEntryLabel: string | null;
|
caughtEntryLabel: string | null;
|
||||||
|
legacyServiceWorkerRequested: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Fixtures = { state: ScenarioState; providerMock: void };
|
type Fixtures = { state: ScenarioState; providerMock: void };
|
||||||
@@ -51,7 +52,8 @@ export const test = base.extend<Fixtures>({
|
|||||||
entries: [],
|
entries: [],
|
||||||
lastResponseStatus: null,
|
lastResponseStatus: null,
|
||||||
lastMessage: null,
|
lastMessage: null,
|
||||||
caughtEntryLabel: null
|
caughtEntryLabel: null,
|
||||||
|
legacyServiceWorkerRequested: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,28 @@ async function mailCountFor(email: string, subject: string): Promise<number> {
|
|||||||
return body.total ?? body.messages?.length ?? 0;
|
return body.total ?? body.messages?.length ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recoveryLinkFromMail(email: string): Promise<string> {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt++) {
|
||||||
|
const search = await fetch(
|
||||||
|
`${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${email} subject:Reset`)}`
|
||||||
|
);
|
||||||
|
if (search.ok) {
|
||||||
|
const result = (await search.json()) as { messages?: Array<{ ID?: string; Id?: string }> };
|
||||||
|
const id = result.messages?.[0]?.ID ?? result.messages?.[0]?.Id;
|
||||||
|
if (id) {
|
||||||
|
const response = await fetch(`${MAILPIT_URL}/api/v1/message/${id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const message = JSON.stringify(await response.json());
|
||||||
|
const match = message.match(/https?:\/\/[^"'<>\s]+\/auth\/v1\/verify[^"'<>\s]+/);
|
||||||
|
if (match) return match[0].replaceAll('&', '&').replaceAll('\\u0026', '&');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
throw new Error('No password recovery link arrived in MailPit');
|
||||||
|
}
|
||||||
|
|
||||||
Given('I am a new visitor', async ({ page }) => {
|
Given('I am a new visitor', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
});
|
});
|
||||||
@@ -28,19 +50,16 @@ Given('I am signed in', async ({ page, state }) => {
|
|||||||
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
Given('I follow a valid password reset link', async ({ page, state }) => {
|
||||||
* Deliberately an ordinary signed-in session, not a recovery one: following a real recovery
|
|
||||||
* action link currently bounces to /signin, because the browser client in src/routes/+layout.ts
|
|
||||||
* has no cookie `set`/`remove` method and so cannot persist the session it parses out of the
|
|
||||||
* URL. Until that is fixed, these scenarios cover the form, not the emailed-link flow - hence
|
|
||||||
* the step name. `createRecoveryLink` in ../support/app.ts is ready for when it is.
|
|
||||||
*/
|
|
||||||
Given('I am signed in on the password reset page', async ({ page, state }) => {
|
|
||||||
await createConfirmedUser(state);
|
await createConfirmedUser(state);
|
||||||
await signIn(page, state);
|
await page.goto('/forgot-password');
|
||||||
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
await page.getByLabel('Email').fill(state.email);
|
||||||
await page.goto('/reset-password');
|
await page.getByRole('button', { name: 'Send Reset Link' }).click();
|
||||||
await expect(page.getByLabel('New Password')).toBeVisible();
|
await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
|
||||||
|
const actionLink = await recoveryLinkFromMail(state.email);
|
||||||
|
await page.goto(actionLink);
|
||||||
|
await expect(page).toHaveURL(/\/reset-password/);
|
||||||
|
await expect(page.getByLabel('New Password')).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
When('I register with valid account details', async ({ page, state }) => {
|
When('I register with valid account details', async ({ page, state }) => {
|
||||||
@@ -66,12 +85,28 @@ When('I sign out', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
|
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
When('the sign-out request fails', async ({ page }) => {
|
||||||
|
await page.route('**/auth/v1/logout*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: '{"message":"unavailable"}'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'usericon' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
When('I request a password reset', async ({ page, state }) => {
|
When('I request a password reset', async ({ page, state }) => {
|
||||||
await page.goto('/forgot-password');
|
await page.goto('/forgot-password');
|
||||||
await page.getByLabel('Email').fill(state.email);
|
await page.getByLabel('Email').fill(state.email);
|
||||||
await page.getByRole('button', { name: 'Send Reset Link' }).click();
|
await page.getByRole('button', { name: 'Send Reset Link' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
When('I visit the password recovery page directly', async ({ page }) => {
|
||||||
|
await page.goto('/reset-password');
|
||||||
|
});
|
||||||
|
|
||||||
When('I enter two different replacement passwords', async ({ page, state }) => {
|
When('I enter two different replacement passwords', async ({ page, state }) => {
|
||||||
await page.getByLabel('New Password').fill(state.replacementPassword);
|
await page.getByLabel('New Password').fill(state.replacementPassword);
|
||||||
await page.getByLabel('Confirm Password').fill(`${state.replacementPassword}-different`);
|
await page.getByLabel('Confirm Password').fill(`${state.replacementPassword}-different`);
|
||||||
@@ -105,11 +140,21 @@ Then('I return to the public home page', async ({ page }) => {
|
|||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Then('I remain signed in with an error', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
await expect(page.locator('.alert-error.rounded-none')).toContainText('Sign out failed');
|
||||||
|
});
|
||||||
|
|
||||||
Then('a password reset email is captured locally', async ({ page, state }) => {
|
Then('a password reset email is captured locally', async ({ page, state }) => {
|
||||||
await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
|
await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
|
||||||
await expect.poll(() => mailCountFor(state.email, 'Reset')).toBeGreaterThan(0);
|
await expect.poll(() => mailCountFor(state.email, 'Reset')).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Then('the replacement password form is unavailable', async ({ page }) => {
|
||||||
|
await expect(page.getByLabel('New Password')).toBeDisabled();
|
||||||
|
await expect(page.getByText(/invalid or expired/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
Then('I am told that the passwords do not match', async ({ page }) => {
|
Then('I am told that the passwords do not match', async ({ page }) => {
|
||||||
await expect(page.getByText('Passwords do not match')).toBeVisible();
|
await expect(page.getByText('Passwords do not match')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -117,3 +162,12 @@ Then('I am told that the passwords do not match', async ({ page }) => {
|
|||||||
Then('I am told that my password was updated', async ({ page }) => {
|
Then('I am told that my password was updated', async ({ page }) => {
|
||||||
await expect(page.getByText(/Password updated successfully/)).toBeVisible();
|
await expect(page.getByText(/Password updated successfully/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Then('only the replacement password signs me in', async ({ page, state }) => {
|
||||||
|
await expect(page).toHaveURL(/\/signin$/, { timeout: 10_000 });
|
||||||
|
await signIn(page, state, state.password);
|
||||||
|
await expect(page.locator('.alert-error')).toBeVisible();
|
||||||
|
await page.getByLabel('Password').fill(state.replacementPassword);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,12 +28,22 @@ async function cacheContents(page: Page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
When('I open the built application', async ({ page }) => {
|
function recordLegacyWorkerRequest(page: Page, state: { legacyServiceWorkerRequested: boolean }) {
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (new URL(request.url()).pathname === '/service-worker.js') {
|
||||||
|
state.legacyServiceWorkerRequested = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
When('I open the built application', async ({ page, state }) => {
|
||||||
|
recordLegacyWorkerRequest(page, state);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForServiceWorker(page);
|
await waitForServiceWorker(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
Given('I have opened the built application online', async ({ page }) => {
|
Given('I have opened the built application online', async ({ page, state }) => {
|
||||||
|
recordLegacyWorkerRequest(page, state);
|
||||||
await page.context().setOffline(false);
|
await page.context().setOffline(false);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForServiceWorker(page);
|
await waitForServiceWorker(page);
|
||||||
@@ -45,11 +55,45 @@ When('I go offline and reload the home page', async ({ page }) => {
|
|||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
});
|
});
|
||||||
|
|
||||||
When('I go offline and navigate to the sign-in page', async ({ page }) => {
|
When('I go offline and reload the sign-in page', async ({ page }) => {
|
||||||
|
await page.goto('/signin');
|
||||||
await page.context().setOffline(true);
|
await page.context().setOffline(true);
|
||||||
// Client-side navigation, which only works if the route's chunks were precached.
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
await page.getByRole('link', { name: 'Sign In' }).click();
|
});
|
||||||
await expect(page).toHaveURL(/\/signin$/);
|
|
||||||
|
Given('my offline copy is synchronized', async ({ page, state }) => {
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
const serializedSnapshot = await page.evaluate(async () => {
|
||||||
|
const metaResponse = await (
|
||||||
|
await caches.open('livingdex-offline-meta-v1')
|
||||||
|
).match('/__offline/current');
|
||||||
|
if (!metaResponse) return '';
|
||||||
|
const meta = await metaResponse.json();
|
||||||
|
const snapshotResponse = await (
|
||||||
|
await caches.open(meta.dataCache)
|
||||||
|
).match(`/__offline/snapshot/${encodeURIComponent(meta.userId)}`);
|
||||||
|
return snapshotResponse ? await snapshotResponse.text() : '';
|
||||||
|
});
|
||||||
|
expect(serializedSnapshot).not.toMatch(/access_token|refresh_token/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and reload the current Pokédex', async ({ page, state }) => {
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}/offline`, {
|
||||||
|
waitUntil: 'domcontentloaded'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
When('I go offline and then return online', async ({ page }) => {
|
When('I go offline and then return online', async ({ page }) => {
|
||||||
@@ -61,6 +105,15 @@ When('I go offline and then return online', async ({ page }) => {
|
|||||||
|
|
||||||
Then('a service worker controls the page', async ({ page }) => {
|
Then('a service worker controls the page', async ({ page }) => {
|
||||||
expect(await waitForServiceWorker(page)).toMatch(/\/(?:sw|prompt-sw)\.js$/);
|
expect(await waitForServiceWorker(page)).toMatch(/\/(?:sw|prompt-sw)\.js$/);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
navigator.serviceWorker.getRegistrations().then((items) => items.length)
|
||||||
|
)
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('no legacy service worker is requested', async ({ state }) => {
|
||||||
|
expect(state.legacyServiceWorkerRequested).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +130,11 @@ Then('the application shell is precached', async ({ page }) => {
|
|||||||
const origin = new URL(page.url()).origin;
|
const origin = new URL(page.url()).origin;
|
||||||
const urls = contents[names[0]].map((url) => url.slice(`${origin}/`.length));
|
const urls = contents[names[0]].map((url) => url.slice(`${origin}/`.length));
|
||||||
|
|
||||||
expect(urls, 'app shell is not precached').toContain('');
|
expect(urls, 'personalized SSR root must not be precached').not.toContain('');
|
||||||
|
expect(
|
||||||
|
urls.some((url) => /^offline(?:\.html)?(?:\?__WB_REVISION__=|$)/.test(url)),
|
||||||
|
'offline viewer is not precached'
|
||||||
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__=')),
|
urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__=')),
|
||||||
'revisioned manifest.webmanifest is not precached'
|
'revisioned manifest.webmanifest is not precached'
|
||||||
@@ -100,8 +157,31 @@ Then('the application remains available', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
Then('the sign-in form is available offline', async ({ page }) => {
|
Then('the read-only offline viewer is available', async ({ page }) => {
|
||||||
await expect(page.getByLabel('Email')).toBeVisible();
|
await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
|
||||||
await expect(page.getByLabel('Password')).toBeVisible();
|
});
|
||||||
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
|
|
||||||
|
Then('the offline copy contains {string}', async ({ page }, name: string) => {
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible();
|
||||||
|
await expect(page.getByText(/read-only copy/i)).toBeVisible();
|
||||||
|
await expect(page.locator('button, input, textarea, select')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('my offline copy is removed', async ({ page }) => {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(async () => {
|
||||||
|
const names = await caches.keys();
|
||||||
|
return names.some((name) => name.startsWith('livingdex-offline-'));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('my offline copy remains', async ({ page, state }) => {
|
||||||
|
const owner = await page.evaluate(async () => {
|
||||||
|
const meta = await (await caches.open('livingdex-offline-meta-v1')).match('/__offline/current');
|
||||||
|
return meta ? (await meta.json()).userId : null;
|
||||||
|
});
|
||||||
|
expect(owner).toBe(state.userId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,34 +43,6 @@ export async function deleteAllPokedexes(state: ScenarioState): Promise<void> {
|
|||||||
if (!response.ok) throw new Error(`Unable to clear Pokédexes: ${await response.text()}`);
|
if (!response.ok) throw new Error(`Unable to clear Pokédexes: ${await response.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Asks Supabase for a real recovery action link - the same token-bearing URL the emailed link
|
|
||||||
* carries - so the reset scenarios exercise token verification rather than an ordinary session.
|
|
||||||
* `redirectTo` must be listed in `auth.additional_redirect_urls` in supabase/config.toml.
|
|
||||||
*/
|
|
||||||
export async function createRecoveryLink(
|
|
||||||
state: ScenarioState,
|
|
||||||
redirectTo: string
|
|
||||||
): Promise<string> {
|
|
||||||
const key = requireServiceRoleKey();
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/admin/generate_link`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
apikey: key,
|
|
||||||
Authorization: `Bearer ${key}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ type: 'recovery', email: state.email, redirect_to: redirectTo })
|
|
||||||
});
|
|
||||||
if (!response.ok)
|
|
||||||
throw new Error(
|
|
||||||
`Unable to generate a recovery link: ${response.status} ${await response.text()}`
|
|
||||||
);
|
|
||||||
const body = (await response.json()) as { action_link?: string };
|
|
||||||
if (!body.action_link) throw new Error('Supabase returned no recovery action link');
|
|
||||||
return body.action_link;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signIn(page: Page, state: ScenarioState, password = state.password) {
|
export async function signIn(page: Page, state: ScenarioState, password = state.password) {
|
||||||
await page.goto('/signin');
|
await page.goto('/signin');
|
||||||
await page.getByLabel('Email').fill(state.email);
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
|||||||
@@ -29,13 +29,16 @@ describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
|
|||||||
match && match.length === 1,
|
match && match.length === 1,
|
||||||
'missing manifest.webmanifest in sw precache manifest'
|
'missing manifest.webmanifest in sw precache manifest'
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
// The generateSW manifest is emitted as JSON ("url": "/"), while prompt-sw.ts's own
|
match = swContent.match(/"?url"?:\s*"\/?offline(?:\.html)?"/);
|
||||||
// `precache([{ url: '/' }])` survives minification as an unquoted key (url:"/").
|
|
||||||
match = swContent.match(/"?url"?:\s*"(?:\/|index\.html)"/);
|
|
||||||
expect(
|
expect(
|
||||||
match && match.length === 1,
|
match && match.length === 1,
|
||||||
'missing offline entry point in sw precache manifest'
|
'missing credential-free offline entry point in sw precache manifest'
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
const outputRoot = `./build/${nodeAdapter ? 'client/' : ''}`;
|
||||||
|
expect(existsSync(`${outputRoot}offline.html`)).toBe(true);
|
||||||
|
expect(existsSync(`${outputRoot}offline-worker.js`)).toBe(true);
|
||||||
|
expect(existsSync(`${outputRoot}service-worker.js`)).toBe(false);
|
||||||
|
expect(swContent).not.toMatch(/"?url"?:\s*"\/"/);
|
||||||
if (nodeAdapter) {
|
if (nodeAdapter) {
|
||||||
match = swContent.match(/"url":\s*"server\//);
|
match = swContent.match(/"url":\s*"server\//);
|
||||||
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
|
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||||
const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
|
const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
|
||||||
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
|
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
describe('database integrity and ownership', () => {
|
describe('database integrity and ownership', () => {
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (!anonKey || !serviceKey) {
|
if (!anonKey || !serviceKey) {
|
||||||
throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY');
|
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('enforces catch-record uniqueness and cascades records when a Pokédex is deleted', async () => {
|
it('enforces catch-record uniqueness and cascades records when a Pokédex is deleted', async () => {
|
||||||
const admin = createClient(url, serviceKey!);
|
const admin = createClient(url, serviceKey!);
|
||||||
const email = `integration-cascade-${Date.now()}@example.test`;
|
const email = `integration-cascade-${Date.now()}@example.test`;
|
||||||
@@ -22,6 +29,7 @@ describe('database integrity and ownership', () => {
|
|||||||
});
|
});
|
||||||
expect(userError).toBeNull();
|
expect(userError).toBeNull();
|
||||||
const userId = created.user!.id;
|
const userId = created.user!.id;
|
||||||
|
createdUserIds.push(userId);
|
||||||
const { data: dex, error: dexError } = await admin
|
const { data: dex, error: dexError } = await admin
|
||||||
.from('pokedexes')
|
.from('pokedexes')
|
||||||
.insert({ userId, name: 'Cascade', isLivingDex: true })
|
.insert({ userId, name: 'Cascade', isLivingDex: true })
|
||||||
@@ -64,6 +72,7 @@ describe('database integrity and ownership', () => {
|
|||||||
});
|
});
|
||||||
expect(first.error).toBeNull();
|
expect(first.error).toBeNull();
|
||||||
expect(second.error).toBeNull();
|
expect(second.error).toBeNull();
|
||||||
|
createdUserIds.push(first.data.user!.id, second.data.user!.id);
|
||||||
const { data: dex } = await admin
|
const { data: dex } = await admin
|
||||||
.from('pokedexes')
|
.from('pokedexes')
|
||||||
.insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true })
|
.insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true })
|
||||||
@@ -87,6 +96,7 @@ describe('database integrity and ownership', () => {
|
|||||||
password: 'Integration123!',
|
password: 'Integration123!',
|
||||||
email_confirm: true
|
email_confirm: true
|
||||||
});
|
});
|
||||||
|
createdUserIds.push(created.data.user!.id);
|
||||||
const { data: dex } = await admin
|
const { data: dex } = await admin
|
||||||
.from('pokedexes')
|
.from('pokedexes')
|
||||||
.insert({ userId: created.data.user!.id, name: 'Unique mapping', isLivingDex: true })
|
.insert({ userId: created.data.user!.id, name: 'Unique mapping', isLivingDex: true })
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
|
describe('resolveSpriteUrl', () => {
|
||||||
|
it('uses a supplied sprite key and local shiny path', () => {
|
||||||
|
expect(
|
||||||
|
resolveSpriteUrl({ pokedexNumber: 25, form: '', spriteKey: '25-partner-cap' }, true, true)
|
||||||
|
).toBe('/sprites-small/home/shiny/25-partner-cap.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places female forms in the female folder', () => {
|
||||||
|
expect(
|
||||||
|
resolveSpriteUrl({ pokedexNumber: 592, form: 'female', spriteKey: '592' }, false, true)
|
||||||
|
).toBe('/sprites-small/home/female/592.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the established fallback key for regional forms', () => {
|
||||||
|
expect(resolveSpriteUrl({ pokedexNumber: 83, form: 'Galarian' }, false, false)).toBe(
|
||||||
|
'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home/83-galar.webp'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the Pokédex number for the default male form', () => {
|
||||||
|
expect(resolveSpriteUrl({ pokedexNumber: 25, form: 'Male' }, false, true)).toBe(
|
||||||
|
'/sprites-small/home/25.webp'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes decorated form names and an all-zero number', () => {
|
||||||
|
expect(
|
||||||
|
resolveSpriteUrl({ pokedexNumber: 0, form: 'Form 2 [event] (legacy)' }, false, true)
|
||||||
|
).toBe('/sprites-small/home/0-form-two.webp');
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-22
@@ -45,32 +45,14 @@ export default defineConfig({
|
|||||||
background_color: '#f0f0f0'
|
background_color: '#f0f0f0'
|
||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
||||||
globIgnores: ['**/sprites/**', '**/sprites-small/**']
|
globIgnores: ['**/sprites/**', '**/sprites-small/**']
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
||||||
globIgnores: ['**/sprites/**', '**/sprites-small/**'],
|
globIgnores: ['**/sprites/**', '**/sprites-small/**'],
|
||||||
// No route is prerendered, so globbing finds no HTML document and a generateSW
|
// Shared message/fetch handling keeps generateSW and injectManifest behavior equal.
|
||||||
// build would precache nothing navigable - i.e. no offline support at all.
|
importScripts: ['/offline-worker.js']
|
||||||
// prompt-sw.ts does the equivalent with `precache([{ url: '/' }])`.
|
|
||||||
additionalManifestEntries: [{ url: '/', revision: null }],
|
|
||||||
navigateFallback: '/',
|
|
||||||
runtimeCaching: [
|
|
||||||
{
|
|
||||||
urlPattern: ({ request }) => request.destination === 'image',
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'image-cache',
|
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 3000,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
|
||||||
purgeOnQuotaError: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
+4
-4
@@ -22,10 +22,10 @@ export default defineConfig({
|
|||||||
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
|
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
|
||||||
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
|
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 33,
|
statements: 34.58,
|
||||||
functions: 73,
|
functions: 73.68,
|
||||||
lines: 33,
|
lines: 34.58,
|
||||||
branches: 79
|
branches: 79.79
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user