Compare commits

...

7 Commits

Author SHA1 Message Date
Josh Creek 31fd959350 Merge pull request #98 from jcreek/perf/page-load-and-lighthouse
perf: fix slow page loads and gate performance in CI
2026-09-14 21:41:59 +01:00
Josh Creek 7603116369 ci(lighthouse): stop lhci reading the form factor as a CLI flag
lhci treats every LHCI_* environment variable as a command-line option, so
LHCI_PRESET=mobile reached `lhci assert` as an invalid --preset and failed
both jobs after the audits had run. Use LIGHTHOUSE_FORM_FACTOR instead.

Also upload the reports: upload-artifact v4+ skips dot-directories such as
.lighthouseci unless include-hidden-files is set.
2026-09-14 21:19:20 +01:00
Josh Creek 5d41462d35 test(bdd): find the account menu by its new accessible name
The avatar's alt text changed from "usericon" to "Account menu" as part of
the accessibility fixes, so the sign-out and offline-guide steps now look it
up by that name.
2026-09-14 20:59:38 +01:00
Josh Creek ff29095c47 perf: fix slow page loads and gate performance in CI
- Ship one hashed Tailwind stylesheet instead of two (one render-blocking)
- Compress responses in-app (brotli/gzip, streaming-safe) and precompress
  the node build so local and CI measurements match production
- Validate the Supabase session once per request
- Render the homepage immediately and stream public stats
- Stream a Pokedex's entries with the page instead of fetching after
  hydration, running the rows and count queries in parallel
- Shrink the avatar and offline placeholder images, fix layout shift,
  contrast, link names and missing meta descriptions
- Add Lighthouse CI (mobile + desktop) with score and metric budgets,
  bundle-size budgets in the build tests, and signed-in speed scenarios
2026-09-14 20:53:53 +01:00
Josh Creek 556f120f16 Merge pull request #97 from jcreek/fix/backup-reconnect-and-offline-guide
fix(backup): pause revoked backups and tell users to reconnect
2026-09-14 20:16:58 +01:00
Josh Creek 7786d46078 fix(backup): don't let a stale export pause a reconnected backup
- An export now pauses a revoked integration only if its row is
  unchanged since the export read it, using the trigger-maintained
  updatedAt column as the row version. If the user reconnected in the
  meantime, the stale failure no longer disables the fresh credentials
  or asks the user to reconnect again. updateExportStatus now reports
  whether a row was written.
- The backup status store ignores a response overtaken by a newer
  refresh, or by the status being flagged, cleared or set directly, so
  a slow response can't overwrite newer state.
- Unit tests cover the whole integration repository, the guarded pause
  and stale status responses. Coverage thresholds are raised to the
  new baseline.
2026-09-14 19:03:29 +01:00
Josh Creek fb95b43b30 fix(backup): pause revoked backups and tell users to reconnect
Google answers a revoked or expired refresh token with invalid_grant.
Every save then retried the dead token, and reconnecting never cleared
the old error, so it kept showing on Backup Settings afterwards.

- The Google Drive and Dropbox OAuth callbacks clear lastError when a
  provider is reconnected.
- An invalid_grant, or a missing refresh token, now pauses the
  integration with a readable "reconnect" message instead of retrying
  it on every catch update. Other failures still retry as before.
- A banner on every page and an alert on the Pokédex page point to
  Backup Settings, which shows a "Reconnect needed" badge. The Pokédex
  page re-checks backup status after each export, because saving a
  catch record also exports on the server and may pause a provider
  first.
- Offline sync status and the "Save all artwork" link move from every
  page to a new /offline-guide page, linked from the user menu and the
  home and welcome pages. Only the offline read-only banner stays
  sitewide.
- Unit tests cover every export path and the backup status store. BDD
  covers revocation and reconnecting for both providers, and the
  offline guide. The mock provider can now reject token refreshes, and
  mock control calls fail loudly if a stale mock is reused. Coverage
  thresholds are raised to the new baseline.
