mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-18 11:32:06 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31fd959350 | |||
| 7603116369 | |||
| 5d41462d35 | |||
| ff29095c47 | |||
| 556f120f16 | |||
| 7786d46078 | |||
| fb95b43b30 |
@@ -80,6 +80,37 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run test:build
|
- 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:
|
bdd:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ node_modules
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
/static/output.css
|
/static/output.css
|
||||||
|
.lighthouseci
|
||||||
.netlify
|
.netlify
|
||||||
.features-gen
|
.features-gen
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -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/integration` checks the migrated Supabase schema, views, constraints, RLS, and repositories.
|
||||||
- `tests/bdd/features` is the executable Gherkin specification for user-visible behaviour. Step
|
- `tests/bdd/features` is the executable Gherkin specification for user-visible behaviour. Step
|
||||||
definitions and browser fixtures live beside it under `tests/bdd`.
|
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
|
Run the offline suites while developing. `test:fast` includes the coverage run, so there is no need
|
||||||
to run both:
|
to run both:
|
||||||
|
|||||||
+3
-2
@@ -5,9 +5,10 @@ import AdapterNetlify from '@sveltejs/adapter-netlify';
|
|||||||
export const nodeAdapter = process.env.NODE_ADAPTER === 'true';
|
export const nodeAdapter = process.env.NODE_ADAPTER === 'true';
|
||||||
|
|
||||||
// Netlify is the deployment target; the node adapter exists so the service worker
|
// 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
|
export const adapter = nodeAdapter
|
||||||
? AdapterNode()
|
? AdapterNode({ precompress: true })
|
||||||
: AdapterNetlify({
|
: AdapterNetlify({
|
||||||
// if true, will create a Netlify Edge Function rather
|
// if true, will create a Netlify Edge Function rather
|
||||||
// than using standard Node-based functions
|
// than using standard Node-based functions
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+3098
-54
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -21,7 +21,8 @@
|
|||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"lint-fix": "npm run lint --fix",
|
"lint-fix": "npm run lint --fix",
|
||||||
"format": "prettier --write .",
|
"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:unit": "vitest run tests/unit",
|
||||||
"test:data": "vitest run tests/data",
|
"test:data": "vitest run tests/data",
|
||||||
"test:coverage": "vitest run tests/unit --coverage",
|
"test:coverage": "vitest run tests/unit --coverage",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"dev:supabase": "supabase start && npm run dev"
|
"dev:supabase": "supabase start && npm run dev"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lhci/cli": "^0.15.1",
|
||||||
"@playwright/test": "1.55.1",
|
"@playwright/test": "1.55.1",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-netlify": "^4.1.0",
|
"@sveltejs/adapter-netlify": "^4.1.0",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
|
|
||||||
const port = Number(process.env.MOCK_PROVIDER_PORT ?? 4199);
|
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 = {}) {
|
function send(response, status, body, headers = {}) {
|
||||||
response.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
response.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
||||||
@@ -25,6 +25,7 @@ const server = createServer(async (request, response) => {
|
|||||||
if (url.pathname === '/__mock/reset') {
|
if (url.pathname === '/__mock/reset') {
|
||||||
state.requests = [];
|
state.requests = [];
|
||||||
state.failUploads = false;
|
state.failUploads = false;
|
||||||
|
state.revokeRefresh = false;
|
||||||
state.refreshes = 0;
|
state.refreshes = 0;
|
||||||
return send(response, 200, { ok: true });
|
return send(response, 200, { ok: true });
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,10 @@ const server = createServer(async (request, response) => {
|
|||||||
state.failUploads = true;
|
state.failUploads = true;
|
||||||
return send(response, 200, { ok: 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')) {
|
if (url.pathname.endsWith('/authorize')) {
|
||||||
const redirectUri = url.searchParams.get('redirect_uri');
|
const redirectUri = url.searchParams.get('redirect_uri');
|
||||||
@@ -45,7 +50,13 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.endsWith('/token')) {
|
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, {
|
return send(response, 200, {
|
||||||
access_token: 'mock-access-token',
|
access_token: 'mock-access-token',
|
||||||
refresh_token: 'mock-refresh-token',
|
refresh_token: 'mock-refresh-token',
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
<link rel="stylesheet" href="%sveltekit.assets%/output.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
|||||||
+21
-13
@@ -1,6 +1,7 @@
|
|||||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||||
import { createServerClient } from '@supabase/ssr';
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import { compressResponse } from '$lib/server/compression';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
@@ -24,24 +25,31 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
* doesn't validate the JWT, this function validates the JWT by first calling
|
* doesn't validate the JWT, this function validates the JWT by first calling
|
||||||
* `getUser` and aborts early if the JWT signature is invalid.
|
* `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
|
||||||
const {
|
// ask for the session, so validate once per request and share the result.
|
||||||
data: { user },
|
let sessionPromise: ReturnType<App.Locals['safeGetSession']> | null = null;
|
||||||
error
|
event.locals.safeGetSession = () => {
|
||||||
} = await event.locals.supabase.auth.getUser();
|
sessionPromise ??= (async () => {
|
||||||
if (error) {
|
const {
|
||||||
return { session: null, user: null };
|
data: { user },
|
||||||
}
|
error
|
||||||
|
} = await event.locals.supabase.auth.getUser();
|
||||||
|
if (error) {
|
||||||
|
return { session: null, user: null };
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { session }
|
data: { session }
|
||||||
} = await event.locals.supabase.auth.getSession();
|
} = await event.locals.supabase.auth.getSession();
|
||||||
return { session, user };
|
return { session, user };
|
||||||
|
})();
|
||||||
|
return sessionPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
return resolve(event, {
|
const response = await resolve(event, {
|
||||||
filterSerializedResponseHeaders(name) {
|
filterSerializedResponseHeaders(name) {
|
||||||
return name === 'content-range';
|
return name === 'content-range';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return compressResponse(event.request, response);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface PokedexExportIntegration {
|
|||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
lastExportedAt: string | null;
|
lastExportedAt: string | null;
|
||||||
lastError: 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 {
|
export interface PokedexExportIntegrationDB {
|
||||||
@@ -32,4 +34,5 @@ export interface PokedexExportIntegrationDB {
|
|||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
lastExportedAt: string | null;
|
lastExportedAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class PokedexExportIntegrationRepository {
|
|||||||
accessTokenExpiresAt: db.accessTokenExpiresAt,
|
accessTokenExpiresAt: db.accessTokenExpiresAt,
|
||||||
metadata: db.metadata,
|
metadata: db.metadata,
|
||||||
lastExportedAt: db.lastExportedAt,
|
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(
|
async updateExportStatus(
|
||||||
id: string,
|
id: string,
|
||||||
patch: {
|
patch: {
|
||||||
|
enabled?: boolean;
|
||||||
lastExportedAt?: string | null;
|
lastExportedAt?: string | null;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
folderId?: string | null;
|
folderId?: string | null;
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
}
|
},
|
||||||
): Promise<void> {
|
ifUpdatedAt?: string
|
||||||
const query = this.supabase
|
): Promise<boolean> {
|
||||||
|
let query = this.supabase
|
||||||
.from('pokedex_export_integrations')
|
.from('pokedex_export_integrations')
|
||||||
.update(patch)
|
.update(patch)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('userId', this.userId);
|
.eq('userId', this.userId);
|
||||||
const { error } = this.pokedexId
|
if (ifUpdatedAt) query = query.eq('updatedAt', ifUpdatedAt);
|
||||||
? await query.eq('pokedexId', this.pokedexId)
|
const scoped = this.pokedexId
|
||||||
: await query.is('pokedexId', null);
|
? query.eq('pokedexId', this.pokedexId)
|
||||||
|
: query.is('pokedexId', null);
|
||||||
|
const { data, error } = await scoped.select('id');
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to update export integration status:', error);
|
console.error('Failed to update export integration status:', error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
return (data?.length ?? 0) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
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 {
|
export function shouldRefreshToken(expiresAt: string | null): boolean {
|
||||||
if (!expiresAt) return false;
|
if (!expiresAt) return false;
|
||||||
const expiry = new Date(expiresAt).getTime();
|
const expiry = new Date(expiresAt).getTime();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getEnv } from '$lib/utils/env';
|
|||||||
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
import {
|
import {
|
||||||
buildCsv,
|
buildCsv,
|
||||||
|
isRevokedGrant,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
shouldRefreshToken
|
shouldRefreshToken
|
||||||
} from '$lib/services/PokedexExportFormatting';
|
} from '$lib/services/PokedexExportFormatting';
|
||||||
@@ -21,6 +22,7 @@ type ExportFailure = {
|
|||||||
integrationId: string;
|
integrationId: string;
|
||||||
provider: ExportProvider;
|
provider: ExportProvider;
|
||||||
error: string;
|
error: string;
|
||||||
|
reconnectRequired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PokedexExportResult = {
|
export type PokedexExportResult = {
|
||||||
@@ -29,12 +31,21 @@ export type PokedexExportResult = {
|
|||||||
failed: ExportFailure[];
|
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(
|
async function refreshGoogleToken(
|
||||||
integration: PokedexExportIntegration,
|
integration: PokedexExportIntegration,
|
||||||
repo: PokedexExportIntegrationRepository
|
repo: PokedexExportIntegrationRepository
|
||||||
): Promise<PokedexExportIntegration> {
|
): Promise<PokedexExportIntegration> {
|
||||||
if (!integration.refreshToken) {
|
if (!integration.refreshToken) {
|
||||||
throw new Error('Missing Google refresh token');
|
throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive);
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
@@ -59,6 +70,9 @@ async function refreshGoogleToken(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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}`);
|
throw new Error(`Google token refresh failed: ${response.status} ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +102,7 @@ async function refreshDropboxToken(
|
|||||||
repo: PokedexExportIntegrationRepository
|
repo: PokedexExportIntegrationRepository
|
||||||
): Promise<PokedexExportIntegration> {
|
): Promise<PokedexExportIntegration> {
|
||||||
if (!integration.refreshToken) {
|
if (!integration.refreshToken) {
|
||||||
throw new Error('Missing Dropbox refresh token');
|
throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
@@ -113,6 +127,9 @@ async function refreshDropboxToken(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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}`);
|
throw new Error(`Dropbox token refresh failed: ${response.status} ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,13 +435,24 @@ export async function exportPokedexIfConfigured(
|
|||||||
successes++;
|
successes++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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({
|
failures.push({
|
||||||
integrationId: integration._id,
|
integrationId: integration._id,
|
||||||
provider: integration.provider,
|
provider: integration.provider,
|
||||||
error: message
|
error: message,
|
||||||
});
|
reconnectRequired
|
||||||
await scopedRepo.updateExportStatus(integration._id, {
|
|
||||||
lastError: message
|
|
||||||
});
|
});
|
||||||
console.error('Failed to export pokedex:', integration.provider, message);
|
console.error('Failed to export pokedex:', integration.provider, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
}
|
||||||
+45
-43
@@ -1,19 +1,21 @@
|
|||||||
<script lang="ts">
|
<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 { onDestroy, onMount } from 'svelte';
|
||||||
import { user } from '$lib/stores/user.js';
|
import { user } from '$lib/stores/user.js';
|
||||||
import { type User } from '@supabase/auth-js';
|
import { type User } from '@supabase/auth-js';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import SignOut from '$lib/components/SignOut.svelte';
|
import SignOut from '$lib/components/SignOut.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { claimOfflineData, requestOfflineSync, startOfflineSync } from '$lib/stores/offlineSync';
|
||||||
import {
|
import {
|
||||||
artworkDownloadStatus,
|
PROVIDER_LABELS,
|
||||||
claimOfflineData,
|
backupsNeedingReconnect,
|
||||||
downloadAllArtwork,
|
clearBackupStatus,
|
||||||
offlineSyncStatus,
|
refreshBackupStatus
|
||||||
requestOfflineSync,
|
} from '$lib/stores/backupStatus';
|
||||||
startOfflineSync
|
|
||||||
} from '$lib/stores/offlineSync';
|
|
||||||
|
|
||||||
import { pwaInfo } from 'virtual:pwa-info';
|
import { pwaInfo } from 'virtual:pwa-info';
|
||||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||||
@@ -57,7 +59,10 @@
|
|||||||
updateOnlineState();
|
updateOnlineState();
|
||||||
void getUser()
|
void getUser()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
if (localUser) await claimOfflineData(localUser.id);
|
if (localUser) {
|
||||||
|
void refreshBackupStatus();
|
||||||
|
await claimOfflineData(localUser.id);
|
||||||
|
}
|
||||||
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
||||||
})
|
})
|
||||||
.catch((error) => console.error('Unable to claim offline data', error));
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
@@ -75,12 +80,14 @@
|
|||||||
void claimOfflineData(session.user.id)
|
void claimOfflineData(session.user.id)
|
||||||
.then(requestOfflineSync)
|
.then(requestOfflineSync)
|
||||||
.catch((error) => console.error('Unable to claim offline data', error));
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
|
void refreshBackupStatus();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Offline data is only cleared by the Sign Out button (or another account claiming it).
|
// 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
|
// An expired or rejected session must not throw away artwork that would then have to be
|
||||||
// downloaded again after signing back in.
|
// downloaded again after signing back in.
|
||||||
localUser = null;
|
localUser = null;
|
||||||
|
clearBackupStatus();
|
||||||
}
|
}
|
||||||
user.set(localUser);
|
user.set(localUser);
|
||||||
});
|
});
|
||||||
@@ -98,10 +105,7 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatMegabytes(bytes: number) {
|
$: reconnectLabels = $backupsNeedingReconnect.map((provider) => PROVIDER_LABELS[provider]);
|
||||||
const megabytes = bytes / 1048576;
|
|
||||||
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUser() {
|
async function getUser() {
|
||||||
const {
|
const {
|
||||||
@@ -178,7 +182,7 @@
|
|||||||
{#if localUser}
|
{#if localUser}
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
||||||
<div class="w-10 rounded-full">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
@@ -192,6 +196,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/backup-settings"> Backup Settings </a>
|
<a href="/backup-settings"> Backup Settings </a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/offline-guide"> Using Offline </a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<SignOut
|
<SignOut
|
||||||
{supabase}
|
{supabase}
|
||||||
@@ -222,34 +229,18 @@
|
|||||||
<div class="alert rounded-none" role="status">
|
<div class="alert rounded-none" role="status">
|
||||||
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
||||||
</div>
|
</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
|
|
||||||
>
|
|
||||||
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
{#if localUser && reconnectLabels.length > 0 && $page.url.pathname !== '/backup-settings'}
|
||||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">Updating offline copy…</p>
|
<div
|
||||||
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
class="alert alert-warning rounded-none"
|
||||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
role="alert"
|
||||||
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
data-testid="backup-reconnect-banner"
|
||||||
{#if $artworkDownloadStatus.state === 'downloading'}
|
>
|
||||||
Saving all artwork for offline…
|
<span>
|
||||||
{:else if $artworkDownloadStatus.state === 'missing'}
|
Your {reconnectLabels.join(' and ')} backup has stopped because access expired or was revoked.
|
||||||
<button class="link" on:click={downloadAllArtwork}>
|
</span>
|
||||||
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
|
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
|
||||||
$artworkDownloadStatus.missingBytes
|
</div>
|
||||||
)}){/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main class="flex-grow">
|
<main class="flex-grow">
|
||||||
@@ -258,8 +249,14 @@
|
|||||||
|
|
||||||
<footer class="footer items-center p-4 bg-neutral text-neutral-content bottom-0">
|
<footer class="footer items-center p-4 bg-neutral text-neutral-content bottom-0">
|
||||||
<aside class="items-center grid-flow-col">
|
<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
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
width="36"
|
width="36"
|
||||||
height="36"
|
height="36"
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
@@ -280,8 +277,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
|
<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
|
><svg
|
||||||
|
aria-hidden="true"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export type PublicStats = {
|
||||||
|
pokemonCaught: number;
|
||||||
|
users: number;
|
||||||
|
livingDexesCompleted: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side load function for the homepage
|
* Server-side load function for the homepage
|
||||||
*
|
*
|
||||||
* Fetches public statistics from the database and passes them to the page component.
|
* Signed-in users go straight to their Pokédexes. For everyone else the page renders at once and
|
||||||
* Stats are cached for 24 hours to improve performance.
|
* the public statistics stream in afterwards, so a slow stats query never delays the first paint.
|
||||||
*/
|
*/
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
// Fetch stats from database
|
const { user } = await locals.safeGetSession();
|
||||||
const statsResponse = await fetch('/api/stats');
|
if (user) {
|
||||||
const statsData = await statsResponse.json();
|
throw redirect(303, '/my-pokedexes');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const stats: Promise<PublicStats | null> = fetch('/api/stats')
|
||||||
stats: statsData.error ? null : statsData
|
.then((response) => response.json())
|
||||||
};
|
.then((statsData) => (statsData.error ? null : statsData))
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
return { stats };
|
||||||
};
|
};
|
||||||
|
|||||||
+302
-338
@@ -1,35 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import SignUp from '$lib/components/SignUp.svelte';
|
import SignUp from '$lib/components/SignUp.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
// Signed-in visitors are redirected by the server load, so the page renders straight away.
|
||||||
export let data;
|
export let data;
|
||||||
let { supabase, stats } = data;
|
let { supabase, stats } = data;
|
||||||
$: ({ 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() {
|
async function handleSignedUp() {
|
||||||
await goto('/welcome');
|
await goto('/welcome');
|
||||||
}
|
}
|
||||||
@@ -44,16 +21,6 @@
|
|||||||
}
|
}
|
||||||
return num.toString();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -72,322 +39,319 @@
|
|||||||
/>
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if isCheckingSession}
|
<!-- Hero Section -->
|
||||||
<!-- Loading placeholder while checking session -->
|
<div class="hero bg-base-100 my-36">
|
||||||
<div class="hero bg-base-100 my-36">
|
<div class="hero-content flex-col lg:flex-row-reverse">
|
||||||
<div class="hero-content flex-col">
|
<div class="text-center lg:text-left">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<div class="badge badge-primary badge-lg gap-1">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Free
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-secondary badge-lg gap-1">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Open Source
|
||||||
|
</div>
|
||||||
|
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Offline-friendly
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
|
||||||
|
<p class="text-xl mb-6 text-base-content/80">
|
||||||
|
Track your progress towards a complete Living Pokédex — one of every Pokémon, actively
|
||||||
|
maintained across your boxes. Join thousands of trainers worldwide.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/2ytj4pkUPY"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
aria-label="Join the Living Dex Tracker Discord community"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Join Discord
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/jcreek/LivingDexTracker"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-outline btn-lg"
|
||||||
|
aria-label="View the Living Dex Tracker project on GitHub"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Contribute on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl mb-4">Get Started Free</h2>
|
||||||
|
<SignUp {supabase} on:signedUp={handleSignedUp} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
<!-- Hero Section -->
|
|
||||||
<div class="hero bg-base-100 my-36">
|
<!-- Stats Section -->
|
||||||
<div class="hero-content flex-col lg:flex-row-reverse">
|
<div class="bg-base-200 py-16">
|
||||||
<div class="text-center lg:text-left">
|
<div class="container mx-auto px-4">
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
|
||||||
<div class="badge badge-primary badge-lg gap-1">
|
<div class="stat">
|
||||||
<svg
|
<div class="stat-title">Pokémon caught</div>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div class="stat-value text-primary">
|
||||||
class="h-4 w-4"
|
{#await stats}–{:then value}{formatNumber(value?.pokemonCaught ?? 0)}{/await}
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Free
|
|
||||||
</div>
|
|
||||||
<div class="badge badge-secondary badge-lg gap-1">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Open Source
|
|
||||||
</div>
|
|
||||||
<div class="badge badge-accent badge-lg gap-1">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Offline-friendly
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
|
|
||||||
<p class="text-xl mb-6 text-base-content/80">
|
|
||||||
Track your progress towards a complete Living Pokédex — one of every Pokémon, actively
|
|
||||||
maintained across your boxes. Join thousands of trainers worldwide.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-3 justify-center lg:justify-start">
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/2ytj4pkUPY"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="btn btn-primary btn-lg"
|
|
||||||
aria-label="Join the Living Dex Tracker Discord community"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Join Discord
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/jcreek/LivingDexTracker"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="btn btn-outline btn-lg"
|
|
||||||
aria-label="View the Living Dex Tracker project on GitHub"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Contribute on GitHub
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Users</div>
|
||||||
|
<div class="stat-value text-primary">
|
||||||
|
{#await stats}–{:then value}{formatNumber(value?.users ?? 0)}{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Living Dexes Completed</div>
|
||||||
|
<div class="stat-value text-primary">
|
||||||
|
{#await stats}–{:then value}{formatNumber(value?.livingDexesCompleted ?? 0)}{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- What is a Living Dex Section -->
|
||||||
|
<div class="py-16 bg-base-100">
|
||||||
|
<div class="container mx-auto px-4 max-w-4xl">
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-3xl mb-4">What is a Living Dex?</h2>
|
||||||
|
<p class="text-lg text-base-content/80">
|
||||||
|
A Living Dex is a complete Pokédex where you keep one of every Pokémon in your boxes
|
||||||
|
(often including forms/variants). Living Dex Tracker helps you build and maintain that
|
||||||
|
collection with filters, notes, and progress tracking.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<div class="py-16 bg-base-200">
|
||||||
|
<div class="container mx-auto px-4 max-w-6xl">
|
||||||
|
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div class="card bg-neutral shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
class="w-10 h-10 text-green-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-xl mb-2">Social & Shareable</h2>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go
|
||||||
|
it alone, that's okay too!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-neutral shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
class="w-10 h-10 text-green-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
Completely open source and free to use, enabling the community to contribute updates
|
||||||
|
as soon as new Pokémon are released.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-neutral shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
class="w-10 h-10 text-green-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-xl mb-2">Advanced Filtering</h2>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
Track simple progress or tackle harder variants like a Living Origin Form Dex with
|
||||||
|
our powerful filtering options for targeted catching sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-neutral shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
class="w-10 h-10 text-green-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-xl mb-2">100% free</h2>
|
||||||
|
<p class="text-base-content/80">
|
||||||
|
Did we mention it's completely free to use? Oh, we did? Good. Because it is.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<div class="py-16 bg-base-100">
|
||||||
|
<div class="container mx-auto px-4 text-center">
|
||||||
|
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
|
||||||
|
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
|
||||||
|
Get started today with tracking your Living Pokédex progress. It's free, open source, and
|
||||||
|
built with love for the Pokémon community.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-4 justify-center">
|
||||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
|
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-4">Get Started Free</h2>
|
<h3 class="card-title text-xl mb-4">Sign Up Now</h3>
|
||||||
<SignUp {supabase} on:signedUp={handleSignedUp} />
|
<SignUp {supabase} on:signedUp={handleSignedUp} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats Section -->
|
<!-- Legal Section -->
|
||||||
<div class="bg-base-200 py-16">
|
<div class="py-8 bg-base-200">
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4 max-w-4xl">
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="stat">
|
<div class="card-body">
|
||||||
<div class="stat-title">Pokémon caught</div>
|
<h2 class="card-title text-lg mb-2">Legal Disclaimer</h2>
|
||||||
<div class="stat-value text-primary">{pokemonCaught}</div>
|
<p class="text-sm text-base-content/70">
|
||||||
</div>
|
Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
|
||||||
<div class="stat">
|
characters, images, or other content featured on this website. This project is not
|
||||||
<div class="stat-title">Users</div>
|
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak, or
|
||||||
<div class="stat-value text-primary">{users}</div>
|
The Pokémon Company.
|
||||||
</div>
|
</p>
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">Living Dexes Completed</div>
|
|
||||||
<div class="stat-value text-primary">{livingDexesCompleted}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- What is a Living Dex Section -->
|
|
||||||
<div class="py-16 bg-base-100">
|
|
||||||
<div class="container mx-auto px-4 max-w-4xl">
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-3xl mb-4">What is a Living Dex?</h2>
|
|
||||||
<p class="text-lg text-base-content/80">
|
|
||||||
A Living Dex is a complete Pokédex where you keep one of every Pokémon in your boxes
|
|
||||||
(often including forms/variants). Living Dex Tracker helps you build and maintain that
|
|
||||||
collection with filters, notes, and progress tracking.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
|
||||||
<div class="py-16 bg-base-200">
|
|
||||||
<div class="container mx-auto px-4 max-w-6xl">
|
|
||||||
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
|
||||||
<div class="card bg-neutral shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
class="w-10 h-10 text-green-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="card-title text-xl mb-2">Social & Shareable</h2>
|
|
||||||
<p class="text-base-content/80">
|
|
||||||
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go
|
|
||||||
it alone, that's okay too!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-neutral shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
class="w-10 h-10 text-green-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
|
|
||||||
<p class="text-base-content/80">
|
|
||||||
Completely open source and free to use, enabling the community to contribute
|
|
||||||
updates as soon as new Pokémon are released.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-neutral shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
class="w-10 h-10 text-green-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="card-title text-xl mb-2">Advanced Filtering</h2>
|
|
||||||
<p class="text-base-content/80">
|
|
||||||
Track simple progress or tackle harder variants like a Living Origin Form Dex with
|
|
||||||
our powerful filtering options for targeted catching sessions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-neutral shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
class="w-10 h-10 text-green-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="card-title text-xl mb-2">100% free</h2>
|
|
||||||
<p class="text-base-content/80">
|
|
||||||
Did we mention it's completely free to use? Oh, we did? Good. Because it is.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CTA Section -->
|
|
||||||
<div class="py-16 bg-base-100">
|
|
||||||
<div class="container mx-auto px-4 text-center">
|
|
||||||
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
|
|
||||||
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
|
|
||||||
Get started today with tracking your Living Pokédex progress. It's free, open source, and
|
|
||||||
built with love for the Pokémon community.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-4 justify-center">
|
|
||||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-xl mb-4">Sign Up Now</h3>
|
|
||||||
<SignUp {supabase} on:signedUp={handleSignedUp} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Legal Section -->
|
|
||||||
<div class="py-8 bg-base-200">
|
|
||||||
<div class="container mx-auto px-4 max-w-4xl">
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-lg mb-2">Legal Disclaimer</h2>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
|
|
||||||
characters, images, or other content featured on this website. This project is not
|
|
||||||
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak,
|
|
||||||
or The Pokémon Company.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
accessToken: tokenData.access_token,
|
accessToken: tokenData.access_token,
|
||||||
refreshToken: tokenData.refresh_token ?? null,
|
refreshToken: tokenData.refresh_token ?? null,
|
||||||
accessTokenExpiresAt: expiresAt,
|
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) {
|
} catch (saveError) {
|
||||||
console.error('Dropbox integration save failed:', saveError);
|
console.error('Dropbox integration save failed:', saveError);
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
accessToken: tokenData.access_token,
|
accessToken: tokenData.access_token,
|
||||||
refreshToken: tokenData.refresh_token ?? null,
|
refreshToken: tokenData.refresh_token ?? null,
|
||||||
accessTokenExpiresAt: expiresAt,
|
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) {
|
} catch (saveError) {
|
||||||
console.error('Google Drive integration save failed:', saveError);
|
console.error('Google Drive integration save failed:', saveError);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
|
||||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
import { getOptionalUserId } from '$lib/utils/auth';
|
import { getOptionalUserId } from '$lib/utils/auth';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
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
|
// GET: Get combined data (pokédex entries + catch records) for specific pokédex
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
@@ -23,48 +22,29 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
const region = url.searchParams.get('region') || '';
|
const region = url.searchParams.get('region') || '';
|
||||||
const game = url.searchParams.get('game') || '';
|
const game = url.searchParams.get('game') || '';
|
||||||
|
|
||||||
// If authenticated, verify user owns this pokédex and get its gameScope
|
if (!userId) {
|
||||||
let pokedex;
|
|
||||||
if (userId) {
|
|
||||||
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
|
|
||||||
pokedex = await pokedexRepo.findById(pokedexId);
|
|
||||||
|
|
||||||
if (!pokedex) {
|
|
||||||
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
|
|
||||||
return json({ error: 'Pokedex not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Anonymous users cannot view pokédexes
|
// Anonymous users cannot view pokédexes
|
||||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use pokédex's gameScope as default filter if no manual game filter is set
|
// Verify the user owns this pokédex and get its gameScope
|
||||||
const effectiveGame = game || pokedex.gameScope || '';
|
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
|
||||||
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
|
const pokedex = await pokedexRepo.findById(pokedexId);
|
||||||
|
|
||||||
const repo = new CombinedDataRepository(event.locals.supabase, userId, pokedexId);
|
if (!pokedex) {
|
||||||
|
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
|
||||||
|
return json({ error: 'Pokedex not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
// Get paginated combined data
|
return json(
|
||||||
const combinedData = await repo.findCombinedData(
|
await loadCombinedDataPage(event.locals.supabase, userId, pokedex, {
|
||||||
userId!,
|
page,
|
||||||
page,
|
limit,
|
||||||
limit,
|
enableForms,
|
||||||
enableForms,
|
region,
|
||||||
region,
|
game
|
||||||
effectiveGame,
|
})
|
||||||
dexScopes
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
if (err && typeof err === 'object' && 'status' in err) {
|
if (err && typeof err === 'object' && 'status' in err) {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stats = data[0];
|
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({
|
return json({
|
||||||
pokemonCaught: stats.pokemon_caught,
|
pokemonCaught: stats.pokemon_caught,
|
||||||
users: stats.total_users,
|
users: stats.total_users,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { setBackupStatus } from '$lib/stores/backupStatus';
|
||||||
|
|
||||||
type ExportIntegrationSummary = {
|
type ExportIntegrationSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,6 +41,8 @@
|
|||||||
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
|
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
|
||||||
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
|
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
|
||||||
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
exportError = message || 'Failed to load export settings';
|
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 {
|
function getGoogleFolderUrl(folderId: string): string {
|
||||||
return `https://drive.google.com/drive/folders/${folderId}`;
|
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="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-semibold">Google Drive</h2>
|
<h2 class="font-semibold">Google Drive</h2>
|
||||||
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
<span class={`badge ${googleBadge.className}`}>{googleBadge.label}</span>
|
||||||
{googleIntegration ? 'Connected' : 'Not Connected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
<div class="flex items-center justify-end gap-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'}
|
{googleIntegration ? 'Reconnect' : 'Connect'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,13 +156,14 @@
|
|||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-semibold">Dropbox</h2>
|
<h2 class="font-semibold">Dropbox</h2>
|
||||||
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
<span class={`badge ${dropboxBadge.className}`}>{dropboxBadge.label}</span>
|
||||||
{dropboxIntegration ? 'Connected' : 'Not Connected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
<div class="flex items-center justify-end gap-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'}
|
{dropboxIntegration ? 'Reconnect' : 'Connect'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
|
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
|
||||||
import type { PageServerLoad } from './$types';
|
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 }) => {
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
const { safeGetSession, supabase } = locals;
|
const { safeGetSession, supabase } = locals;
|
||||||
const { session, user } = await safeGetSession();
|
const { session, user } = await safeGetSession();
|
||||||
@@ -22,7 +26,22 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
throw error(404, 'Pokédex not found');
|
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 {
|
return {
|
||||||
pokedex
|
pokedex,
|
||||||
|
initialCombinedData
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
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';
|
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
@@ -66,6 +74,8 @@
|
|||||||
lastSuccessfulFlushAt: null
|
lastSuccessfulFlushAt: null
|
||||||
};
|
};
|
||||||
let lastOfflineSyncFlush: number | null = 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 exportAfterFlush = false;
|
||||||
let exportInFlight = false;
|
let exportInFlight = false;
|
||||||
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -110,6 +120,24 @@
|
|||||||
console.error('Auto-export failed:', response.status, body);
|
console.error('Auto-export failed:', response.status, body);
|
||||||
return;
|
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) {
|
if (exportGeneration === exportInFlightGeneration) {
|
||||||
exportAfterFlush = false;
|
exportAfterFlush = false;
|
||||||
}
|
}
|
||||||
@@ -474,8 +502,35 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch data whenever pagination controls change (client-side only)
|
// Show data whenever the dex or pagination changes (client-side only). The first page is streamed
|
||||||
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
|
// 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(() => {
|
onMount(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -514,6 +569,24 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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>
|
<svelte:head>
|
||||||
<title>{pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'}</title>
|
<title>{pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
@@ -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="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">
|
<div class="max-w-6xl mx-auto">
|
||||||
<!-- Success Badge -->
|
<!-- Success Badge -->
|
||||||
{#if showSuccess}
|
<!-- Always rendered (faded in) so the hero doesn't jump down when the badge appears. -->
|
||||||
<div class="flex justify-center mb-8 animate-fade-in">
|
<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">
|
<div class="badge badge-success badge-lg gap-2 p-6 shadow-lg">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -42,7 +46,7 @@
|
|||||||
<span class="text-lg font-semibold">Account Created Successfully!</span>
|
<span class="text-lg font-semibold">Account Created Successfully!</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
<!-- Hero Content -->
|
<!-- Hero Content -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
@@ -306,6 +310,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h4 class="font-semibold">Works Offline</h4>
|
<h4 class="font-semibold">Works Offline</h4>
|
||||||
<p class="text-sm opacity-70">Track your catches even without an internet connection</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,21 +319,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
@keyframes pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@@ -345,7 +335,6 @@
|
|||||||
|
|
||||||
/* Respect user's motion preferences */
|
/* Respect user's motion preferences */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-fade-in,
|
|
||||||
.animate-pulse {
|
.animate-pulse {
|
||||||
animation: none;
|
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
@@ -1,9 +1,9 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
'./src/routes/**/*.{svelte,js,ts}',
|
'./src/**/*.{html,svelte,js,ts}',
|
||||||
'./src/routes/**/**/*.{svelte,js,ts}',
|
'./static/offline.html',
|
||||||
'./src/lib/components/**/*.{svelte,js,ts}'
|
'./static/offline-viewer.js'
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {}
|
extend: {}
|
||||||
@@ -16,11 +16,16 @@ export default {
|
|||||||
'dracula',
|
'dracula',
|
||||||
{
|
{
|
||||||
pokeball: {
|
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',
|
'primary-content': '#ffffff',
|
||||||
secondary: '#ffd700',
|
secondary: '#ffd700',
|
||||||
'secondary-content': '#ffffff',
|
'secondary-content': '#ffffff',
|
||||||
accent: '#3b82c4',
|
accent: '#3b82c4',
|
||||||
|
// daisyUI's default info blue is too light for text on the base colours.
|
||||||
|
info: '#0369a1',
|
||||||
|
'info-content': '#ffffff',
|
||||||
neutral: '#ffffff',
|
neutral: '#ffffff',
|
||||||
'base-100': '#f0f0f0',
|
'base-100': '#f0f0f0',
|
||||||
'base-content': '#222224'
|
'base-content': '#222224'
|
||||||
|
|||||||
@@ -40,3 +40,29 @@ Feature: Backup and export
|
|||||||
When I update collection progress
|
When I update collection progress
|
||||||
Then the catch remains marked caught
|
Then the catch remains marked caught
|
||||||
And the provider failure is shown in backup settings
|
And the provider failure is shown in backup settings
|
||||||
|
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 |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -26,6 +26,20 @@ Feature: Offline-friendly application
|
|||||||
When I go offline and reload the current Pokédex
|
When I go offline and reload the current Pokédex
|
||||||
Then the offline copy contains "Offline Collection"
|
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
|
Scenario: Restore network access
|
||||||
Given I have opened the built application online
|
Given I have opened the built application online
|
||||||
When I go offline and then return online
|
When I go offline and then return online
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ When('I visit the public home page', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
When('I sign out', 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();
|
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"}'
|
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();
|
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ const SUPABASE_URL = requireLoopbackUrl(
|
|||||||
|
|
||||||
type Provider = 'google_drive' | 'dropbox';
|
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(
|
async function seedIntegration(
|
||||||
state: import('../fixtures').ScenarioState,
|
state: import('../fixtures').ScenarioState,
|
||||||
provider: Provider,
|
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 }) => {
|
Given('Google Drive is connected to a failing mocked provider', async ({ page, state }) => {
|
||||||
await seedIntegration(state, 'google_drive');
|
await seedIntegration(state, 'google_drive');
|
||||||
await fetch(`${MOCK_URL}/__mock/fail-uploads`);
|
await mockControl('fail-uploads');
|
||||||
await ensureExportDex(page, state);
|
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 }) => {
|
When('I visit backup settings', async ({ page }) => {
|
||||||
await page.goto('/backup-settings');
|
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 page.goto('/backup-settings');
|
||||||
await expect(page.getByText(/mock upload failure/)).toBeVisible();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 }) => {
|
When('I go offline and then return online', async ({ page }) => {
|
||||||
await page.context().setOffline(true);
|
await page.context().setOffline(true);
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
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();
|
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 }) => {
|
Then('the read-only offline viewer is available', async ({ page }) => {
|
||||||
await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
|
await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { generateSW } from '../../pwa.mjs';
|
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();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
buildCsv,
|
buildCsv,
|
||||||
csvEscape,
|
csvEscape,
|
||||||
|
isRevokedGrant,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
shouldRefreshToken
|
shouldRefreshToken
|
||||||
} from '$lib/services/PokedexExportFormatting';
|
} 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', () => {
|
it('refreshes only finite expiries within the next minute', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-09-13T12:00:00Z'));
|
vi.setSystemTime(new Date('2026-09-13T12:00:00Z'));
|
||||||
|
|||||||
+4
-4
@@ -22,10 +22,10 @@ export default defineConfig({
|
|||||||
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
|
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
|
||||||
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
|
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 34.58,
|
statements: 59.73,
|
||||||
functions: 73.68,
|
functions: 85.88,
|
||||||
lines: 34.58,
|
lines: 59.73,
|
||||||
branches: 79.79
|
branches: 88.35
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user