2026-09-14 18:46:09 +01:00
49 changed files with 5657 additions and 558 deletions
+31
View File
@@ -80,6 +80,37 @@ jobs:
- run: npm ci
- run: npm run test:build
lighthouse:
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
form-factor: [mobile, desktop]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
# The homepage streams public stats from the database, so audit against a real stack.
- run: npx supabase start
- run: npm run test:lighthouse
# Not LHCI_*: lhci reads any LHCI_ variable as a CLI flag (LHCI_PRESET became `--preset`).
env:
LIGHTHOUSE_FORM_FACTOR: ${{ matrix.form-factor }}
- uses: actions/upload-artifact@v6
if: always()
with:
name: lighthouse-${{ matrix.form-factor }}
path: .lighthouseci/
# upload-artifact v4+ skips dot-directories unless told otherwise.
include-hidden-files: true
if-no-files-found: ignore
- if: always()
run: npx supabase stop
bdd:
runs-on: ubuntu-latest
timeout-minutes: 30
+1
View File
@@ -9,6 +9,7 @@ node_modules
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/static/output.css
.lighthouseci
.netlify
.features-gen
coverage
+11 -1
View File
@@ -40,7 +40,17 @@ The test suite is split by responsibility so a failure points to the correct lay
- `tests/integration` checks the migrated Supabase schema, views, constraints, RLS, and repositories.
- `tests/bdd/features` is the executable Gherkin specification for user-visible behaviour. Step
definitions and browser fixtures live beside it under `tests/bdd`.
- `tests/build` verifies generated service-worker and manifest artifacts after each supported build.
- `tests/build` verifies generated service-worker and manifest artifacts after each supported build,
and fails if the gzipped JS or CSS every page loads grows past its budget.
- `lighthouserc.cjs` audits the public pages with Lighthouse CI (`npm run test:lighthouse`, which
builds and serves the Node output). PRs fail if Performance, Accessibility, Best Practices or SEO
drops below 90, or if LCP, TBT, CLS, script, stylesheet or total transfer size exceeds its budget.
CI runs it with both the mobile and desktop profiles (`LIGHTHOUSE_FORM_FACTOR=desktop`).
- `tests/bdd/features/performance.feature` holds time budgets for signed-in pages Lighthouse can't
reach: opening a Pokédex and switching between it and the Pokédex list.
The budgets sit just above current measurements so regressions fail the PR. If a change genuinely
needs more, raise the budget in the same PR so the cost is reviewed.
Run the offline suites while developing. `test:fast` includes the coverage run, so there is no need
to run both:
+3 -2
View File
@@ -5,9 +5,10 @@ import AdapterNetlify from '@sveltejs/adapter-netlify';
export const nodeAdapter = process.env.NODE_ADAPTER === 'true';
// Netlify is the deployment target; the node adapter exists so the service worker
// build tests can check the `build/client` layout a Node server produces.
// build tests can check the `build/client` layout a Node server produces, and so Lighthouse CI
// can audit a production build. Netlify's CDN compresses responses, so precompress here to match.
export const adapter = nodeAdapter
? AdapterNode()
? AdapterNode({ precompress: true })
: AdapterNetlify({
// if true, will create a Netlify Edge Function rather
// than using standard Node-based functions
+55
View File
@@ -0,0 +1,55 @@
// Lighthouse CI: `npm run test:lighthouse` builds the Node adapter output, serves it and audits the
// public pages. Any category below 90 fails the run (and so the PR).
// Set LIGHTHOUSE_FORM_FACTOR=desktop to audit with the desktop profile; the default is Lighthouse's
// mobile profile (slow 4G + CPU throttling), which is the stricter of the two. The variable must not
// start with LHCI_: lhci treats those as CLI flags, so LHCI_PRESET was passed to `lhci assert` as
// an invalid `--preset`.
const preset = process.env.LIGHTHOUSE_FORM_FACTOR === 'desktop' ? 'desktop' : undefined;
module.exports = {
ci: {
collect: {
startServerCommand: 'npm run preview-node',
startServerReadyPattern: 'Listening on',
url: [
'http://localhost:4173/',
'http://localhost:4173/signin',
'http://localhost:4173/welcome',
'http://localhost:4173/offline-guide',
'http://localhost:4173/forgot-password'
],
// Median of three runs smooths out noise from shared CI runners.
numberOfRuns: 3,
settings: {
...(preset ? { preset } : {}),
chromeFlags: '--no-sandbox --headless=new'
}
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
// The app compresses its own responses (see src/lib/server/compression.ts and the
// precompressed build), so nothing may be served uncompressed.
'uses-text-compression': ['error', { minScore: 1 }],
// Regression budgets, set a little above what every audited page measured in September
// 2026 (mobile profile: LCP 1.4-2.6 s, TBT 0 ms, CLS 0, ~100 KB script, ~14 KB CSS and
// ~175 KB in total over the wire). A PR that makes pages meaningfully slower or heavier
// fails here even while the category scores stay above 90. When a change legitimately
// needs more, raise the number in the same PR so the cost is reviewed.
'largest-contentful-paint': ['error', { maxNumericValue: 3000 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.05 }],
'resource-summary:script:size': ['error', { maxNumericValue: 115 * 1024 }],
'resource-summary:stylesheet:size': ['error', { maxNumericValue: 20 * 1024 }],
'resource-summary:total:size': ['error', { maxNumericValue: 220 * 1024 }]
}
},
upload: {
target: 'filesystem',
outputDir: '.lighthouseci/reports'
}
}
};
+3098 -54
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -21,7 +21,8 @@
"lint": "prettier --check . && eslint .",
"lint-fix": "npm run lint --fix",
"format": "prettier --write .",
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css",
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css --minify",
"test:lighthouse": "npm run build-inject-manifest-node && lhci autorun",
"test:unit": "vitest run tests/unit",
"test:data": "vitest run tests/data",
"test:coverage": "vitest run tests/unit --coverage",
@@ -46,6 +47,7 @@
"dev:supabase": "supabase start && npm run dev"
},
"devDependencies": {
"@lhci/cli": "^0.15.1",
"@playwright/test": "1.55.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-netlify": "^4.1.0",
+13 -2
View File
@@ -3,7 +3,7 @@
import { createServer } from 'node:http';
const port = Number(process.env.MOCK_PROVIDER_PORT ?? 4199);
const state = { requests: [], failUploads: false, refreshes: 0 };
const state = { requests: [], failUploads: false, revokeRefresh: false, refreshes: 0 };
function send(response, status, body, headers = {}) {
response.writeHead(status, { 'Content-Type': 'application/json', ...headers });
@@ -25,6 +25,7 @@ const server = createServer(async (request, response) => {
if (url.pathname === '/__mock/reset') {
state.requests = [];
state.failUploads = false;
state.revokeRefresh = false;
state.refreshes = 0;
return send(response, 200, { ok: true });
}
@@ -32,6 +33,10 @@ const server = createServer(async (request, response) => {
state.failUploads = true;
return send(response, 200, { ok: true });
}
if (url.pathname === '/__mock/revoke-refresh') {
state.revokeRefresh = true;
return send(response, 200, { ok: true });
}
if (url.pathname.endsWith('/authorize')) {
const redirectUri = url.searchParams.get('redirect_uri');
@@ -45,7 +50,13 @@ const server = createServer(async (request, response) => {
}
if (url.pathname.endsWith('/token')) {
if (body.includes('grant_type=refresh_token')) state.refreshes++;
if (body.includes('grant_type=refresh_token')) {
state.refreshes++;
// Mirrors Google and Dropbox answering a revoked or expired refresh token.
if (state.revokeRefresh) {
return send(response, 400, { error: 'invalid_grant', error_description: 'Bad Request' });
}
}
return send(response, 200, {
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-1
View File
@@ -18,7 +18,6 @@
})();
</script>
%sveltekit.head%
<link rel="stylesheet" href="%sveltekit.assets%/output.css" />
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
+10 -2
View File
@@ -1,6 +1,7 @@
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { createServerClient } from '@supabase/ssr';
import type { Handle } from '@sveltejs/kit';
import { compressResponse } from '$lib/server/compression';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
@@ -24,7 +25,11 @@ export const handle: Handle = async ({ event, resolve }) => {
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.safeGetSession = async () => {
// getUser is a network round trip to Supabase Auth. Layout and page loads (and API routes) all
// ask for the session, so validate once per request and share the result.
let sessionPromise: ReturnType<App.Locals['safeGetSession']> | null = null;
event.locals.safeGetSession = () => {
sessionPromise ??= (async () => {
const {
data: { user },
error
@@ -37,11 +42,14 @@ export const handle: Handle = async ({ event, resolve }) => {
data: { session }
} = await event.locals.supabase.auth.getSession();
return { session, user };
})();
return sessionPromise;
};
return resolve(event, {
const response = await resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range';
}
});
return compressResponse(event.request, response);
};
@@ -15,6 +15,8 @@ export interface PokedexExportIntegration {
metadata: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
/** Set by a database trigger on every write, so it doubles as the row's version. */
updatedAt: string | null;
}
export interface PokedexExportIntegrationDB {
@@ -32,4 +34,5 @@ export interface PokedexExportIntegrationDB {
metadata: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
updatedAt: string | null;
}
@@ -30,7 +30,8 @@ class PokedexExportIntegrationRepository {
accessTokenExpiresAt: db.accessTokenExpiresAt,
metadata: db.metadata,
lastExportedAt: db.lastExportedAt,
lastError: db.lastError
lastError: db.lastError,
updatedAt: db.updatedAt ?? null
};
}
@@ -136,28 +137,38 @@ class PokedexExportIntegrationRepository {
}
}
/**
* Returns whether a row was updated. With `ifUpdatedAt`, the write only applies if the row is
* unchanged since it was read, so a stale export can't overwrite credentials a reconnect saved.
*/
async updateExportStatus(
id: string,
patch: {
enabled?: boolean;
lastExportedAt?: string | null;
lastError?: string | null;
metadata?: Record<string, unknown> | null;
folderId?: string | null;
path?: string | null;
}
): Promise<void> {
const query = this.supabase
},
ifUpdatedAt?: string
): Promise<boolean> {
let query = this.supabase
.from('pokedex_export_integrations')
.update(patch)
.eq('id', id)
.eq('userId', this.userId);
const { error } = this.pokedexId
? await query.eq('pokedexId', this.pokedexId)
: await query.is('pokedexId', null);
if (ifUpdatedAt) query = query.eq('updatedAt', ifUpdatedAt);
const scoped = this.pokedexId
? query.eq('pokedexId', this.pokedexId)
: query.is('pokedexId', null);
const { data, error } = await scoped.select('id');
if (error) {
console.error('Failed to update export integration status:', error);
return false;
}
return (data?.length ?? 0) > 0;
}
}
+73
View File
@@ -0,0 +1,73 @@
import { Readable } from 'node:stream';
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
import { constants, createBrotliCompress, createGzip } from 'node:zlib';
export type Encoding = 'br' | 'gzip';
// Text responses the app renders or returns from API routes. Images, fonts and other binary
// content is already compressed, so re-compressing it only costs CPU.
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json)|image\/svg\+xml)/i;
/** Picks the best encoding the client accepts, preferring brotli. Honours `q=0` refusals. */
export function pickEncoding(acceptEncoding: string | null): Encoding | null {
if (!acceptEncoding) return null;
const accepted = new Map<string, number>();
for (const part of acceptEncoding.split(',')) {
const [name, ...params] = part.trim().toLowerCase().split(';');
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
accepted.set(name, q ? Number(q.slice(2)) : 1);
}
const allows = (name: Encoding) => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0;
if (allows('br')) return 'br';
if (allows('gzip')) return 'gzip';
return null;
}
// adapter-netlify's Lambda handler serialises text responses with `response.text()`, which would
// corrupt a compressed body. Netlify compresses function responses itself, so skip it there.
// Everywhere else (the Node build, local preview, CI) the app compresses its own responses.
const serialisesBodiesAsText = () => Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
function shouldCompress(request: Request, response: Response): boolean {
if (serialisesBodiesAsText()) return false;
if (!response.body || request.method === 'HEAD') return false;
if (response.status < 200 || response.status === 204 || response.status === 304) return false;
if (response.headers.has('content-encoding')) return false;
return COMPRESSIBLE.test(response.headers.get('content-type') ?? '');
}
/**
* Compresses a rendered page or API response so its transfer size doesn't depend on the host.
* Every chunk is flushed as soon as it is written, so SvelteKit's streamed load data still reaches
* the browser progressively instead of waiting for the whole body.
*/
export function compressResponse(request: Request, response: Response): Response {
if (!shouldCompress(request, response)) return response;
const encoding = pickEncoding(request.headers.get('accept-encoding'));
const headers = new Headers(response.headers);
// Caches must key on the request encoding even when this response isn't compressed.
headers.append('vary', 'Accept-Encoding');
if (!encoding) return new Response(response.body, { status: response.status, headers });
const compressor =
encoding === 'br'
? createBrotliCompress({
flush: constants.BROTLI_OPERATION_FLUSH,
// Quality 11 is for build-time precompression; 5 is fast enough per request.
params: { [constants.BROTLI_PARAM_QUALITY]: 5 }
})
: createGzip({ flush: constants.Z_SYNC_FLUSH, level: 6 });
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream);
source.on('error', (error) => compressor.destroy(error));
source.pipe(compressor);
headers.set('content-encoding', encoding);
headers.delete('content-length');
return new Response(Readable.toWeb(compressor) as unknown as ReadableStream, {
status: response.status,
statusText: response.statusText,
headers
});
}
+42
View File
@@ -0,0 +1,42 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import type { Pokedex } from '$lib/models/Pokedex';
export type CombinedDataQuery = {
page: number;
limit: number;
enableForms: boolean;
region?: string;
game?: string;
};
/**
* Loads one page of a Pokédex's entries joined with the owner's catch records. Shared by the
* combined-data API and the Pokédex page's server load so both return exactly the same data.
* The caller must already have checked that `userId` owns `pokedex`.
*/
export async function loadCombinedDataPage(
supabase: SupabaseClient,
userId: string,
pokedex: Pokedex,
{ page, limit, enableForms, region = '', game = '' }: CombinedDataQuery
) {
// Use the pokédex's gameScope as the default filter if no manual game filter is set.
const effectiveGame = game || pokedex.gameScope || '';
const dexScopes = await resolveDexScopes(supabase, pokedex);
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
// The rows and the count are independent queries, so run them together.
const [combinedData, totalCount] = await Promise.all([
repo.findCombinedData(userId, page, limit, enableForms, region, effectiveGame, dexScopes),
repo.countCombinedData(enableForms, region, effectiveGame, dexScopes)
]);
return {
combinedData,
totalPages: Math.ceil(totalCount / limit),
currentPage: page,
totalCount
};
}
@@ -54,6 +54,17 @@ export function buildCsv(combinedData: CombinedData[]): string {
return lines.join('\r\n');
}
/** OAuth providers answer a revoked or expired refresh token with `invalid_grant`. */
export function isRevokedGrant(status: number, body: string): boolean {
if (status !== 400 && status !== 401) return false;
try {
const parsed = JSON.parse(body) as { error?: unknown } | null;
return parsed?.error === 'invalid_grant';
} catch {
return false;
}
}
export function shouldRefreshToken(expiresAt: string | null): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
+34 -6
View File
@@ -13,6 +13,7 @@ import { getEnv } from '$lib/utils/env';
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
import {
buildCsv,
isRevokedGrant,
sanitizeFileName,
shouldRefreshToken
} from '$lib/services/PokedexExportFormatting';
@@ -21,6 +22,7 @@ type ExportFailure = {
integrationId: string;
provider: ExportProvider;
error: string;
reconnectRequired: boolean;
};
export type PokedexExportResult = {
@@ -29,12 +31,21 @@ export type PokedexExportResult = {
failed: ExportFailure[];
};
const RECONNECT_MESSAGES: Record<ExportProvider, string> = {
google_drive:
'Google Drive access has expired or was revoked. Reconnect Google Drive to resume backups.',
dropbox: 'Dropbox access has expired or was revoked. Reconnect Dropbox to resume backups.'
};
/** The provider rejected the stored grant, so only a fresh OAuth connection can resume exports. */
class ReconnectRequiredError extends Error {}
async function refreshGoogleToken(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!integration.refreshToken) {
throw new Error('Missing Google refresh token');
throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive);
}
const env = getEnv();
@@ -59,6 +70,9 @@ async function refreshGoogleToken(
if (!response.ok) {
const text = await response.text();
if (isRevokedGrant(response.status, text)) {
throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive);
}
throw new Error(`Google token refresh failed: ${response.status} ${text}`);
}
@@ -88,7 +102,7 @@ async function refreshDropboxToken(
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!integration.refreshToken) {
throw new Error('Missing Dropbox refresh token');
throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox);
}
const env = getEnv();
@@ -113,6 +127,9 @@ async function refreshDropboxToken(
if (!response.ok) {
const text = await response.text();
if (isRevokedGrant(response.status, text)) {
throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox);
}
throw new Error(`Dropbox token refresh failed: ${response.status} ${text}`);
}
@@ -418,13 +435,24 @@ export async function exportPokedexIfConfigured(
successes++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
let reconnectRequired = false;
if (error instanceof ReconnectRequiredError) {
// A revoked grant never succeeds on retry, so pause this integration until the user
// reconnects - but only if its row is unchanged since this export read it. A reconnect
// in the meantime saved new credentials, which this stale failure must not disable.
reconnectRequired = await scopedRepo.updateExportStatus(
integration._id,
{ lastError: message, enabled: false },
integration.updatedAt ?? undefined
);
} else {
await scopedRepo.updateExportStatus(integration._id, { lastError: message });
}
failures.push({
integrationId: integration._id,
provider: integration.provider,
error: message
});
await scopedRepo.updateExportStatus(integration._id, {
lastError: message
error: message,
reconnectRequired
});
console.error('Failed to export pokedex:', integration.provider, message);
}
+50
View File
@@ -0,0 +1,50 @@
import { writable } from 'svelte/store';
import type { ExportProvider } from '$lib/models/PokedexExportIntegration';
export const PROVIDER_LABELS: Record<ExportProvider, string> = {
google_drive: 'Google Drive',
dropbox: 'Dropbox'
};
/**
* Backup providers whose access has lapsed. Exports switch an integration off when the provider
* revokes its grant, so these stay paused until the user reconnects.
*/
export const backupsNeedingReconnect = writable<ExportProvider[]>([]);
type IntegrationSummary = { provider: ExportProvider; enabled: boolean };
// A response is only applied if no newer refresh has started and nothing has changed the status
// since it was requested, so a slow response can't overwrite newer or cleared state.
let refreshSequence = 0;
let mutationGeneration = 0;
export function setBackupStatus(integrations: IntegrationSummary[]): void {
mutationGeneration++;
backupsNeedingReconnect.set(integrations.filter((i) => !i.enabled).map((i) => i.provider));
}
export async function refreshBackupStatus(): Promise<void> {
if (typeof window === 'undefined' || !navigator.onLine) return;
const sequence = ++refreshSequence;
const generation = mutationGeneration;
try {
const response = await fetch('/api/export-integrations', { credentials: 'include' });
if (!response.ok) return;
const integrations = (await response.json()) as IntegrationSummary[];
if (sequence !== refreshSequence || generation !== mutationGeneration) return;
setBackupStatus(integrations);
} catch (error) {
console.error('Unable to check backup status', error);
}
}
export function markReconnectNeeded(providers: ExportProvider[]): void {
mutationGeneration++;
backupsNeedingReconnect.update((current) => [...new Set([...current, ...providers])]);
}
export function clearBackupStatus(): void {
mutationGeneration++;
backupsNeedingReconnect.set([]);
}
+44 -42
View File
@@ -1,19 +1,21 @@
<script lang="ts">
import 'tailwindcss/tailwind.css';
// The only app stylesheet: Vite bundles, minifies and content-hashes it so it is cached for good.
// static/output.css is built separately for the credential-free offline.html page only.
import '../app.css';
import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js';
import SignIn from '$lib/components/SignIn.svelte';
import SignOut from '$lib/components/SignOut.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import { page } from '$app/stores';
import { claimOfflineData, requestOfflineSync, startOfflineSync } from '$lib/stores/offlineSync';
import {
artworkDownloadStatus,
claimOfflineData,
downloadAllArtwork,
offlineSyncStatus,
requestOfflineSync,
startOfflineSync
} from '$lib/stores/offlineSync';
PROVIDER_LABELS,
backupsNeedingReconnect,
clearBackupStatus,
refreshBackupStatus
} from '$lib/stores/backupStatus';
import { pwaInfo } from 'virtual:pwa-info';
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
@@ -57,7 +59,10 @@
updateOnlineState();
void getUser()
.then(async () => {
if (localUser) await claimOfflineData(localUser.id);
if (localUser) {
void refreshBackupStatus();
await claimOfflineData(localUser.id);
}
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
})
.catch((error) => console.error('Unable to claim offline data', error));
@@ -75,12 +80,14 @@
void claimOfflineData(session.user.id)
.then(requestOfflineSync)
.catch((error) => console.error('Unable to claim offline data', error));
void refreshBackupStatus();
}
} else {
// Offline data is only cleared by the Sign Out button (or another account claiming it).
// An expired or rejected session must not throw away artwork that would then have to be
// downloaded again after signing back in.
localUser = null;
clearBackupStatus();
}
user.set(localUser);
});
@@ -98,10 +105,7 @@
};
});
function formatMegabytes(bytes: number) {
const megabytes = bytes / 1048576;
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
}
$: reconnectLabels = $backupsNeedingReconnect.map((provider) => PROVIDER_LABELS[provider]);
async function getUser() {
const {
@@ -178,7 +182,7 @@
{#if localUser}
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<img alt="usericon" src="/OIG5.jpg" />
<img alt="Account menu" src="/avatar.webp" width="40" height="40" />
</div>
</div>
<ul
@@ -192,6 +196,9 @@
<li>
<a href="/backup-settings"> Backup Settings </a>
</li>
<li>
<a href="/offline-guide"> Using Offline </a>
</li>
<li>
<SignOut
{supabase}
@@ -222,35 +229,19 @@
<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 && $artworkDownloadStatus.state === 'error'}
<div class="alert alert-warning rounded-none" role="status">
<span
>Offline data is saved, but artwork could not be saved: {$artworkDownloadStatus.message}.</span
{/if}
{#if localUser && reconnectLabels.length > 0 && $page.url.pathname !== '/backup-settings'}
<div
class="alert alert-warning rounded-none"
role="alert"
data-testid="backup-reconnect-banner"
>
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
<span>
Your {reconnectLabels.join(' and ')} backup has stopped because access expired or was revoked.
</span>
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
</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()}.
{#if $artworkDownloadStatus.state === 'downloading'}
Saving all artwork for offline…
{:else if $artworkDownloadStatus.state === 'missing'}
<button class="link" on:click={downloadAllArtwork}>
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
$artworkDownloadStatus.missingBytes
)}){/if}
</button>
{/if}
</p>
{/if}
<main class="flex-grow">
<slot />
@@ -258,8 +249,14 @@
<footer class="footer items-center p-4 bg-neutral text-neutral-content bottom-0">
<aside class="items-center grid-flow-col">
<a href="https://github.com/jcreek/LivingDexTracker" target="_blank">
<a
href="https://github.com/jcreek/LivingDexTracker"
target="_blank"
rel="noopener noreferrer"
aria-label="Living Dex Tracker on GitHub"
>
<svg
aria-hidden="true"
width="36"
height="36"
fill-rule="evenodd"
@@ -280,8 +277,13 @@
</p>
</aside>
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
<a href="https://discord.gg/SQcJkaXDye" target="_blank"
<a
href="https://discord.gg/SQcJkaXDye"
target="_blank"
rel="noopener noreferrer"
aria-label="Living Dex Tracker Discord community"
><svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
+20 -9
View File
@@ -1,17 +1,28 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export type PublicStats = {
pokemonCaught: number;
users: number;
livingDexesCompleted: number;
};
/**
* Server-side load function for the homepage
*
* Fetches public statistics from the database and passes them to the page component.
* Stats are cached for 24 hours to improve performance.
* Signed-in users go straight to their Pokédexes. For everyone else the page renders at once and
* the public statistics stream in afterwards, so a slow stats query never delays the first paint.
*/
export const load: PageServerLoad = async ({ fetch }) => {
// Fetch stats from database
const statsResponse = await fetch('/api/stats');
const statsData = await statsResponse.json();
export const load: PageServerLoad = async ({ fetch, locals }) => {
const { user } = await locals.safeGetSession();
if (user) {
throw redirect(303, '/my-pokedexes');
}
return {
stats: statsData.error ? null : statsData
};
const stats: Promise<PublicStats | null> = fetch('/api/stats')
.then((response) => response.json())
.then((statsData) => (statsData.error ? null : statsData))
.catch(() => null);
return { stats };
};
+33 -69
View File
@@ -1,35 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import SignUp from '$lib/components/SignUp.svelte';
import { goto } from '$app/navigation';
// Signed-in visitors are redirected by the server load, so the page renders straight away.
export let data;
let { supabase, stats } = data;
$: ({ supabase, stats } = data);
// Redirection is decided from the live session below, so the user store is not needed here.
let isCheckingSession = true;
onMount(() => {
checkSessionAndRedirect();
});
async function checkSessionAndRedirect() {
try {
const {
data: { session }
} = await supabase.auth.getSession();
if (session) {
await goto('/my-pokedexes');
}
} catch (error) {
console.error('Error checking session:', error);
} finally {
isCheckingSession = false;
}
}
async function handleSignedUp() {
await goto('/welcome');
}
@@ -44,16 +21,6 @@
}
return num.toString();
}
// Get formatted stats or fallback to 0
let pokemonCaught = '0';
let users = '0';
let livingDexesCompleted = '0';
$: {
pokemonCaught = formatNumber(stats?.pokemonCaught ?? 0);
users = formatNumber(stats?.users ?? 0);
livingDexesCompleted = formatNumber(stats?.livingDexesCompleted ?? 0);
}
</script>
<svelte:head>
@@ -72,16 +39,8 @@
/>
</svelte:head>
{#if isCheckingSession}
<!-- Loading placeholder while checking session -->
<div class="hero bg-base-100 my-36">
<div class="hero-content flex-col">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
{:else}
<!-- Hero Section -->
<div class="hero bg-base-100 my-36">
<!-- Hero Section -->
<div class="hero bg-base-100 my-36">
<div class="hero-content flex-col lg:flex-row-reverse">
<div class="text-center lg:text-left">
<div class="flex flex-wrap gap-2 mb-4">
@@ -115,7 +74,7 @@
</svg>
Open Source
</div>
<div class="badge badge-accent badge-lg gap-1">
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
@@ -130,7 +89,7 @@
/>
</svg>
Offline-friendly
</div>
</a>
</div>
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
<p class="text-xl mb-6 text-base-content/80">
@@ -185,30 +144,36 @@
</div>
</div>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="bg-base-200 py-16">
<!-- Stats Section -->
<div class="bg-base-200 py-16">
<div class="container mx-auto px-4">
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
<div class="stat">
<div class="stat-title">Pokémon caught</div>
<div class="stat-value text-primary">{pokemonCaught}</div>
<div class="stat-value text-primary">
{#await stats}{:then value}{formatNumber(value?.pokemonCaught ?? 0)}{/await}
</div>
</div>
<div class="stat">
<div class="stat-title">Users</div>
<div class="stat-value text-primary">{users}</div>
<div class="stat-value text-primary">
{#await stats}{:then value}{formatNumber(value?.users ?? 0)}{/await}
</div>
</div>
<div class="stat">
<div class="stat-title">Living Dexes Completed</div>
<div class="stat-value text-primary">{livingDexesCompleted}</div>
<div class="stat-value text-primary">
{#await stats}{:then value}{formatNumber(value?.livingDexesCompleted ?? 0)}{/await}
</div>
</div>
</div>
</div>
</div>
<!-- What is a Living Dex Section -->
<div class="py-16 bg-base-100">
<!-- What is a Living Dex Section -->
<div class="py-16 bg-base-100">
<div class="container mx-auto px-4 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@@ -221,10 +186,10 @@
</div>
</div>
</div>
</div>
</div>
<!-- Features Section -->
<div class="py-16 bg-base-200">
<!-- Features Section -->
<div class="py-16 bg-base-200">
<div class="container mx-auto px-4 max-w-6xl">
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
@@ -283,8 +248,8 @@
<div>
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
<p class="text-base-content/80">
Completely open source and free to use, enabling the community to contribute
updates as soon as new Pokémon are released.
Completely open source and free to use, enabling the community to contribute updates
as soon as new Pokémon are released.
</p>
</div>
</div>
@@ -353,10 +318,10 @@
</div>
</div>
</div>
</div>
</div>
<!-- CTA Section -->
<div class="py-16 bg-base-100">
<!-- CTA Section -->
<div class="py-16 bg-base-100">
<div class="container mx-auto px-4 text-center">
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
@@ -372,10 +337,10 @@
</div>
</div>
</div>
</div>
</div>
<!-- Legal Section -->
<div class="py-8 bg-base-200">
<!-- Legal Section -->
<div class="py-8 bg-base-200">
<div class="container mx-auto px-4 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@@ -383,11 +348,10 @@
<p class="text-sm text-base-content/70">
Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
characters, images, or other content featured on this website. This project is not
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak,
or The Pokémon Company.
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak, or
The Pokémon Company.
</p>
</div>
</div>
</div>
</div>
{/if}
</div>
@@ -98,7 +98,9 @@ export const GET = async (event: RequestEvent) => {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
accessTokenExpiresAt: expiresAt,
metadata: tokenData.scope ? { scope: tokenData.scope } : null
metadata: tokenData.scope ? { scope: tokenData.scope } : null,
// A reconnect replaces the tokens, so any error from the old ones no longer applies.
lastError: null
});
} catch (saveError) {
console.error('Dropbox integration save failed:', saveError);
@@ -99,7 +99,9 @@ export const GET = async (event: RequestEvent) => {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
accessTokenExpiresAt: expiresAt,
metadata: tokenData.scope ? { scope: tokenData.scope } : null
metadata: tokenData.scope ? { scope: tokenData.scope } : null,
// A reconnect replaces the tokens, so any error from the old ones no longer applies.
lastError: null
});
} catch (saveError) {
console.error('Google Drive integration save failed:', saveError);
@@ -1,9 +1,8 @@
import { json } from '@sveltejs/kit';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { getOptionalUserId } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
// GET: Get combined data (pokédex entries + catch records) for specific pokédex
export const GET = async (event: RequestEvent) => {
@@ -23,48 +22,29 @@ export const GET = async (event: RequestEvent) => {
const region = url.searchParams.get('region') || '';
const game = url.searchParams.get('game') || '';
// If authenticated, verify user owns this pokédex and get its gameScope
let pokedex;
if (userId) {
if (!userId) {
// Anonymous users cannot view pokédexes
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify the user owns this pokédex and get its gameScope
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
pokedex = await pokedexRepo.findById(pokedexId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
return json({ error: 'Pokedex not found' }, { status: 404 });
}
} else {
// Anonymous users cannot view pokédexes
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Use pokédex's gameScope as default filter if no manual game filter is set
const effectiveGame = game || pokedex.gameScope || '';
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
const repo = new CombinedDataRepository(event.locals.supabase, userId, pokedexId);
// Get paginated combined data
const combinedData = await repo.findCombinedData(
userId!,
return json(
await loadCombinedDataPage(event.locals.supabase, userId, pokedex, {
page,
limit,
enableForms,
region,
effectiveGame,
dexScopes
game
})
);
// Get total count for pagination
const totalCount = await repo.countCombinedData(enableForms, region, effectiveGame, dexScopes);
const totalPages = Math.ceil(totalCount / limit);
return json({
combinedData,
totalPages,
currentPage: page,
totalCount
});
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
+4
View File
@@ -27,6 +27,10 @@ export const GET = async (event: RequestEvent) => {
}
const stats = data[0];
// The figures refresh at most daily, so let browsers and the CDN reuse them.
event.setHeaders({
'cache-control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400'
});
return json({
pokemonCaught: stats.pokemon_caught,
users: stats.total_users,
+23 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { setBackupStatus } from '$lib/stores/backupStatus';
type ExportIntegrationSummary = {
id: string;
@@ -40,6 +41,8 @@
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
// Keeps the sitewide banner in step, e.g. clearing it after the OAuth flow returns here.
setBackupStatus(exportIntegrations);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
exportError = message || 'Failed to load export settings';
@@ -48,6 +51,16 @@
}
}
function statusBadge(integration: ExportIntegrationSummary | undefined) {
if (!integration) return { label: 'Not Connected', className: 'badge-ghost' };
// Exports switch an integration off when the provider revokes its access.
if (!integration.enabled) return { label: 'Reconnect needed', className: 'badge-warning' };
return { label: 'Connected', className: 'badge-success' };
}
$: googleBadge = statusBadge(googleIntegration);
$: dropboxBadge = statusBadge(dropboxIntegration);
function getGoogleFolderUrl(folderId: string): string {
return `https://drive.google.com/drive/folders/${folderId}`;
}
@@ -106,13 +119,14 @@
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Google Drive</h2>
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
{googleIntegration ? 'Connected' : 'Not Connected'}
</span>
<span class={`badge ${googleBadge.className}`}>{googleBadge.label}</span>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" on:click={connectGoogleDrive}>
<button
class={`btn btn-sm ${googleIntegration?.enabled === false ? 'btn-primary' : 'btn-outline'}`}
on:click={connectGoogleDrive}
>
{googleIntegration ? 'Reconnect' : 'Connect'}
</button>
</div>
@@ -142,13 +156,14 @@
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Dropbox</h2>
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
{dropboxIntegration ? 'Connected' : 'Not Connected'}
</span>
<span class={`badge ${dropboxBadge.className}`}>{dropboxBadge.label}</span>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" on:click={connectDropbox}>
<button
class={`btn btn-sm ${dropboxIntegration?.enabled === false ? 'btn-primary' : 'btn-outline'}`}
on:click={connectDropbox}
>
{dropboxIntegration ? 'Reconnect' : 'Connect'}
</button>
</div>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { user } from '$lib/stores/user.js';
import {
artworkDownloadStatus,
downloadAllArtwork,
offlineSyncStatus,
requestOfflineSync
} from '$lib/stores/offlineSync';
function formatMegabytes(bytes: number) {
const megabytes = bytes / 1048576;
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
}
</script>
<svelte:head>
<title>Using Offline - Living Dex Tracker</title>
<meta
name="description"
content="How Living Dex Tracker keeps your Pokédexes and artwork available offline, and how to save everything before you lose signal."
/>
</svelte:head>
<div class="container mx-auto p-4 max-w-screen-lg">
<h1 class="text-3xl font-bold mb-6">Using Living Dex Tracker offline</h1>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Your offline copy</h2>
{#if !$user}
<p>
<a href="/signin" class="link link-primary">Sign in</a> to keep a copy of your pokédexes on
this device.
</p>
{:else}
<div data-testid="offline-copy-status" role="status">
{#if $offlineSyncStatus.state === 'syncing'}
<p>Updating your offline copy…</p>
{:else if $offlineSyncStatus.state === 'error'}
<div class="alert alert-warning">
<span>Your offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
</div>
{:else if $offlineSyncStatus.generatedAt}
<p>
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
</p>
{:else}
<p>
No offline copy is saved on this device yet. It saves automatically while you are
online.
</p>
{/if}
</div>
<h3 class="font-semibold mt-4">Artwork</h3>
{#if $artworkDownloadStatus.state === 'downloading'}
<p role="status">Saving all artwork for offline…</p>
{:else if $artworkDownloadStatus.state === 'error'}
<div class="alert alert-warning" role="status">
<span>Some artwork could not be saved: {$artworkDownloadStatus.message}.</span>
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
</div>
{:else if $artworkDownloadStatus.state === 'done'}
<p>All artwork is saved on this device.</p>
{:else}
<p>
Artwork is saved as you view it. To browse every Pokémon offline, including every form,
shiny and female variant, save it all now.
</p>
{#if $artworkDownloadStatus.state === 'missing'}
<div>
<button class="btn btn-primary btn-sm" on:click={downloadAllArtwork}>
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
$artworkDownloadStatus.missingBytes
)}){/if}
</button>
</div>
{/if}
{/if}
{/if}
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">How it works</h2>
<ul class="list-disc pl-5 space-y-2">
<li>
<strong>Install the app.</strong> Use your browser's "Install app" or "Add to Home Screen"
option so Living Dex Tracker opens without a connection.
</li>
<li>
<strong>Stay signed in.</strong> Your offline copy updates automatically whenever you are online,
including after you make changes.
</li>
<li>
<strong>Offline is read-only.</strong> You can browse your pokédexes, but catches and edits
are disabled until you are back online.
</li>
<li>
<strong>Artwork.</strong> Sprites are saved as you view them. Use "Save all artwork for offline"
above to download the rest in one go.
</li>
<li>
<strong>Signing out</strong> removes the offline copy from this device.
</li>
</ul>
</div>
</div>
</div>
+20 -1
View File
@@ -1,7 +1,11 @@
import { error, redirect } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
import type { PageServerLoad } from './$types';
// Must match the page's itemsPerPage: the box view needs the whole dex in one page.
const INITIAL_PAGE_SIZE = 9999;
export const load: PageServerLoad = async ({ locals, params }) => {
const { safeGetSession, supabase } = locals;
const { session, user } = await safeGetSession();
@@ -22,7 +26,22 @@ export const load: PageServerLoad = async ({ locals, params }) => {
throw error(404, 'Pokédex not found');
}
// Streamed rather than awaited: the page shell renders straight away and the entries arrive in
// the same response, instead of the browser requesting them after hydration. A failure resolves
// to null so the page falls back to fetching (and reporting) through the API.
const initialCombinedData = loadCombinedDataPage(supabase, user.id, pokedex, {
page: 1,
limit: INITIAL_PAGE_SIZE,
enableForms: pokedex.isFormDex
})
.then((result) => result.combinedData)
.catch((err) => {
console.error('Unable to preload combined data', err);
return null;
});
return {
pokedex
pokedex,
initialCombinedData
};
};
+75 -2
View File
@@ -17,6 +17,14 @@
import type { Pokedex } from '$lib/models/Pokedex';
import type { PageData } from './$types';
import { requestOfflineSync } from '$lib/stores/offlineSync';
import { get } from 'svelte/store';
import {
PROVIDER_LABELS,
backupsNeedingReconnect,
markReconnectNeeded,
refreshBackupStatus
} from '$lib/stores/backupStatus';
import type { ExportProvider } from '$lib/models/PokedexExportIntegration';
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
export let data: PageData;
@@ -66,6 +74,8 @@
lastSuccessfulFlushAt: null
};
let lastOfflineSyncFlush: number | null = null;
// Backup providers that just refused this page's export because their access was revoked.
let reconnectToastLabels: string[] = [];
let exportAfterFlush = false;
let exportInFlight = false;
let exportTimer: ReturnType<typeof setTimeout> | null = null;
@@ -110,6 +120,24 @@
console.error('Auto-export failed:', response.status, body);
return;
}
const result = (await response.json().catch(() => null)) as {
failed?: Array<{ provider: ExportProvider; reconnectRequired?: boolean }>;
} | null;
const revoked = (result?.failed ?? []).filter((failure) => failure.reconnectRequired);
const alreadyPaused = new Set(get(backupsNeedingReconnect));
if (revoked.length > 0) {
markReconnectNeeded(revoked.map((failure) => failure.provider));
} else {
// Saving a catch record also exports on the server, and that export may already have
// paused a provider, leaving this export nothing to report. Re-read the status to catch it.
await refreshBackupStatus();
}
const newlyPaused = get(backupsNeedingReconnect).filter(
(provider) => !alreadyPaused.has(provider)
);
if (newlyPaused.length > 0) {
reconnectToastLabels = newlyPaused.map((provider) => PROVIDER_LABELS[provider]);
}
if (exportGeneration === exportInFlightGeneration) {
exportAfterFlush = false;
}
@@ -474,8 +502,35 @@
});
}
// Fetch data whenever pagination controls change (client-side only)
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
// Show data whenever the dex or pagination changes (client-side only). The first page is streamed
// from the server load, so it only needs fetching when that failed or the page changes.
let shownKey = '';
function showPage(
id: string,
page: number,
perPage: number,
initial: Promise<CombinedData[] | null> | undefined
) {
const key = `${id}:${page}:${perPage}`;
if (key === shownKey) return;
shownKey = key;
if (page !== 1 || !initial) {
void getData({ page, perPage });
return;
}
combinedData = null;
void initial.then((rows) => {
if (shownKey !== key) return;
if (!rows) {
void getData({ page, perPage });
return;
}
combinedData = rows;
boxNumbers = calculateBoxNumbers(rows.length);
});
}
$: if (browser && pokedexId)
showPage(pokedexId, currentPage, itemsPerPage, data?.initialCombinedData);
onMount(() => {
if (!browser) return;
@@ -514,6 +569,24 @@
});
</script>
{#if reconnectToastLabels.length > 0}
<!-- Above DaisyUI's modal (z-index 999) so the alert stays usable over an open Pokémon dialog. -->
<div class="toast toast-end z-[1000]">
<div class="alert alert-warning" role="alert" data-testid="backup-reconnect-toast">
<span>
Backups to {reconnectToastLabels.join(' and ')} have stopped because access expired or was revoked.
</span>
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
<button
type="button"
class="btn btn-sm btn-ghost"
aria-label="Dismiss"
on:click={() => (reconnectToastLabels = [])}>✕</button
>
</div>
</div>
{/if}
<svelte:head>
<title>{pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'}</title>
</svelte:head>
+8 -19
View File
@@ -22,8 +22,12 @@
<div class="min-h-[calc(100vh-16rem)] bg-base-100 py-8 md:py-16 px-4 sm:px-6 lg:px-8">
<div class="max-w-6xl mx-auto">
<!-- Success Badge -->
{#if showSuccess}
<div class="flex justify-center mb-8 animate-fade-in">
<!-- Always rendered (faded in) so the hero doesn't jump down when the badge appears. -->
<div
class="flex justify-center mb-8 transition-opacity duration-500"
class:opacity-0={!showSuccess}
>
<div class="flex justify-center">
<div class="badge badge-success badge-lg gap-2 p-6 shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -42,7 +46,7 @@
<span class="text-lg font-semibold">Account Created Successfully!</span>
</div>
</div>
{/if}
</div>
<!-- Hero Content -->
<div class="hero">
@@ -306,6 +310,7 @@
</svg>
<h4 class="font-semibold">Works Offline</h4>
<p class="text-sm opacity-70">Track your catches even without an internet connection</p>
<a href="/offline-guide" class="link link-primary text-sm">How to use offline</a>
</div>
</div>
</div>
@@ -314,21 +319,6 @@
</div>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.5s ease-out forwards;
}
@keyframes pulse {
0%,
100% {
@@ -345,7 +335,6 @@
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-pulse {
animation: none;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 14 KiB

+9 -4
View File
@@ -1,9 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/routes/**/*.{svelte,js,ts}',
'./src/routes/**/**/*.{svelte,js,ts}',
'./src/lib/components/**/*.{svelte,js,ts}'
'./src/**/*.{html,svelte,js,ts}',
'./static/offline.html',
'./static/offline-viewer.js'
],
theme: {
extend: {}
@@ -16,11 +16,16 @@ export default {
'dracula',
{
pokeball: {
primary: '#ee1515',
// A slightly deeper Poké Ball red: #ee1515 gave white text only 4.3:1 contrast, below
// the WCAG AA 4.5:1 minimum that the Lighthouse accessibility gate checks.
primary: '#d31111',
'primary-content': '#ffffff',
secondary: '#ffd700',
'secondary-content': '#ffffff',
accent: '#3b82c4',
// daisyUI's default info blue is too light for text on the base colours.
info: '#0369a1',
'info-content': '#ffffff',
neutral: '#ffffff',
'base-100': '#f0f0f0',
'base-content': '#222224'
+26
View File
@@ -40,3 +40,29 @@ Feature: Backup and export
When I update collection progress
Then the catch remains marked caught
And the provider failure is shown in backup settings
And "Google Drive" is not flagged for reconnection
Scenario Outline: Warn when a provider's access is revoked
Given "<provider>" is connected with a revoked refresh token
When I update collection progress
Then the Pokédex page tells me to reconnect "<provider>"
And I can dismiss the reconnect alert
And backup settings asks me to reconnect "<provider>"
And other pages warn that my "<provider>" backup has stopped
And later exports do not retry the revoked token
Examples:
| provider |
| Google Drive |
| Dropbox |
Scenario Outline: Reconnecting clears a previous backup error
Given "<provider>" previously lost access
When I connect the mocked "<provider>" provider
Then "<provider>" is shown as connected
And the previous backup error is cleared
Examples:
| provider |
| Google Drive |
| Dropbox |
+19
View File
@@ -0,0 +1,19 @@
Feature: Signed-in page speed
As a trainer
I want my Pokédexes to open and switch quickly
So that tracking catches never feels sluggish
Lighthouse CI covers the public pages; these budgets cover the signed-in ones it can't reach.
Background:
Given I am signed in
And I have a Living Dex named "Speed Check"
Scenario: A Pokédex opens without a second round trip for its entries
When I load the Pokédex page directly
Then its entries appear within 5 seconds
And the browser did not request the entries separately
Scenario: Moving between my Pokédex list and a Pokédex is quick
When I switch between my Pokédex list and the Pokédex
Then each switch finishes within 3 seconds
+14
View File
@@ -26,6 +26,20 @@ Feature: Offline-friendly application
When I go offline and reload the current Pokédex
Then the offline copy contains "Offline Collection"
Scenario: Explain offline use on the offline guide
Given I am signed in
When I open the offline guide
Then the offline guide shows my offline copy status
Scenario: Keep offline sync status off everyday pages
Given I am signed in
And I have a Living Dex named "Quiet Offline"
And my offline copy is synchronized
When I open the offline guide from the user menu
Then the offline guide shows when my offline copy was updated
When I return to my Pokédexes from the user menu
Then no offline sync status is shown
Scenario: Restore network access
Given I have opened the built application online
When I go offline and then return online
+2 -2
View File
@@ -81,7 +81,7 @@ When('I visit the public home page', async ({ page }) => {
});
When('I sign out', async ({ page }) => {
await page.getByRole('button', { name: 'usericon' }).click();
await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
});
@@ -93,7 +93,7 @@ When('the sign-out request fails', async ({ page }) => {
body: '{"message":"unavailable"}'
})
);
await page.getByRole('button', { name: 'usericon' }).click();
await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
});
+100 -1
View File
@@ -12,6 +12,17 @@ const SUPABASE_URL = requireLoopbackUrl(
type Provider = 'google_drive' | 'dropbox';
// Outside CI Playwright reuses an already-running mock, which may predate a new control route.
// Fail loudly then, rather than letting the scenario run against the wrong mock behaviour.
async function mockControl(route: string) {
const response = await fetch(`${MOCK_URL}/__mock/${route}`);
if (!response.ok) {
throw new Error(
`Mock provider rejected /__mock/${route} (${response.status}). Stop any stale mock on port 4199 and rerun.`
);
}
}
async function seedIntegration(
state: import('../fixtures').ScenarioState,
provider: Provider,
@@ -67,10 +78,36 @@ Given('Dropbox is connected with an expired token', async ({ page, state }) => {
Given('Google Drive is connected to a failing mocked provider', async ({ page, state }) => {
await seedIntegration(state, 'google_drive');
await fetch(`${MOCK_URL}/__mock/fail-uploads`);
await mockControl('fail-uploads');
await ensureExportDex(page, state);
});
const PROVIDERS: Record<string, Provider> = { 'Google Drive': 'google_drive', Dropbox: 'dropbox' };
function providerFor(label: string): Provider {
const provider = PROVIDERS[label];
if (!provider) throw new Error(`Unknown backup provider "${label}"`);
return provider;
}
Given(
'{string} is connected with a revoked refresh token',
async ({ page, state }, label: string) => {
await seedIntegration(state, providerFor(label), {
accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString()
});
await mockControl('revoke-refresh');
await ensureExportDex(page, state);
}
);
Given('{string} previously lost access', async ({ state }, label: string) => {
await seedIntegration(state, providerFor(label), {
enabled: false,
lastError: `${label} access has expired or was revoked. Reconnect ${label} to resume backups.`
});
});
When('I visit backup settings', async ({ page }) => {
await page.goto('/backup-settings');
});
@@ -182,3 +219,65 @@ Then('the provider failure is shown in backup settings', async ({ page }) => {
await page.goto('/backup-settings');
await expect(page.getByText(/mock upload failure/)).toBeVisible();
});
Then('the Pokédex page tells me to reconnect {string}', async ({ page }, label: string) => {
const toast = page.getByTestId('backup-reconnect-toast');
await expect(toast).toBeVisible({ timeout: 15_000 });
await expect(toast).toContainText(label);
await expect(toast.getByRole('link', { name: 'Reconnect' })).toHaveAttribute(
'href',
'/backup-settings'
);
});
Then('I can dismiss the reconnect alert', async ({ page }) => {
await page.getByTestId('backup-reconnect-toast').getByRole('button', { name: 'Dismiss' }).click();
await expect(page.getByTestId('backup-reconnect-toast')).toHaveCount(0);
// Dismissing the one-off alert must not hide the standing sitewide warning.
await expect(page.getByTestId('backup-reconnect-banner')).toBeVisible();
});
Then('backup settings asks me to reconnect {string}', async ({ page }, label: string) => {
await page.goto('/backup-settings');
const card = page.locator('.border').filter({ hasText: label });
await expect(card.getByText('Reconnect needed', { exact: true })).toBeVisible();
await expect(card.getByText(/access has expired or was revoked/)).toBeVisible();
// The settings page already explains the problem, so the sitewide banner stays out of the way.
await expect(page.getByTestId('backup-reconnect-banner')).toHaveCount(0);
});
Then('other pages warn that my {string} backup has stopped', async ({ page }, label: string) => {
await page.goto('/my-pokedexes');
const banner = page.getByTestId('backup-reconnect-banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText(label);
await expect(banner.getByRole('link', { name: 'Reconnect' })).toHaveAttribute(
'href',
'/backup-settings'
);
});
Then('{string} is not flagged for reconnection', async ({ page, state }, label: string) => {
await page.goto('/backup-settings');
const card = page.locator('.border').filter({ hasText: label });
await expect(card.getByText('Connected', { exact: true })).toBeVisible();
await page.goto('/my-pokedexes');
await expect(page.getByTestId('backup-reconnect-banner')).toHaveCount(0);
// A transient upload failure leaves the integration enabled, so the next export still tries it.
const response = await page.request.post(`/api/pokedexes/${state.pokedexId}/export`);
expect(await response.json()).toMatchObject({ attempted: 1 });
});
Then('later exports do not retry the revoked token', async ({ page, state }) => {
const before = (await mockState()).refreshes;
const response = await page.request.post(`/api/pokedexes/${state.pokedexId}/export`);
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ attempted: 0 });
expect((await mockState()).refreshes).toBe(before);
});
Then('the previous backup error is cleared', async ({ page }) => {
await expect(page.getByText(/access has expired or was revoked/)).toHaveCount(0);
await page.goto('/my-pokedexes');
await expect(page.getByTestId('backup-reconnect-banner')).toHaveCount(0);
});
+77
View File
@@ -0,0 +1,77 @@
import { createBdd } from 'playwright-bdd';
import type { Page } from '@playwright/test';
import { test, expect } from '../fixtures';
const { When, Then } = createBdd(test);
// Per-page scratch values; scenarios run one at a time (workers: 1).
const timings = new WeakMap<
Page,
{ entriesMs?: number; switchMs: number[]; entryRequests: number }
>();
function entriesVisible(page: Page) {
return expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible({
timeout: 30_000
});
}
When('I load the Pokédex page directly', async ({ page, state }) => {
if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be opened');
const record = { switchMs: [], entryRequests: 0 };
timings.set(page, record);
// Only requests made while the page first loads matter; the page's 60s reconciliation refetch
// can't fire within this window.
const countEntryRequests = (request: { url(): string }) => {
if (/\/api\/pokedexes\/[^/]+\/combined-data/.test(request.url())) record.entryRequests++;
};
page.on('request', countEntryRequests);
const started = Date.now();
await page.goto(`/pokedex/${state.pokedexId}`);
await entriesVisible(page);
(record as { entriesMs?: number }).entriesMs = Date.now() - started;
page.off('request', countEntryRequests);
});
Then('its entries appear within {int} seconds', async ({ page }, seconds: number) => {
const entriesMs = timings.get(page)?.entriesMs;
expect(entriesMs, 'entries never became visible').toBeDefined();
expect(entriesMs!).toBeLessThan(seconds * 1000);
});
Then('the browser did not request the entries separately', async ({ page }) => {
// The server load streams the first page of entries with the HTML, so the page must not make
// the old hydrate-then-fetch round trip.
expect(timings.get(page)?.entryRequests).toBe(0);
});
When('I switch between my Pokédex list and the Pokédex', async ({ page, state }) => {
if (!state.pokedexName) throw new Error('A Pokédex must exist before switching to it');
const record = { switchMs: [] as number[], entryRequests: 0 };
timings.set(page, record);
await page.goto('/my-pokedexes');
const card = page.locator('.card').filter({ hasText: state.pokedexName }).first();
await expect(card).toBeVisible();
for (let round = 0; round < 2; round++) {
// List -> Pokédex: a client-side navigation through the card's View button.
let started = Date.now();
await card.getByRole('button', { name: 'View', exact: true }).click();
await page.waitForURL('**/pokedex/**');
await entriesVisible(page);
record.switchMs.push(Date.now() - started);
// Pokédex -> list: back navigation is also handled by the client router.
started = Date.now();
await page.goBack();
await expect(card).toBeVisible();
record.switchMs.push(Date.now() - started);
}
});
Then('each switch finishes within {int} seconds', async ({ page }, seconds: number) => {
const switchMs = timings.get(page)?.switchMs ?? [];
expect(switchMs).toHaveLength(4);
for (const ms of switchMs) expect(ms).toBeLessThan(seconds * 1000);
});
+37
View File
@@ -104,6 +104,23 @@ When('I go offline and reload the current Pokédex', async ({ page, state }) =>
});
});
When('I open the offline guide', async ({ page }) => {
await page.goto('/offline-guide');
});
When('I open the offline guide from the user menu', async ({ page }) => {
await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('link', { name: 'Using Offline' }).click();
await page.waitForURL(/\/offline-guide$/);
});
// Client-side navigation keeps the sync status in memory, so the old layout would show it at once.
When('I return to my Pokédexes from the user menu', async ({ page }) => {
await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('link', { name: 'My Pokédexes' }).click();
await page.waitForURL(/\/my-pokedexes$/);
});
When('I go offline and then return online', async ({ page }) => {
await page.context().setOffline(true);
await page.reload({ waitUntil: 'domcontentloaded' });
@@ -167,6 +184,26 @@ Then('the application remains available', async ({ page }) => {
await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible();
});
Then('the offline guide shows my offline copy status', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Using Living Dex Tracker offline' })
).toBeVisible();
await expect(page.getByTestId('offline-copy-status')).toBeVisible();
});
Then('the offline guide shows when my offline copy was updated', async ({ page }) => {
await expect(page.getByTestId('offline-copy-status')).toContainText(/Offline copy updated/, {
timeout: 30_000
});
});
Then('no offline sync status is shown', async ({ page }) => {
await expect(page.getByRole('heading', { name: /My Pok/ }).first()).toBeVisible();
await expect(
page.getByText(/Offline copy updated|Updating offline copy|Save all artwork for offline/)
).toHaveCount(0);
});
Then('the read-only offline viewer is available', async ({ page }) => {
await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
});
+42 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { generateSW } from '../../pwa.mjs';
@@ -44,4 +45,44 @@ describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
}
});
const outputRoot = `./build/${nodeAdapter ? 'client/' : ''}`;
const nodesDir = './.svelte-kit/output/server/nodes/';
const gzippedSize = (path: string) => gzipSync(readFileSync(`${outputRoot}${path}`)).length;
/** The client files a route node makes the browser load (its imports and stylesheets). */
function assetsLoadedBy(node: string, extension: 'js' | 'css'): string[] {
const pattern = new RegExp(`_app/immutable/[^"']+\\.${extension}`, 'g');
return [...new Set(readFileSync(`${nodesDir}${node}`, 'utf-8').match(pattern) ?? [])];
}
it('ships the app stylesheet once, hashed and small', () => {
const referenced = new Set(
readdirSync(nodesDir).flatMap((node) => assetsLoadedBy(node, 'css'))
);
// Every page loads Tailwind's preflight; exactly one served stylesheet may contain it.
const withPreflight = [...referenced].filter((path) =>
readFileSync(`${outputRoot}${path}`, 'utf-8').includes('--tw-content')
);
expect(withPreflight, 'Tailwind is bundled more than once').toHaveLength(1);
// The un-hashed output.css exists only for offline.html; pages must not block on it.
const appHtml = readFileSync('./src/app.html', 'utf-8');
expect(appHtml).not.toMatch(/output\.css/);
});
// Regression budgets for what every page downloads before it can render: the root layout's
// scripts and stylesheets. Unlike Lighthouse timings these sizes don't vary between runs, so any
// growth past the budget fails the PR. Measured September 2026: 95.7 KB JS and 15.7 KB CSS
// gzipped. Raise a budget in the same PR only when the extra weight is deliberate.
it.each([
['js', 105 * 1024],
['css', 18 * 1024]
] as const)('keeps the layout %s loaded on every page within budget', (extension, budget) => {
const total = assetsLoadedBy('0.js', extension).reduce(
(sum, path) => sum + gzippedSize(path),
0
);
expect(total, `layout ${extension} is ${total} bytes gzipped`).toBeLessThan(budget);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { get } from 'svelte/store';
import {
backupsNeedingReconnect,
clearBackupStatus,
markReconnectNeeded,
refreshBackupStatus,
setBackupStatus
} from '$lib/stores/backupStatus';
describe('backup reconnect status', () => {
beforeEach(() => clearBackupStatus());
it('lists only the providers that exports switched off', () => {
setBackupStatus([
{ provider: 'google_drive', enabled: false },
{ provider: 'dropbox', enabled: true }
]);
expect(get(backupsNeedingReconnect)).toEqual(['google_drive']);
});
it('adds newly revoked providers without duplicating known ones', () => {
markReconnectNeeded(['google_drive']);
markReconnectNeeded(['google_drive', 'dropbox']);
expect(get(backupsNeedingReconnect)).toEqual(['google_drive', 'dropbox']);
});
it('clears everything, e.g. on sign-out', () => {
markReconnectNeeded(['dropbox']);
clearBackupStatus();
expect(get(backupsNeedingReconnect)).toEqual([]);
});
});
describe('refreshBackupStatus', () => {
const fetchMock = vi.fn();
beforeEach(() => {
clearBackupStatus();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {});
vi.stubGlobal('navigator', { onLine: true });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('loads the paused providers from the integrations API', async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify([
{ provider: 'google_drive', enabled: true },
{ provider: 'dropbox', enabled: false }
])
)
);
await refreshBackupStatus();
expect(fetchMock).toHaveBeenCalledWith('/api/export-integrations', {
credentials: 'include'
});
expect(get(backupsNeedingReconnect)).toEqual(['dropbox']);
});
it('clears a stale warning once the provider has been reconnected', async () => {
markReconnectNeeded(['google_drive']);
fetchMock.mockResolvedValue(
new Response(JSON.stringify([{ provider: 'google_drive', enabled: true }]))
);
await refreshBackupStatus();
expect(get(backupsNeedingReconnect)).toEqual([]);
});
it('does nothing while offline', async () => {
vi.stubGlobal('navigator', { onLine: false });
markReconnectNeeded(['google_drive']);
await refreshBackupStatus();
expect(fetchMock).not.toHaveBeenCalled();
expect(get(backupsNeedingReconnect)).toEqual(['google_drive']);
});
it('does nothing during server rendering', async () => {
vi.stubGlobal('window', undefined);
await refreshBackupStatus();
expect(fetchMock).not.toHaveBeenCalled();
});
it('keeps the last known status when the API rejects the request', async () => {
markReconnectNeeded(['google_drive']);
fetchMock.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
await refreshBackupStatus();
expect(get(backupsNeedingReconnect)).toEqual(['google_drive']);
});
function deferredResponse() {
let resolve!: (response: Response) => void;
const promise = new Promise<Response>((r) => (resolve = r));
return { promise, resolve };
}
const json = (body: unknown) => new Response(JSON.stringify(body));
it('ignores a response overtaken by a newer refresh', async () => {
const slow = deferredResponse();
fetchMock
.mockReturnValueOnce(slow.promise)
.mockResolvedValueOnce(json([{ provider: 'google_drive', enabled: true }]));
const first = refreshBackupStatus();
await refreshBackupStatus();
slow.resolve(json([{ provider: 'google_drive', enabled: false }]));
await first;
expect(get(backupsNeedingReconnect)).toEqual([]);
});
it('ignores a response that arrives after the status was flagged directly', async () => {
const slow = deferredResponse();
fetchMock.mockReturnValueOnce(slow.promise);
const pending = refreshBackupStatus();
markReconnectNeeded(['dropbox']);
slow.resolve(json([{ provider: 'dropbox', enabled: true }]));
await pending;
expect(get(backupsNeedingReconnect)).toEqual(['dropbox']);
});
it('ignores a response that arrives after the status was cleared', async () => {
const slow = deferredResponse();
fetchMock.mockReturnValueOnce(slow.promise);
const pending = refreshBackupStatus();
clearBackupStatus();
slow.resolve(json([{ provider: 'google_drive', enabled: false }]));
await pending;
expect(get(backupsNeedingReconnect)).toEqual([]);
});
it('ignores a response that arrives after the status was set from another source', async () => {
const slow = deferredResponse();
fetchMock.mockReturnValueOnce(slow.promise);
const pending = refreshBackupStatus();
setBackupStatus([{ provider: 'google_drive', enabled: true }]);
slow.resolve(json([{ provider: 'google_drive', enabled: false }]));
await pending;
expect(get(backupsNeedingReconnect)).toEqual([]);
});
it('keeps the last known status when the request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
markReconnectNeeded(['dropbox']);
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
await refreshBackupStatus();
expect(get(backupsNeedingReconnect)).toEqual(['dropbox']);
expect(consoleError).toHaveBeenCalled();
});
});
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Pokedex } from '$lib/models/Pokedex';
const findCombinedData = vi.fn();
const countCombinedData = vi.fn();
const constructed: unknown[][] = [];
vi.mock('$lib/repositories/CombinedDataRepository', () => ({
default: class {
constructor(...args: unknown[]) {
constructed.push(args);
}
findCombinedData = findCombinedData;
countCombinedData = countCombinedData;
}
}));
vi.mock('$lib/services/PokedexDexScopeService', () => ({
resolveDexScopes: vi.fn(async () => ['national'])
}));
const { loadCombinedDataPage } = await import('$lib/services/CombinedDataService');
const supabase = {} as never;
const pokedex = { _id: 'dex-1', gameScope: 'Black' } as unknown as Pokedex;
describe('loadCombinedDataPage', () => {
beforeEach(() => {
constructed.length = 0;
findCombinedData.mockReset().mockResolvedValue([{ id: 'row' }]);
countCombinedData.mockReset().mockResolvedValue(45);
});
it("defaults to the Pokédex's game scope and reports pagination", async () => {
const result = await loadCombinedDataPage(supabase, 'user-1', pokedex, {
page: 2,
limit: 20,
enableForms: true
});
expect(constructed).toEqual([[supabase, 'user-1', 'dex-1']]);
expect(findCombinedData).toHaveBeenCalledWith('user-1', 2, 20, true, '', 'Black', ['national']);
expect(countCombinedData).toHaveBeenCalledWith(true, '', 'Black', ['national']);
expect(result).toEqual({
combinedData: [{ id: 'row' }],
totalPages: 3,
currentPage: 2,
totalCount: 45
});
});
it('prefers an explicit game and region filter', async () => {
await loadCombinedDataPage(supabase, 'user-1', pokedex, {
page: 1,
limit: 9999,
enableForms: false,
region: 'unova',
game: 'White'
});
expect(countCombinedData).toHaveBeenCalledWith(false, 'unova', 'White', ['national']);
});
it('runs the rows and count queries at the same time', async () => {
let releaseRows: (rows: unknown[]) => void = () => {};
findCombinedData.mockReturnValue(new Promise((resolve) => (releaseRows = resolve)));
const pending = loadCombinedDataPage(supabase, 'user-1', pokedex, {
page: 1,
limit: 10,
enableForms: false
});
// The count starts before the rows query has finished.
await vi.waitFor(() => expect(countCombinedData).toHaveBeenCalled());
releaseRows([]);
await expect(pending).resolves.toMatchObject({ totalCount: 45, totalPages: 5 });
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import { brotliDecompressSync, gunzipSync } from 'node:zlib';
import { compressResponse, pickEncoding } from '$lib/server/compression';
const html = '<!doctype html><p>' + 'Living Dex '.repeat(500) + '</p>';
function request(acceptEncoding?: string, method = 'GET') {
return new Request('http://localhost/', {
method,
headers: acceptEncoding ? { 'accept-encoding': acceptEncoding } : {}
});
}
function page(body: BodyInit | null = html, init: ResponseInit = {}) {
return new Response(body, {
status: 200,
headers: { 'content-type': 'text/html; charset=utf-8', 'content-length': '999' },
...init
});
}
async function bytes(response: Response) {
return Buffer.from(await response.arrayBuffer());
}
describe('pickEncoding', () => {
it.each([
['gzip, deflate, br', 'br'],
['gzip', 'gzip'],
['br;q=0, gzip', 'gzip'],
['*', 'br'],
['identity', null],
['gzip;q=0', null]
])('%s -> %s', (header, expected) => {
expect(pickEncoding(header)).toBe(expected);
});
it('returns null when the client sends no Accept-Encoding', () => {
expect(pickEncoding(null)).toBeNull();
});
});
describe('compressResponse', () => {
it('brotli-compresses HTML and the body round-trips', async () => {
const response = compressResponse(request('gzip, br'), page());
expect(response.headers.get('content-encoding')).toBe('br');
expect(response.headers.get('content-length')).toBeNull();
expect(response.headers.get('vary')).toMatch(/Accept-Encoding/);
const body = await bytes(response);
expect(body.length).toBeLessThan(html.length / 4);
expect(brotliDecompressSync(body).toString()).toBe(html);
});
it('falls back to gzip for JSON', async () => {
const json = JSON.stringify({ rows: Array.from({ length: 200 }, (_, i) => ({ i })) });
const response = compressResponse(
request('gzip'),
new Response(json, { headers: { 'content-type': 'application/json' } })
);
expect(response.headers.get('content-encoding')).toBe('gzip');
expect(gunzipSync(await bytes(response)).toString()).toBe(json);
});
it('keeps set-cookie headers and the status', async () => {
const original = page(html, { status: 404 });
original.headers.append('set-cookie', 'a=1; Path=/');
original.headers.append('set-cookie', 'b=2; Path=/');
const response = compressResponse(request('br'), original);
expect(response.status).toBe(404);
expect(response.headers.getSetCookie()).toEqual(['a=1; Path=/', 'b=2; Path=/']);
});
it('flushes each streamed chunk rather than buffering the whole body', async () => {
let sendRest: () => void = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<p>shell</p>'));
sendRest = () => {
controller.enqueue(new TextEncoder().encode('<p>streamed data</p>'));
controller.close();
};
}
});
const response = compressResponse(request('gzip'), page(stream));
const reader = response.body!.getReader();
// The shell arrives while the stream is still open.
const first = await reader.read();
expect(gunzipSync(Buffer.from(first.value!), { finishFlush: 2 }).toString()).toBe(
'<p>shell</p>'
);
sendRest();
const rest: Uint8Array[] = [Buffer.from(first.value!)];
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
rest.push(chunk.value);
}
expect(gunzipSync(Buffer.concat(rest)).toString()).toBe('<p>shell</p><p>streamed data</p>');
});
it('passes responses through inside a Netlify (Lambda) function', () => {
// adapter-netlify reads text bodies with response.text(), which would corrupt compressed bytes.
process.env.AWS_LAMBDA_FUNCTION_NAME = 'sveltekit-render';
try {
const original = page();
expect(compressResponse(request('br'), original)).toBe(original);
} finally {
delete process.env.AWS_LAMBDA_FUNCTION_NAME;
}
});
it.each([
['a client that accepts no encoding', request(), page()],
['a HEAD request', request('br', 'HEAD'), page(null)],
['an image', request('br'), new Response('png', { headers: { 'content-type': 'image/png' } })],
['a 304', request('br'), new Response(null, { status: 304 })],
[
'an already-encoded body',
request('br'),
new Response('x', { headers: { 'content-type': 'text/html', 'content-encoding': 'gzip' } })
]
])('leaves %s uncompressed', async (_label, req, res) => {
const response = compressResponse(req, res);
expect(response.headers.get('content-encoding')).toBe(res.headers.get('content-encoding'));
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
const getUser = vi.fn();
const getSession = vi.fn();
vi.mock('$env/static/public', () => ({
PUBLIC_SUPABASE_URL: 'http://127.0.0.1:54321',
PUBLIC_SUPABASE_ANON_KEY: 'anon'
}));
vi.mock('@supabase/ssr', () => ({
createServerClient: () => ({ auth: { getUser, getSession } })
}));
const { handle } = await import('../../src/hooks.server');
async function runHandle() {
const event = {
cookies: { getAll: () => [], set: vi.fn() },
locals: {}
} as unknown as Parameters<typeof handle>[0]['event'];
await handle({ event, resolve: vi.fn(async () => new Response()) } as never);
return event.locals;
}
describe('safeGetSession', () => {
it('validates the session with Supabase Auth only once per request', async () => {
getUser.mockReset().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
getSession.mockReset().mockResolvedValue({ data: { session: { access_token: 't' } } });
const locals = await runHandle();
const [first, second] = await Promise.all([locals.safeGetSession(), locals.safeGetSession()]);
const third = await locals.safeGetSession();
expect(getUser).toHaveBeenCalledTimes(1);
expect(first.user?.id).toBe('user-1');
expect(second).toBe(first);
expect(third).toBe(first);
});
it('does not share a session between requests', async () => {
getUser.mockReset().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
getSession.mockReset().mockResolvedValue({ data: { session: {} } });
await (await runHandle()).safeGetSession();
await (await runHandle()).safeGetSession();
expect(getUser).toHaveBeenCalledTimes(2);
});
it('returns no session when the JWT is rejected', async () => {
getUser.mockReset().mockResolvedValue({ data: { user: null }, error: new Error('bad jwt') });
getSession.mockReset();
const locals = await runHandle();
await expect(locals.safeGetSession()).resolves.toEqual({ session: null, user: null });
expect(getSession).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,257 @@
import { describe, expect, it, vi } from 'vitest';
import type { SupabaseClient } from '@supabase/supabase-js';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
type Result = { data: unknown; error: unknown };
/** A chainable stand-in for the Supabase query builder that records each call made on it. */
function fakeSupabase(result: Result) {
const calls: unknown[][] = [];
const builder: Record<string, unknown> = {};
for (const method of ['select', 'update', 'upsert', 'single', 'eq', 'is', 'or']) {
builder[method] = (...args: unknown[]) => {
calls.push([method, ...args]);
return builder;
};
}
// Awaiting the builder runs the query, as it does in supabase-js.
builder.then = (resolve: (value: Result) => unknown, reject?: (reason: unknown) => unknown) =>
Promise.resolve(result).then(resolve, reject);
const from = vi.fn(() => builder);
return { supabase: { from } as unknown as SupabaseClient, calls, from };
}
const VERSION = '2026-09-14T12:00:00.123456+00:00';
const WRITTEN: Result = { data: [{ id: 'int-1' }], error: null };
describe('PokedexExportIntegrationRepository.updateExportStatus', () => {
it('writes only while the row still has the version the caller read', async () => {
const { supabase, calls, from } = fakeSupabase(WRITTEN);
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
const applied = await repo.updateExportStatus('int-1', { enabled: false }, VERSION);
expect(applied).toBe(true);
expect(from).toHaveBeenCalledWith('pokedex_export_integrations');
expect(calls).toEqual([
['update', { enabled: false }],
['eq', 'id', 'int-1'],
['eq', 'userId', 'user-1'],
['eq', 'updatedAt', VERSION],
['is', 'pokedexId', null],
['select', 'id']
]);
});
it('writes unconditionally without a version, scoped to the Pokédex', async () => {
const { supabase, calls } = fakeSupabase(WRITTEN);
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', 'dex-1');
expect(await repo.updateExportStatus('int-1', { lastError: null })).toBe(true);
expect(calls).toEqual([
['update', { lastError: null }],
['eq', 'id', 'int-1'],
['eq', 'userId', 'user-1'],
['eq', 'pokedexId', 'dex-1'],
['select', 'id']
]);
});
it.each([
['no row matched, e.g. after a reconnect changed it', { data: [], error: null }],
['the response carries no rows', { data: null, error: null }]
])('reports nothing written when %s', async (_label, result) => {
const { supabase } = fakeSupabase(result);
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
expect(await repo.updateExportStatus('int-1', { enabled: false }, VERSION)).toBe(false);
});
it('logs and reports nothing written when the update fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { supabase } = fakeSupabase({ data: null, error: { message: 'boom' } });
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
expect(await repo.updateExportStatus('int-1', { enabled: false })).toBe(false);
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe('PokedexExportIntegrationRepository.listAll', () => {
it('maps each row version, defaulting a missing one to null', async () => {
const row = {
userId: 'user-1',
pokedexId: null,
provider: 'google_drive',
enabled: true,
fileName: null,
folderId: null,
path: null,
accessToken: 'access',
refreshToken: 'refresh',
accessTokenExpiresAt: null,
metadata: null,
lastExportedAt: null,
lastError: null
};
const { supabase } = fakeSupabase({
data: [
{ ...row, id: 'with-version', updatedAt: VERSION },
{ ...row, id: 'without-version' }
],
error: null
});
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
const [withVersion, withoutVersion] = await repo.listAll();
expect(withVersion).toMatchObject({ _id: 'with-version', updatedAt: VERSION });
expect(withoutVersion).toMatchObject({ _id: 'without-version', updatedAt: null });
});
});
function dbRow(id: string) {
return {
id,
userId: 'user-1',
pokedexId: null,
provider: 'dropbox',
enabled: true,
fileName: null,
folderId: null,
path: '/Backups',
accessToken: 'access',
refreshToken: 'refresh',
accessTokenExpiresAt: null,
metadata: null,
lastExportedAt: null,
lastError: null,
updatedAt: VERSION
};
}
describe('PokedexExportIntegrationRepository queries', () => {
it('lists enabled integrations scoped to one Pokédex', async () => {
const { supabase, calls } = fakeSupabase({ data: [dbRow('int-1')], error: null });
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', 'dex-1');
const [integration] = await repo.listEnabled();
expect(integration).toMatchObject({ _id: 'int-1', provider: 'dropbox', path: '/Backups' });
expect(calls).toEqual([
['select', '*'],
['eq', 'userId', 'user-1'],
['eq', 'pokedexId', 'dex-1'],
['eq', 'enabled', true]
]);
});
it.each([
['dex-1', ['or', 'pokedexId.eq.dex-1,pokedexId.is.null']],
[null, ['is', 'pokedexId', null]]
])('lists enabled integrations for Pokédex %j or the whole account', async (pokedexId, scope) => {
const { supabase, calls } = fakeSupabase({ data: [dbRow('int-1')], error: null });
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', pokedexId);
expect(await repo.listEnabledForPokedexOrUser()).toHaveLength(1);
expect(calls).toEqual([
['select', '*'],
['eq', 'userId', 'user-1'],
['eq', 'enabled', true],
scope
]);
});
it.each(['listAll', 'listEnabled', 'listEnabledForPokedexOrUser'] as const)(
'%s returns nothing when no rows come back',
async (method) => {
const { supabase } = fakeSupabase({ data: null, error: null });
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
expect(await repo[method]()).toEqual([]);
}
);
it.each(['listAll', 'listEnabled', 'listEnabledForPokedexOrUser'] as const)(
'%s throws when the query fails',
async (method) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { supabase } = fakeSupabase({ data: null, error: { message: 'boom' } });
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
await expect(repo[method]()).rejects.toThrow('Failed to load export integrations: boom');
consoleError.mockRestore();
}
);
});
describe('PokedexExportIntegrationRepository.upsert', () => {
it('saves one account-wide integration per provider', async () => {
const { supabase, calls } = fakeSupabase({ data: dbRow('int-1'), error: null });
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', 'dex-1');
const saved = await repo.upsert({ provider: 'dropbox', enabled: true, lastError: null });
expect(saved).toMatchObject({ _id: 'int-1', updatedAt: VERSION });
expect(calls).toEqual([
[
'upsert',
{
userId: 'user-1',
pokedexId: null,
provider: 'dropbox',
enabled: true,
lastError: null
},
{ onConflict: 'userId,provider' }
],
['select'],
['single']
]);
});
it.each([
['the save fails', { data: null, error: { message: 'boom' } }, 'boom'],
['nothing comes back', { data: null, error: null }, 'No result returned']
])('throws when %s', async (_label, result, message) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { supabase } = fakeSupabase(result);
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
await expect(repo.upsert({ provider: 'dropbox' })).rejects.toThrow(
`Failed to save export integration: ${message}`
);
consoleError.mockRestore();
});
});
describe('PokedexExportIntegrationRepository.updateTokens', () => {
it.each([
['dex-1', ['eq', 'pokedexId', 'dex-1']],
[null, ['is', 'pokedexId', null]]
])('stores refreshed tokens scoped to Pokédex %j', async (pokedexId, scope) => {
const { supabase, calls } = fakeSupabase({ data: null, error: null });
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', pokedexId);
const patch = { accessToken: 'new-access', accessTokenExpiresAt: null };
await repo.updateTokens('int-1', patch);
expect(calls).toEqual([
['update', patch],
['eq', 'id', 'int-1'],
['eq', 'userId', 'user-1'],
scope
]);
});
it('logs instead of throwing when the token update fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { supabase } = fakeSupabase({ data: null, error: { message: 'boom' } });
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
await expect(repo.updateTokens('int-1', { accessToken: 'x' })).resolves.toBeUndefined();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
@@ -0,0 +1,519 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { PokedexExportIntegration } from '$lib/models/PokedexExportIntegration';
const mocks = vi.hoisted(() => ({
env: {} as Record<string, string | undefined>,
pokedex: null as unknown,
integrations: [] as PokedexExportIntegration[],
updateExportStatus: vi.fn(),
updateTokens: vi.fn()
}));
vi.mock('$lib/utils/env', () => ({ getEnv: () => mocks.env }));
vi.mock('$lib/repositories/PokedexRepository', () => ({
default: vi.fn().mockImplementation(() => ({ findById: vi.fn(async () => mocks.pokedex) }))
}));
vi.mock('$lib/repositories/CombinedDataRepository', () => ({
default: vi.fn().mockImplementation(() => ({
findAllCombinedData: vi.fn().mockResolvedValue([])
}))
}));
vi.mock('$lib/services/PokedexDexScopeService', () => ({
resolveDexScopes: vi.fn().mockResolvedValue([])
}));
vi.mock('$lib/repositories/PokedexExportIntegrationRepository', () => ({
default: vi.fn().mockImplementation(() => ({
listEnabledForPokedexOrUser: vi.fn(async () => mocks.integrations),
updateExportStatus: mocks.updateExportStatus,
updateTokens: mocks.updateTokens
}))
}));
import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService';
const supabase = {} as SupabaseClient;
const EXPIRED = () => new Date(Date.now() - 60_000).toISOString();
const FRESH = () => new Date(Date.now() + 3_600_000).toISOString();
/** The row version an export read; a guarded write only applies while it still matches. */
const VERSION = '2026-09-14T12:00:00.123456+00:00';
function integration(overrides: Partial<PokedexExportIntegration> = {}): PokedexExportIntegration {
return {
_id: 'google-1',
userId: 'user-1',
pokedexId: null,
provider: 'google_drive',
enabled: true,
fileName: null,
// A known folder skips the Drive folder lookup, keeping each test to token + upload calls.
folderId: 'folder-1',
path: null,
accessToken: 'old-access',
refreshToken: 'refresh',
accessTokenExpiresAt: EXPIRED(),
metadata: null,
lastExportedAt: null,
lastError: null,
updatedAt: VERSION,
...overrides
};
}
type Reply = { status: number; body: unknown };
const fetchMock = vi.fn();
/** Answers token requests with `token` and every other provider call with `upload`. */
function stubProvider(token: Reply, upload: Reply = { status: 200, body: { id: 'file-1' } }) {
fetchMock.mockImplementation(async (input: string) => {
const reply = input.includes('/token') ? token : upload;
const body = typeof reply.body === 'string' ? reply.body : JSON.stringify(reply.body);
return new Response(body, { status: reply.status });
});
}
const REVOKED: Reply = {
status: 400,
body: { error: 'invalid_grant', error_description: 'Bad Request' }
};
const REFRESHED: Reply = { status: 200, body: { access_token: 'new-access', expires_in: 3600 } };
function uploadCalls() {
return fetchMock.mock.calls.filter(([url]) => !String(url).includes('/token'));
}
/** Routes each provider call to a reply; returning a string or Error makes that fetch reject. */
function routeFetch(handler: (url: string, init?: RequestInit) => Reply | Error | string) {
fetchMock.mockImplementation(async (input: string, init?: RequestInit) => {
const reply = handler(input, init);
if (typeof reply === 'string' || reply instanceof Error) throw reply;
const body = typeof reply.body === 'string' ? reply.body : JSON.stringify(reply.body);
return new Response(body, { status: reply.status });
});
}
const FULL_ENV = {
GOOGLE_OAUTH_CLIENT_ID: 'google-id',
GOOGLE_OAUTH_CLIENT_SECRET: 'google-secret',
DROPBOX_OAUTH_CLIENT_ID: 'dropbox-id',
DROPBOX_OAUTH_CLIENT_SECRET: 'dropbox-secret'
};
const DEX = { _id: 'dex-1', name: 'My Dex', isFormDex: false, gameScope: '' };
let consoleError: ReturnType<typeof vi.spyOn>;
let consoleWarn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mocks.env = { ...FULL_ENV };
mocks.pokedex = DEX;
mocks.integrations = [];
mocks.updateExportStatus.mockReset();
// The repository reports whether a row was written; by default every write applies.
mocks.updateExportStatus.mockResolvedValue(true);
mocks.updateTokens.mockReset();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
// Only undo the spies: vi.restoreAllMocks() would also wipe the repository module mocks above.
consoleError.mockRestore();
consoleWarn.mockRestore();
});
describe('exportPokedexIfConfigured when a provider revokes access', () => {
it.each([
['google_drive', 'google-1', /Reconnect Google Drive/],
['dropbox', 'dropbox-1', /Reconnect Dropbox/]
] as const)('pauses %s when its refresh token is revoked', async (provider, id, message) => {
mocks.integrations = [integration({ _id: id, provider })];
stubProvider(REVOKED);
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result).toMatchObject({ attempted: 1, succeeded: 0 });
expect(result.failed).toEqual([
{
integrationId: id,
provider,
error: expect.stringMatching(message),
reconnectRequired: true
}
]);
expect(mocks.updateExportStatus).toHaveBeenCalledWith(
id,
{ lastError: expect.stringMatching(message), enabled: false },
VERSION
);
expect(uploadCalls()).toHaveLength(0);
expect(mocks.updateTokens).not.toHaveBeenCalled();
});
it('pauses an integration that has no refresh token without calling the provider', async () => {
mocks.integrations = [integration({ refreshToken: null })];
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.failed[0]).toMatchObject({ reconnectRequired: true });
expect(mocks.updateExportStatus).toHaveBeenCalledWith(
'google-1',
{ lastError: expect.stringMatching(/Reconnect Google Drive/), enabled: false },
VERSION
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('leaves a backup reconnected during the export enabled and unflagged', async () => {
mocks.integrations = [integration()];
stubProvider(REVOKED);
// The guarded pause matches no row: a reconnect changed it after this export read it.
mocks.updateExportStatus.mockResolvedValue(false);
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(mocks.updateExportStatus).toHaveBeenCalledTimes(1);
expect(mocks.updateExportStatus).toHaveBeenCalledWith(
'google-1',
{ lastError: expect.stringMatching(/Reconnect Google Drive/), enabled: false },
VERSION
);
// The client must not tell the user to reconnect a connection that is already fresh.
expect(result.failed[0]).toMatchObject({ reconnectRequired: false });
});
it.each([
['a server error', { status: 500, body: 'upstream down' }],
['a different OAuth error', { status: 400, body: { error: 'invalid_client' } }]
])('keeps the integration enabled when the refresh fails with %s', async (_label, token) => {
mocks.integrations = [integration()];
stubProvider(token);
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.failed[0]).toMatchObject({
reconnectRequired: false,
error: expect.stringMatching(/^Google token refresh failed: /)
});
// Exactly this patch: no `enabled` key, so a transient failure is retried on the next export.
expect(mocks.updateExportStatus).toHaveBeenCalledWith('google-1', {
lastError: expect.stringMatching(/^Google token refresh failed: /)
});
});
it('keeps the integration enabled when only the upload fails', async () => {
mocks.integrations = [integration({ accessTokenExpiresAt: FRESH() })];
stubProvider(REFRESHED, { status: 503, body: { error: 'mock upload failure' } });
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.failed[0]).toMatchObject({ reconnectRequired: false });
expect(mocks.updateExportStatus).toHaveBeenCalledWith('google-1', {
lastError: expect.stringMatching(/Google Drive upload failed: 503/)
});
});
it('refreshes an expired token, uploads, and clears the previous error', async () => {
mocks.integrations = [integration({ lastError: 'old failure' })];
stubProvider(REFRESHED);
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result).toEqual({ attempted: 1, succeeded: 1, failed: [] });
expect(mocks.updateTokens).toHaveBeenCalledWith('google-1', {
accessToken: 'new-access',
accessTokenExpiresAt: expect.any(String)
});
expect(uploadCalls()[0][1].headers.Authorization).toBe('Bearer new-access');
expect(mocks.updateExportStatus).toHaveBeenCalledWith('google-1', {
lastExportedAt: expect.any(String),
lastError: null
});
});
it('pauses only the revoked provider when another one still works', async () => {
mocks.integrations = [
integration(),
integration({ _id: 'dropbox-1', provider: 'dropbox', accessTokenExpiresAt: FRESH() })
];
fetchMock.mockImplementation(async (input: string) =>
input.includes('googleapis.com/token')
? new Response(JSON.stringify(REVOKED.body), { status: 400 })
: new Response(JSON.stringify({ id: 'file-1' }), { status: 200 })
);
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result).toMatchObject({ attempted: 2, succeeded: 1 });
expect(result.failed.map((failure) => failure.provider)).toEqual(['google_drive']);
const pausedIds = mocks.updateExportStatus.mock.calls
.filter(([, patch]) => patch.enabled === false)
.map(([integrationId]) => integrationId);
expect(pausedIds).toEqual(['google-1']);
});
});
const DRIVE_API = 'https://www.googleapis.com/drive/v3';
const DRIVE_UPLOAD = 'https://www.googleapis.com/upload/drive/v3';
function isDriveUpload(url: string) {
return url.startsWith(DRIVE_UPLOAD);
}
/** The JSON metadata part of a Drive multipart upload body. */
function driveUploadMetadata(init?: RequestInit) {
return JSON.parse(String(init?.body).split('\r\n')[3]) as Record<string, unknown>;
}
function statusPatches() {
return mocks.updateExportStatus.mock.calls.map(([, patch]) => patch as Record<string, unknown>);
}
describe('exportPokedexIfConfigured provider paths', () => {
it('does nothing when the Pokédex no longer exists', async () => {
mocks.pokedex = null;
mocks.integrations = [integration()];
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result).toEqual({ attempted: 0, succeeded: 0, failed: [] });
expect(fetchMock).not.toHaveBeenCalled();
expect(mocks.updateExportStatus).not.toHaveBeenCalled();
});
it('does nothing when no backup is connected', async () => {
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result).toEqual({ attempted: 0, succeeded: 0, failed: [] });
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
['google_drive', 'GOOGLE_OAUTH_CLIENT_SECRET', 'Missing Google OAuth client credentials'],
['dropbox', 'DROPBOX_OAUTH_CLIENT_ID', 'Missing Dropbox OAuth client credentials']
] as const)(
'reports missing %s OAuth credentials without pausing the backup',
async (provider, envKey, error) => {
delete mocks.env[envKey];
mocks.integrations = [integration({ provider })];
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.failed[0]).toMatchObject({ error, reconnectRequired: false });
expect(fetchMock).not.toHaveBeenCalled();
}
);
it('pauses Dropbox when it has no refresh token', async () => {
mocks.integrations = [integration({ provider: 'dropbox', refreshToken: null })];
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.failed[0]).toMatchObject({
error: expect.stringMatching(/Reconnect Dropbox/),
reconnectRequired: true
});
});
it('keeps Dropbox enabled when its token refresh fails for another reason', async () => {
mocks.integrations = [integration({ provider: 'dropbox' })];
stubProvider({ status: 500, body: 'upstream down' });
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.failed[0]).toMatchObject({
error: 'Dropbox token refresh failed: 500 upstream down',
reconnectRequired: false
});
});
it('stores a refreshed token without an expiry when the provider omits one', async () => {
mocks.integrations = [integration({ _id: 'dropbox-1', provider: 'dropbox' })];
stubProvider({ status: 200, body: { access_token: 'new-access' } });
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.succeeded).toBe(1);
expect(mocks.updateTokens).toHaveBeenCalledWith('dropbox-1', {
accessToken: 'new-access',
accessTokenExpiresAt: null
});
expect(uploadCalls()[0][1].headers.Authorization).toBe('Bearer new-access');
});
it.each([
[null, '/My Dex.csv'],
['/Backups/', '/Backups/My Dex.csv'],
['/Backups', '/Backups/My Dex.csv'],
[' /Backups/custom.CSV ', '/Backups/custom.CSV']
])('uploads to Dropbox path %j as %s', async (path, expected) => {
mocks.integrations = [
integration({ provider: 'dropbox', path, accessTokenExpiresAt: FRESH() })
];
stubProvider(REFRESHED);
await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
const headers = uploadCalls()[0][1].headers as Record<string, string>;
expect(JSON.parse(headers['Dropbox-API-Arg'])).toEqual({
path: expected,
mode: 'overwrite',
mute: true
});
});
it('reports a failed Dropbox upload', async () => {
mocks.integrations = [integration({ provider: 'dropbox', accessTokenExpiresAt: FRESH() })];
stubProvider(REFRESHED, { status: 500, body: 'boom' });
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.failed[0]).toMatchObject({
error: 'Dropbox upload failed: 500 boom',
reconnectRequired: false
});
});
it('finds the existing Living Dex Tracker folder in Drive', async () => {
mocks.integrations = [integration({ folderId: null, accessTokenExpiresAt: FRESH() })];
routeFetch((url) => {
if (url.startsWith(`${DRIVE_API}/files?`))
return { status: 200, body: { files: [{ id: 'found' }] } };
if (isDriveUpload(url)) return { status: 200, body: { id: 'file-1' } };
return { status: 500, body: `unexpected ${url}` };
});
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.succeeded).toBe(1);
const [, init] = uploadCalls().find(([url]) => isDriveUpload(String(url)))!;
expect(driveUploadMetadata(init).parents).toEqual(['found']);
expect(statusPatches()).toContainEqual({ folderId: 'found' });
expect(fetchMock.mock.calls.some(([url]) => url === `${DRIVE_API}/files`)).toBe(false);
});
it('creates the Living Dex Tracker folder when Drive has none', async () => {
mocks.integrations = [integration({ folderId: null, accessTokenExpiresAt: FRESH() })];
routeFetch((url) => {
if (url.startsWith(`${DRIVE_API}/files?`)) return { status: 200, body: { files: [] } };
if (url === `${DRIVE_API}/files`) return { status: 200, body: { id: 'created' } };
return { status: 200, body: { id: 'file-1' } };
});
await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
const [, init] = uploadCalls().find(([url]) => isDriveUpload(String(url)))!;
expect(driveUploadMetadata(init).parents).toEqual(['created']);
});
it.each([
['rejects the requests', { status: 500, body: 'nope' }],
['cannot be reached', new TypeError('Failed to fetch')]
])('uploads to the Drive root when the folder API %s', async (_label, folderReply) => {
mocks.integrations = [integration({ folderId: null, accessTokenExpiresAt: FRESH() })];
routeFetch((url) =>
isDriveUpload(url) ? { status: 200, body: { id: 'file-1' } } : folderReply
);
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.succeeded).toBe(1);
const [, init] = uploadCalls().find(([url]) => isDriveUpload(String(url)))!;
expect(driveUploadMetadata(init).parents).toBeUndefined();
expect(statusPatches().some((patch) => 'folderId' in patch)).toBe(false);
expect(statusPatches()).toContainEqual({ metadata: { files: { 'dex-1': 'file-1' } } });
});
it('updates the existing Drive file in place', async () => {
mocks.integrations = [
integration({ accessTokenExpiresAt: FRESH(), metadata: { files: { 'dex-1': 'file-1' } } })
];
stubProvider(REFRESHED, { status: 200, body: { id: 'file-1' } });
await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
const [url, init] = uploadCalls()[0];
expect(url).toBe(`${DRIVE_UPLOAD}/files/file-1?uploadType=multipart&addParents=folder-1`);
expect(init.method).toBe('PATCH');
expect(driveUploadMetadata(init).parents).toBeUndefined();
expect(statusPatches().some((patch) => 'metadata' in patch)).toBe(false);
});
it.each([
[
'the only saved file',
{ scope: 's', files: { 'dex-1': 'stale' } },
{ scope: 's' },
{ scope: 's', files: { 'dex-1': 'new-file' } }
],
[
'one of several saved files',
{ files: { 'dex-1': 'stale', 'dex-2': 'other' } },
{ files: { 'dex-2': 'other' } },
{ files: { 'dex-1': 'new-file', 'dex-2': 'other' } }
]
])(
'recreates a Drive file deleted by the user when it was %s',
async (_label, metadata, cleared, saved) => {
mocks.integrations = [integration({ accessTokenExpiresAt: FRESH(), metadata })];
routeFetch((_url, init) =>
init?.method === 'PATCH'
? { status: 404, body: 'File not found' }
: { status: 200, body: { id: 'new-file' } }
);
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.succeeded).toBe(1);
expect(uploadCalls().map(([, init]) => init.method)).toEqual(['PATCH', 'POST']);
expect(driveUploadMetadata(uploadCalls()[1][1]).parents).toEqual(['folder-1']);
const metadataPatches = statusPatches().filter((patch) => 'metadata' in patch);
expect(metadataPatches).toEqual([{ metadata: cleared }, { metadata: saved }]);
}
);
it('gives up when the recreated Drive file is also missing', async () => {
mocks.integrations = [
integration({ accessTokenExpiresAt: FRESH(), metadata: { files: { 'dex-1': 'stale' } } })
];
stubProvider(REFRESHED, { status: 404, body: 'File not found' });
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(uploadCalls()).toHaveLength(2);
expect(result.failed[0]).toMatchObject({
error: 'Google Drive upload failed: 404 File not found',
reconnectRequired: false
});
});
it('records the export even when Drive returns no file id', async () => {
mocks.integrations = [integration({ accessTokenExpiresAt: FRESH() })];
stubProvider(REFRESHED, { status: 200, body: {} });
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.succeeded).toBe(1);
expect(statusPatches()).toEqual([{ lastExportedAt: expect.any(String), lastError: null }]);
});
it('reports a failure that is not an Error as text', async () => {
mocks.integrations = [integration({ accessTokenExpiresAt: FRESH() })];
routeFetch(() => 'network down');
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(result.failed[0]).toMatchObject({ error: 'network down', reconnectRequired: false });
});
it('does nothing for a provider it does not support', async () => {
mocks.integrations = [integration({ provider: 'onedrive' as never })];
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toMatchObject({ attempted: 1, failed: [] });
});
});
+12
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
buildCsv,
csvEscape,
isRevokedGrant,
sanitizeFileName,
shouldRefreshToken
} from '$lib/services/PokedexExportFormatting';
@@ -59,6 +60,17 @@ describe('Pokédex export formatting', () => {
]);
});
it('recognises a revoked or expired refresh token', () => {
expect(isRevokedGrant(400, '{"error":"invalid_grant","error_description":"Bad Request"}')).toBe(
true
);
expect(isRevokedGrant(401, '{"error":"invalid_grant"}')).toBe(true);
expect(isRevokedGrant(400, '{"error":"invalid_client"}')).toBe(false);
expect(isRevokedGrant(500, '{"error":"invalid_grant"}')).toBe(false);
expect(isRevokedGrant(400, 'Bad Request')).toBe(false);
expect(isRevokedGrant(400, 'null')).toBe(false);
});
it('refreshes only finite expiries within the next minute', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-13T12:00:00Z'));
+4 -4
View File
@@ -22,10 +22,10 @@ export default defineConfig({
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
thresholds: {
statements: 34.58,
functions: 73.68,
lines: 34.58,
branches: 79.79
statements: 59.73,
functions: 85.88,
lines: 59.73,
branches: 88.35
}
}
}