Compare commits

...

45 Commits

Author SHA1 Message Date
Josh Creek 669d0cdeaa Merge pull request #99 from jcreek/perf/pokedex-grid-transport
Perf/pokedex grid transport
2026-09-15 18:39:28 +01:00
Josh Creek 7373bf005d docs(performance): record the pokédex investigation and hosting evaluation
Keeps the measurements, the implementation notes and the hosting comparison next
to the code they justify.
2026-09-15 17:49:04 +01:00
Josh Creek 3ee69f0d04 refactor(api): drop the redundant setSession in export-integrations
requireAuth has already resolved the session on locals.supabase.
2026-09-15 17:49:04 +01:00
Josh Creek 0c1de9a93d fix(a11y): trap focus inside the pokédex modal
Tab moved to the page behind the open dialog and closing it left focus nowhere.
Keep Tab within the dialog, move focus to the close button on open, restore it
to the trigger on close, and give the dialog an accessible name.
2026-09-15 17:49:04 +01:00
Josh Creek 03b74089a0 feat(offline): open entry details from the saved snapshot
Opening an entry while offline needed a request that could not be made. Read it
from the snapshot claimed by the signed-in account instead, re-checking
ownership after the read so a sign-in mid-read cannot surface another account's
data, and let controls marked data-offline-action stay usable in read-only mode.
2026-09-15 17:48:57 +01:00
Josh Creek 56b676b04a perf(startup): defer offline sync and backup status until the grid is interactive
Both fired during hydration and competed with rendering the grid, and the layout
waited for the user store before it could show a signed-in shell even though the
server already knew who the user was.

Seed the user from the layout data, and queue the offline sync and backup
refresh behind the grid's interactive mark; an explicit refresh, a reconnect or
an edit still runs straight away. Backup refreshes now share one in-flight
request per generation, and a change of account cancels the pending startup
refresh and clears the stale status.
2026-09-15 17:48:49 +01:00
Josh Creek 19abb34693 fix(sync): keep queued catch-record writes from crossing an account change
The queue held whole records and flushed whatever was in it, so edits made
before a sign-out could be written against the account that signed in next.

Queue patches and merge them per key, so two edits to one entry combine instead
of one replacing the other; drop everything when the owning account is no longer
the current one; and stop scheduling flushes while offline, where they could only
fail and back off.
2026-09-15 17:48:34 +01:00
Josh Creek 54eaa6d776 fix(catch-records): stop bulk upserts clobbering fields a toggle never touched
Toggling one checkbox sent the whole record, so a stale copy of the other fields
could overwrite newer values. Send only the fields that changed, group rows by
the columns they carry, and upsert each group with defaultToNull: false so
omitted columns keep their stored value. Results are returned in the caller's
order rather than whatever order the groups came back in.
2026-09-15 17:48:34 +01:00
Josh Creek 2042e5a65a test(performance): add a local pokédex benchmark and run it in CI
Measures the pokédex load against a local Supabase with a generated fixture, and
compares runs so a regression shows up as a number rather than a hunch. Wired
into test:ci and given its own job so the artifacts survive a failure.
2026-09-15 17:47:21 +01:00
Josh Creek 2766e5ea67 build(cloudflare): add a Cloudflare preview build target
DEPLOY_TARGET now selects the adapter; Netlify stays the default and NODE_ADAPTER
keeps working. Cloudflare compresses in transit, so the Worker build aliases
$lib/server/compression to a pass-through that leaves the streaming body alone.

CI builds and dry-run deploys this target. That is compilation and packaging
coverage only, not a production compatibility gate: native sharp still blocks the
share-preview route in the Worker runtime.
2026-09-15 17:47:14 +01:00
Josh Creek b2b750115f perf(sprites): serve grid-sized thumbnails from an immutable URL space
Every grid cell downloaded the full detail sprite. Generate a smaller
/sprites-grid/v1/ set at build time and let the grid ask for those first,
falling back through the existing detail URLs when a thumbnail is missing so
detail resolution is unchanged. The new prefix is versioned, so it can be cached
forever, and the service worker recognises it alongside the other sprite roots.

The placeholder is now an empty box rather than a spinner: a thousand spinners
cost layout work and announced nothing useful.
2026-09-15 17:46:49 +01:00
Josh Creek 26b9e3b8c1 perf(pokedex): fetch dex scopes with the pokédex row
findById made a second round trip for scopes on every page load; embed
pokedex_dex_scopes in the select instead.
2026-09-15 17:46:23 +01:00
Josh Creek acaf760b36 perf(pokedex): send the box grid as packed rows with the page
The server load fetched a full combined-data page at a 9999 item page size,
carrying detail text and ownership fields the grid never renders, and the client
re-fetched the same payload after hydration.

Load a trimmed grid row instead and pack it as positional tuples so field names
are not repeated for every one of a thousand-plus entries. Entry detail is
fetched on demand from the new per-entry endpoint when a cell is opened, and the
grid marks itself interactive so other page-start work can queue behind it.
2026-09-15 17:46:19 +01:00
Josh Creek 4c268ba15c feat(pokedex): add opt-in Server-Timing for the pokédex load
POKEDEX_PERFORMANCE=true reports auth, ownership, scopes, entries and catches
with fixed labels only: no IDs, query strings, cookies or entry content ever
reach the header. Auth is measured in the hook, where the session is actually
resolved, and handed to the page load through locals.
2026-09-15 17:46:07 +01:00
Josh Creek 31fd959350 Merge pull request #98 from jcreek/perf/page-load-and-lighthouse
perf: fix slow page loads and gate performance in CI
2026-09-14 21:41:59 +01:00
Josh Creek 7603116369 ci(lighthouse): stop lhci reading the form factor as a CLI flag
lhci treats every LHCI_* environment variable as a command-line option, so
LHCI_PRESET=mobile reached `lhci assert` as an invalid --preset and failed
both jobs after the audits had run. Use LIGHTHOUSE_FORM_FACTOR instead.

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

- The Google Drive and Dropbox OAuth callbacks clear lastError when a
  provider is reconnected.
- An invalid_grant, or a missing refresh token, now pauses the
  integration with a readable "reconnect" message instead of retrying
  it on every catch update. Other failures still retry as before.
- A banner on every page and an alert on the Pokédex page point to
  Backup Settings, which shows a "Reconnect needed" badge. The Pokédex
  page re-checks backup status after each export, because saving a
  catch record also exports on the server and may pause a provider
  first.
- Offline sync status and the "Save all artwork" link move from every
  page to a new /offline-guide page, linked from the user menu and the
  home and welcome pages. Only the offline read-only banner stays
  sitewide.
- Unit tests cover every export path and the backup status store. BDD
  covers revocation and reconnecting for both providers, and the
  offline guide. The mock provider can now reject token refreshes, and
  mock control calls fail loudly if a stale mock is reused. Coverage
  thresholds are raised to the new baseline.
2026-09-14 18:46:09 +01:00
Josh Creek 5c25a763c0 Merge pull request #96 from jcreek/fix/offline-data-usage
Fix/offline data usage
2026-09-14 17:10:35 +01:00
Josh Creek 9ddca18f54 revert(sprites): keep the original 512px sprites
The resize to 192px isn't worthwhile now that sprites are only downloaded
when viewed or saved on request, and then kept forever. Restore the
original files and the script's no-resize default, drop the detail view
size cap, and regenerate the sprite manifest with the original sizes.
2026-09-14 16:53:31 +01:00
Josh Creek 030571fd14 perf(offline): stop re-downloading artwork and the offline snapshot
- Sprites are cached as they are viewed instead of bulk-downloaded after
  every sign-in, in one shared cache that is never pruned and survives
  sign-out and account changes, since sprites never change.
- A "Save all artwork for offline" link saves every remaining sprite on
  request, shows the remaining size, and is hidden once all are saved.
- Page loads reuse an offline snapshot under 15 minutes old; edits,
  retries and a new sign-in still sync immediately.
- An expired session no longer wipes offline data; only the Sign Out
  button does.
2026-09-14 16:19:22 +01:00
Josh Creek 721c44dcd0 perf(sprites): ship 192px sprites and a manifest of every sprite
Sprites were shipped at 512x512 but render at 44-64px in the box grid.
Resizing to 192px halves each file (median 15.5KB -> 7.6KB); a full dex
of artwork drops from 16.6MB to 7.6MB.

The sprite build now also writes static/sprites-small/manifest.json,
listing every sprite (all forms, shiny and female variants) with its
size, so the offline worker can save the complete set and report what
is missing. A unit test keeps it in step with the files on disk.
2026-09-14 16:19:22 +01:00
Josh Creek b7d2db4959 fix(auth): stop stale session cookies signing users out on refresh
@supabase/ssr 0.1.0 never removed the old unchunked session cookie once a
refreshed session grew past one cookie, and always read that stale copy
first. Every load then retried an already-used refresh token, which the
hosted auth server rejects, signing the user out.

Upgrade @supabase/ssr to 0.12 (and supabase-js to match) and move to the
getAll/setAll cookie API, which clears stale chunks when writing.
2026-09-14 16:19:20 +01:00
Josh Creek f382176804 Merge pull request #95 from jcreek/feat/shareable-pokedex 2026-09-14 15:29:59 +01:00
Josh Creek 3f8daea8d2 ci: serve local sprites in the bdd job
Stops each BDD run downloading every Living Dex sprite from GitHub.
2026-09-14 15:22:05 +01:00
Josh Creek c3a3d43883 fix(offline): cache sprite artwork once instead of on every sync
Each sync created a new artwork cache and re-fetched every sprite with no-cors. Opaque responses are padded to several MB each for storage quota, so a Living Dex grew to tens of GB, syncs never finished, and the offline copy could fail to save.

Artwork now lives in one cache per user that is topped up with only missing sprites, fetched with CORS so they count at their real size, with a per-fetch timeout. The offline copy is committed before any artwork, legacy per-sync artwork caches are removed first, and sprites shown online are cached on first load. Also requests persistent storage.
2026-09-14 15:22:05 +01:00
Josh Creek f2d8451c34 chore(deps): match Node engine range to sharp
sharp is now a runtime dependency and requires ^18.17.0 || ^20.3.0 || >=21.0.0.
2026-09-14 14:47:27 +01:00
Josh Creek ca7f9c0e48 test(pokedex): cover shared dex loading and preview badges
Adds loadSharedPokedex unit tests and a share preview case for every badge, restoring the branch coverage gate. Gives the offline sync BDD poll 30s so a full Living Dex snapshot can finish caching on CI.
2026-09-14 14:47:27 +01:00
Josh Creek 27274171fe test(pokedex): cover shared dex privacy 2026-09-14 14:25:22 +01:00
Josh Creek 7240376e00 feat(pokedex): add shareable read-only dex links 2026-09-14 14:25:09 +01:00
Josh Creek 951c9b2878 Merge pull request #94 from jcreek/chore/test-suite-hardening
Chore/test suite hardening
2026-09-14 13:50:01 +01:00
Josh Creek 2c5fc0d459 ci: run workflow jobs on Node 24 2026-09-14 13:46:58 +01:00
Josh Creek 4b2f076e50 ci: upgrade GitHub Actions Node runtimes 2026-09-14 13:46:21 +01:00
Josh Creek 3ea194f87c fix: harden review findings and Netlify install 2026-09-14 12:56:09 +01:00
Josh Creek 86f1c21e4d fix: remediate branch review findings 2026-09-14 11:47:58 +01:00
Josh Creek 4af33709a3 test: make the suite's assertions falsifiable and its state isolated
Several assertions could not fail:

- "the catch update remains saved" was `caught.isChecked() || notes.includes(...)`
  shared by two scenarios, so either half satisfied both. Split into two steps
  that each assert the outcome their own scenario is about.
- The token-refresh check read a global counter with `> 0` and asserted an upload
  had happened `some(...)`, both already satisfied by the preceding scenario. It
  now asserts exactly one refresh, ordered before the upload.
- The box step ignored its box argument and asserted on the first N entries on
  the page; it now scopes to that box and checks its full contents.
- The filter step asserted on whichever entry was first after filtering; it now
  records the caught entry beforehand and names it, and checks the filter did not
  exclude everything.
- The empty-state precondition asserted emptiness instead of establishing it,
  which a fresh user satisfies for free.
- Offline coverage was `caches.keys().length > 0`. It now checks the precache
  contract: one workbox cache holding the shell and a revisioned web manifest,
  with _app/immutable assets cached without a revision query. The scenario that
  claimed to test a trailing slash did not; it is replaced with real offline
  client-side navigation.

The mock provider kept recorded requests, its refresh counter and the
fail-uploads switch in one process-wide object that only one step reset, so
scenario order was load-bearing and the failing-upload scenario poisoned
everything after it. An auto fixture now resets it per scenario, and the mock no
longer records its own control-plane calls. That reset is why the suite stays on
a single worker, which is now documented.

Coverage was gated at 90% per file over an allowlist of exactly the five files
that had tests, so new code was invisible to it permanently. It now measures all
of src/lib with global thresholds at the measured baseline, and no longer runs
the unit tests twice.

Also: a global teardown removes the users each run creates, the Supabase wrapper
distinguishes a stopped stack from a broken CLI call and detects an unseeded
database, the sign-in rate limit is raised above what one serial run needs, and
the integration suite no longer falls back to a hard-coded anon key that would
mask a misconfigured run.

The password-reset scenarios are renamed to what they actually cover: following a
real recovery link bounces to /signin, because the browser client persists no
cookies and so cannot keep the session it parses out of the URL. The helper for
the real flow is left in place and the gap is documented.
2026-09-13 17:38:42 +01:00
Josh Creek 3a2c18bbeb fix: repair defects surfaced by gating CI on lint and typecheck
`npm run check` reported 15 errors and `npm run lint` 20, all pre-existing, so
neither gate could pass. Fixing them turned up three real bugs:

- SignOut destructured `{ error }` off `.then(() => {})`, which resolves to
  undefined, so every sign-out threw a TypeError - after the signed-out event had
  already been emitted. Sign-out also left the user on the protected page they
  were on, still showing its content; it now returns them to the home page and
  re-runs the server loads.

- SignUp passed `redirectTo`, which is not a signUp option and was silently
  ignored, so the confirmation link has always used Supabase's configured site
  URL. Documented rather than changed, since pointing it elsewhere needs an
  absolute allow-listed URL.

- The Pokédex page tracked totalRecordsCreated but never passed it to the box
  view, so the "Processed N entries so far" progress message never rendered.

The rest is typing and dead code: cookie callback parameters in hooks.server.ts
and +layout.ts, the untyped supabase props, a query-builder type that made
PostgREST rows untyped downstream, an unused session destructure, and
`while (true)` paging loops rewritten as `for (;;)`.
2026-09-13 17:38:42 +01:00
Josh Creek 92d6460765 fix(export): restrict provider endpoint overrides to loopback test servers
The endpoint overrides are read through `$env/dynamic/private`, so they are
evaluated per request in production, not baked in at build time. That made a
single injected environment variable enough to redirect the authorization-code
and refresh-token POSTs - which carry the OAuth client secret and the user's
refresh token - to an arbitrary host, and to redirect the user's authorize hop
to an arbitrary URL.

Overrides are now ignored unless ALLOW_PROVIDER_ENDPOINT_OVERRIDES is exactly
"true" and the value is a loopback URL. `npm run test:bdd` sets the flag;
nothing else should. resolveProviderEndpoints is pure so the refusals are unit
tested, including near-miss hosts such as http://127.0.0.1.example.

Also drops the unused `pokedex` parameter from buildCsv rather than silencing it
with `void`, and the dead hasGigantamaxed field from its fallback record.
2026-09-13 17:38:42 +01:00
Josh Creek f9b7bbdf0a fix(pwa): honour the adapter flag and precache an offline entry point
Three defects that together meant `npm run test:build` could not pass in any of
its four variants:

- svelte.config.js constructed adapter-netlify inline and never used the
  `adapter` export from adapter.mjs, so NODE_ADAPTER=true still produced a flat
  build/ directory while the build test expects the node adapter's build/client
  layout. adapter.mjs now returns netlify (the deployment target) or node, and
  svelte.config.js consumes it.

- No route is prerendered, so workbox's glob found no HTML document and a
  generateSW build precached nothing navigable: the app had no offline support
  in that mode at all. Adds the root entry and a navigation fallback, matching
  what prompt-sw.ts already did by hand for injectManifest builds.

- The build scripts used by the tests skipped the tailwind step that `build`
  runs, so static/output.css was never generated on a clean checkout and the
  app under test had no stylesheet.

The offline entry point assertion now also accepts the unquoted object key that
prompt-sw.ts's own precache call survives minification as.
2026-09-13 17:38:42 +01:00
Josh Creek de39dc78ea test: replace ad-hoc tests with a layered suite and CI workflow
Splits testing into five layers so a failure points at the responsible one:

- tests/unit    isolated utility, repository and service tests
- tests/data    validates the tracked Pokémon, game, region and dex files
- tests/integration  schema, views, constraints, RLS and repositories
- tests/bdd     executable Gherkin for user-visible behaviour
- tests/build   service worker and manifest artifacts per build variant

Replaces the two Playwright specs in client-test/ and the two Vitest files in
test/. Adds a GitHub Actions workflow running the layers as separate jobs, a
mock OAuth provider server so the Drive and Dropbox scenarios never touch real
accounts, and a wrapper that reads the local Supabase keys from
`supabase status` rather than hard-coding them.

Extracts the pure formatting helpers out of PokedexExportService so they can be
unit tested, and makes the provider endpoints configurable so the mock server
can stand in for Google and Dropbox.
2026-09-13 17:38:41 +01:00
Josh Creek 08c5e3271c style: format tracked files and ignore generated data exports
`prettier --check .` failed on 22 files, so gating CI on `npm run lint` was
never going to pass. These changes are whitespace only.

Adds the two remaining generated data exports to .prettierignore so this class
of churn cannot recur, along with machine-local settings files.
2026-09-13 17:38:35 +01:00
Josh Creek 676490b800 style(data): reformat generated Pokédex data files
These two files are generated exports that prettier was not ignoring, so
`prettier --check .` failed on them. Reformatting is isolated here because the
diff is ~124k lines and would otherwise bury real changes.

The only semantic change in this commit is the apostrophe in Farfetch'd and
Sirfetch'd: U+0027 -> U+2019, matching the spelling the rest of the data
already used. Everything else is whitespace.
2026-09-13 17:34:35 +01:00
175 changed files with 21126 additions and 9716 deletions
+13
View File
@@ -7,3 +7,16 @@ GOOGLE_OAUTH_CLIENT_ID="your-google-client-id"
GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret"
DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id"
DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret"
# Endpoint overrides for deterministic local provider tests. They are ignored unless
# ALLOW_PROVIDER_ENDPOINT_OVERRIDES is exactly "true", the local BDD stack variables are present,
# and every URL is loopback. These endpoints receive OAuth secrets, so never set this in a deployed
# environment. `npm run test:bdd` supplies the complete test context.
ALLOW_PROVIDER_ENDPOINT_OVERRIDES=""
GOOGLE_OAUTH_AUTHORIZE_URL=""
GOOGLE_OAUTH_TOKEN_URL=""
GOOGLE_DRIVE_API_URL=""
GOOGLE_DRIVE_UPLOAD_URL=""
DROPBOX_OAUTH_AUTHORIZE_URL=""
DROPBOX_OAUTH_TOKEN_URL=""
DROPBOX_UPLOAD_URL=""
+2
View File
@@ -11,3 +11,5 @@ node_modules
pnpm-lock.yaml
package-lock.json
yarn.lock
.wrangler/
+187
View File
@@ -0,0 +1,187 @@
name: Tests
on:
pull_request:
push:
branches: [master]
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
# `$env/static/public` is resolved at build time, so every variable imported from it must be
# present for `vite build` and `svelte-check` to succeed on a clean checkout. These are the
# local-stack defaults already published in .env.local.example - never real credentials.
env:
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'false'
PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
PUBLIC_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
- name: Check committed whitespace
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
git diff --check "${{ github.event.pull_request.base.sha }}...HEAD"
else
git diff --check "HEAD^...HEAD"
fi
- run: npm run check
- run: npm run lint
- run: npm run test:fast
- name: Verify tests leave the checkout clean
run: test -z "$(git status --porcelain --untracked-files=all)"
- uses: actions/upload-artifact@v6
if: always()
with:
name: coverage
path: coverage/
if-no-files-found: ignore
integration:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
# `supabase start` applies migrations and seeds; no separate reset is needed.
- run: npx supabase start
- run: npm run test:integration
- if: always()
run: npx supabase stop
build:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:build
lighthouse:
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
form-factor: [mobile, desktop]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
# The homepage streams public stats from the database, so audit against a real stack.
- run: npx supabase start
- run: npm run test:lighthouse
# Not LHCI_*: lhci reads any LHCI_ variable as a CLI flag (LHCI_PRESET became `--preset`).
env:
LIGHTHOUSE_FORM_FACTOR: ${{ matrix.form-factor }}
- uses: actions/upload-artifact@v6
if: always()
with:
name: lighthouse-${{ matrix.form-factor }}
path: .lighthouseci/
# upload-artifact v4+ skips dot-directories unless told otherwise.
include-hidden-files: true
if-no-files-found: ignore
- if: always()
run: npx supabase stop
bdd:
runs-on: ubuntu-latest
timeout-minutes: 30
# Serve the committed sprites so offline sync doesn't download every Living Dex sprite from
# GitHub on each run.
env:
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
- uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-chromium-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- run: npx supabase start
- run: npm run test:bdd
- uses: actions/upload-artifact@v6
if: failure()
with:
name: playwright-failures
path: |
test-results/
playwright-report/
if-no-files-found: ignore
- if: always()
run: npx supabase stop
pokedex-performance:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx supabase start
- run: npm run test:performance
- uses: actions/upload-artifact@v6
if: always()
with:
name: pokedex-performance
path: test-results/performance/
if-no-files-found: ignore
- if: always()
run: npx supabase stop
cloudflare-preview-build:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
WRANGLER_SEND_METRICS: 'false'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build:cloudflare
- run: npm run check:cloudflare
# This is compilation/package coverage, not a production compatibility gate.
# Native sharp still blocks the share-preview route in the Worker runtime.
+8
View File
@@ -9,4 +9,12 @@ node_modules
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/static/output.css
.lighthouseci
.netlify
.features-gen
coverage
playwright-report
test-results
/static/sprites-grid/
.wrangler/
+15
View File
@@ -2,3 +2,18 @@
pnpm-lock.yaml
package-lock.json
yarn.lock
# Generated data exports. Nobody hand-edits these, and reformatting them buries real data
# changes under tens of thousands of lines of churn.
src/lib/helpers/pokeapi-pokemon.json
static/sprites/pokemon.json
src/lib/helpers/pokedex.json
src/lib/helpers/sprites.json
static/sprites-small/manifest.json
# Machine-local editor and tool settings.
**/*.local.json
# Generated grid artwork and local Worker runtime output.
static/sprites-grid/
.wrangler/
+80
View File
@@ -31,6 +31,86 @@ To create a production version:
npm run build
```
## Testing
The test suite is split by responsibility so a failure points to the correct layer:
- `tests/unit` contains fast, isolated tests for utilities, repositories, and services.
- `tests/data` validates the tracked Pokémon, game, region, dex, and sprite reference files.
- `tests/integration` checks the migrated Supabase schema, views, constraints, RLS, and repositories.
- `tests/bdd/features` is the executable Gherkin specification for user-visible behaviour. Step
definitions and browser fixtures live beside it under `tests/bdd`.
- `tests/build` verifies generated service-worker and manifest artifacts after each supported build,
and fails if the gzipped JS or CSS every page loads grows past its budget.
- `lighthouserc.cjs` audits the public pages with Lighthouse CI (`npm run test:lighthouse`, which
builds and serves the Node output). PRs fail if Performance, Accessibility, Best Practices or SEO
drops below 90, or if LCP, TBT, CLS, script, stylesheet or total transfer size exceeds its budget.
CI runs it with both the mobile and desktop profiles (`LIGHTHOUSE_FORM_FACTOR=desktop`).
- `tests/bdd/features/performance.feature` holds time budgets for signed-in pages Lighthouse can't
reach: opening a Pokédex and switching between it and the Pokédex list.
The budgets sit just above current measurements so regressions fail the PR. If a change genuinely
needs more, raise the budget in the same PR so the cost is reviewed.
Run the offline suites while developing. `test:fast` includes the coverage run, so there is no need
to run both:
```bash
npm run test:fast
```
Coverage is measured across all of `src/lib` (excluding type-only models and the browser-only
store/action modules) with global thresholds set to the current baseline, so new untested code lowers
the number instead of being invisible to the gate. Ratchet the thresholds in `vitest.config.mts` up as
coverage grows, never down.
Database and BDD tests require Docker and the local Supabase stack. The wrappers read the local keys
from `supabase status` at run time, so no keys are hard-coded in the test files; the local stack's
well-known demo keys do appear in `.env.local.example`, and no real credentials are committed:
```bash
npm run supabase:start
npm run supabase:reset
npm run test:integration
npm run test:bdd
```
`npm test` runs the complete CI-equivalent sequence and fails with setup instructions when Supabase is
not available. Individual layers are available as `test:unit`, `test:data`, `test:integration`,
`test:build`, and `test:bdd`.
Gherkin describes outcomes in domain language. Keep selectors, API calls, test-user provisioning, and
provider mocks in step definitions or support fixtures. `@product-review` marks a rule that should be
reviewed with product stakeholders, but does not skip it. Missing or ambiguous steps fail generation.
Google Drive and Dropbox scenarios use a local provider server (`scripts/mock-provider-server.mjs`)
and never contact real provider accounts. The endpoint overrides that point at it are refused unless
`ALLOW_PROVIDER_ENDPOINT_OVERRIDES=true`, the local test-stack/service-role variables are present,
and the override is a loopback URL. These endpoints receive the OAuth client secret and refresh
token, so they must not be redirectable in a deployed environment. `npm run test:bdd` supplies the
complete test context.
The mock's recorded requests, refresh counter and fail-uploads switch are reset before every scenario
by an auto fixture in `tests/bdd/fixtures.ts`. That reset is also why the suite runs with a single
worker: the mock is one shared process, so parallel scenarios would reset each other's state. A global
teardown deletes the users each run creates, so repeated local runs do not need a database reset.
Chromium is the only configured browser project. Playwright traces and screenshots are retained on
failure under `test-results`.
Password-reset scenarios follow recovery links generated by the local Supabase stack and verify both
the rejected old password and accepted replacement password. The application waits for Supabase to
confirm the recovery session before enabling the replacement form.
After sign-in, the application automatically stores a versioned, per-user read-only copy of every
Pokédex and its referenced artwork. Offline navigation opens a static viewer; all mutation and
authentication controls remain unavailable until connectivity returns. A successful sign-out removes
the user-specific snapshot and artwork caches from the device.
The current National Dex maximum is deliberately asserted as 1025. When adding a new generation,
update that expectation together with Pokémon data, the corresponding game/dex files, database seed,
and sprites. Data tests print the exact conflicting identities or broken references.
You can preview the production build with `npm run preview`.
## Sprites
+3
View File
@@ -0,0 +1,3 @@
/sprites-grid/v1/*
Cache-Control: public, max-age=31536000, immutable
+10 -5
View File
@@ -1,7 +1,12 @@
import process from 'node:process'
import process from 'node:process';
import AdapterNode from '@sveltejs/adapter-node';
import AdpaterStatic from '@sveltejs/adapter-static';
import AdapterNetlify from '@sveltejs/adapter-netlify';
export const nodeAdapter = process.env.NODE_ADAPTER === 'true'
export const adapter = nodeAdapter ? AdapterNode() : AdpaterStatic()
export const nodeAdapter =
process.env.NODE_ADAPTER === 'true' || process.env.DEPLOY_TARGET === 'node';
export const cloudflareAdapter = process.env.DEPLOY_TARGET === 'cloudflare';
export const adapter = nodeAdapter
? AdapterNode({ precompress: true })
: cloudflareAdapter
? (await import('@sveltejs/adapter-cloudflare')).default()
: AdapterNetlify({ edge: false, split: false });
-39
View File
@@ -1,39 +0,0 @@
import { test, expect } from '@playwright/test';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
test('Test offline and trailing slashes', async ({ browser }) => {
// test offline + trailing slashes routes
const context = await browser.newContext();
const offlinePage = await context.newPage();
await offlinePage.goto('/');
const offlineSwURL = await offlinePage.evaluate(async () => {
const registration = await Promise.race([
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
navigator.serviceWorker.ready,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000)
)
]);
// @ts-expect-error registration is of type unknown
return registration.active?.scriptURL;
});
const offlineSwName = 'sw.js';
expect(offlineSwURL).toBe(`http://localhost:4173/${offlineSwName}`);
await context.setOffline(true);
const aboutAnchor = offlinePage.getByRole('link', { name: 'About' });
expect(await aboutAnchor.getAttribute('href')).toBe('/about');
await aboutAnchor.click({ noWaitAfter: false });
const url = await offlinePage.evaluate(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
return location.href;
});
expect(url).toBe('http://localhost:4173/about');
expect(offlinePage.locator('li[aria-current="page"] a').getByText('About')).toBeTruthy();
await offlinePage.reload({ waitUntil: 'load' });
expect(offlinePage.url()).toBe('http://localhost:4173/about');
expect(offlinePage.locator('li[aria-current="page"] a').getByText('About')).toBeTruthy();
// Dispose context once it's no longer needed.
await context.close();
});
-56
View File
@@ -1,56 +0,0 @@
import { test, expect } from '@playwright/test';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
test('The service worker is registered and cache storage is present', async ({ page }) => {
await page.goto('/');
const swURL = await page.evaluate(async () => {
const registration = await Promise.race([
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
navigator.serviceWorker.ready,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000)
)
]);
// @ts-expect-error registration is of type unknown
return registration.active?.scriptURL;
});
const swName = 'sw.js';
expect(swURL).toBe(`http://localhost:4173/${swName}`);
const cacheContents = await page.evaluate(async () => {
const cacheState: Record<string, Array<string>> = {};
for (const cacheName of await caches.keys()) {
const cache = await caches.open(cacheName);
cacheState[cacheName] = (await cache.keys()).map((req) => req.url);
}
return cacheState;
});
expect(Object.keys(cacheContents).length).toEqual(1);
const key = 'workbox-precache-v2-http://localhost:4173/';
expect(Object.keys(cacheContents)[0]).toEqual(key);
const urls = cacheContents[key].map((url) => url.slice('http://localhost:4173/'.length));
/*
'http://localhost:4173/about?__WB_REVISION__=38251751d310c9b683a1426c22c135a2',
'http://localhost:4173/?__WB_REVISION__=073370aa3804305a787b01180cd6b8aa',
'http://localhost:4173/manifest.webmanifest?__WB_REVISION__=27df2fa4f35d014b42361148a2207da3'
*/
expect(urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__='))).toEqual(true);
expect(urls.some((url) => url.startsWith('?__WB_REVISION__='))).toEqual(true);
expect(urls.some((url) => url.startsWith('about?__WB_REVISION__='))).toEqual(true);
// dontCacheBustURLsMatching: any asset in _app/immutable folder shouldn't have a revision (?__WB_REVISION__=)
expect(urls.some((url) => url.startsWith('_app/immutable/') && url.endsWith('.css'))).toEqual(
true
);
expect(urls.some((url) => url.startsWith('_app/immutable/') && url.endsWith('.js'))).toEqual(
true
);
expect(urls.some((url) => url.includes('_app/version.json?__WB_REVISION__='))).toEqual(true);
});
@@ -59,7 +59,7 @@ dexNumber,pokemon,form,notes
58,Pyroar,,
59,Psyduck,,
60,Golduck,,
61,Farfetch'd,,
61,Farfetchd,,
62,Riolu,,
63,Lucario,,
64,Ralts,,
1 dexNumber pokemon form notes
59 58 Pyroar
60 59 Psyduck
61 60 Golduck
62 61 Farfetch'd Farfetch’d
63 62 Riolu
64 63 Lucario
65 64 Ralts
@@ -0,0 +1,57 @@
# Hosting evaluation: retain Netlify pending deployed evidence
Date: 15 September 2026. This is an interim decision report. **Do not cut over to Cloudflare yet.** The application improvements and selectable preview build are implemented, but the agreed real-host comparison cannot be completed with the current access and compatibility state.
## Evidence and decision
| Item | Observed result | Consequence |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Production Netlify site | `livingdextracker`, `www.dextracker.uk`; published revision inspected: `31fd959350962c1f1b973a06ecd5cf4eeef86514` | This is the existing deployment, not this working tree |
| Function/database regions | Netlify `us-east-1`; linked Supabase `eu-north-1`; production public database URL matches that project | Cross-region round trips are a plausible contributor; their latency share is unmeasured |
| Node production checks | Compact SSR, details, status writes, offline behavior and rendering budgets exercised locally | Application evidence only |
| Cloudflare build | Adapter 7.2.9, Wrangler 4.131.2, compatibility date 2026-09-15, `nodejs_compat`; build and dry-run passed | Packaging is viable |
| Final Worker artifact | Approximately 1,876 KiB uncompressed / 336 KiB gzip; 9,621 static assets | Within published size/count limits; re-audit after renderer changes |
| Actual local workerd | Home, authenticated dex and public share page returned 200 | Basic runtime support demonstrated |
| Share image in workerd | `/shared/:token/preview.png` returned 500; `sharp` native module dynamic require is unsupported | **Compatibility gate failed**; see separate renderer proposal below |
| Remote preview access | Netlify CLI authenticated; Wrangler not authenticated; equivalent staging fixtures/accounts not supplied | No paired deployed samples collected |
No production deployment, DNS change, database migration or region change was performed. Retaining Netlify now is a decision under incomplete evidence and a failed compatibility gate, not evidence that Netlify is faster.
Netlify supports streaming; the investigation's buffering describes the installed adapter/runtime combination, not the entire platform. Cloudflare's supported SvelteKit adapter and partial Node compatibility do not imply support for native `sharp`. Sources: [Netlify streaming](https://docs.netlify.com/build/functions/lambda-compatibility/), [SvelteKit Cloudflare adapter](https://svelte.dev/docs/kit/adapter-cloudflare), [Cloudflare Node compatibility](https://developers.cloudflare.com/workers/runtime-apis/nodejs/).
## Build and environment requirements
- Default builds remain Netlify. `NODE_ADAPTER=true` or `DEPLOY_TARGET=node` selects Node. `npm run build:cloudflare` selects Workers with Static Assets; `npm run check:cloudflare` checks packaging; `npm run preview:cloudflare` runs workerd locally. Do not run builds concurrently.
- Cloudflare uses platform compression rather than the Node compression implementation. Verify actual deployed content encoding for HTML, SvelteKit data and JSON responses.
- Supply public Supabase URL/anon key at build time, and private Supabase/export/OAuth secrets through platform bindings. Preview secrets must belong to the same staging project on both hosts. Keep provider redirect allowlists and cookie/domain settings explicit.
- Authenticate Wrangler and select the intended account before creating a remote preview. Build/dry-run success does not authorize production routing.
- Current documented limits include 128 MB memory, one-second startup, 64 MiB uncompressed Worker size and 20,000/100,000 static files on Free/Paid. Each static file must be at most 25 MiB. Free CPU allowance is only 10 ms per invocation; do not assume the app or renderer fits it. Measure CPU/memory/startup under actual workerd and deployment. [Workers limits](https://developers.cloudflare.com/workers/platform/limits/)
## Separate proposal: Worker-compatible share-image renderer
This is the concrete implementation proposal required by the migration gate. It is **not implemented** in this change.
1. Extract the existing SVG construction, XML escaping and text truncation into a platform-neutral module. Preserve the endpoint, 1,200 × 630 dimensions, progress values, privacy behavior and cache headers.
2. Keep a Node renderer using `sharp`. Add a Cloudflare renderer using `@resvg/resvg-wasm`, selected through the same build-time platform alias pattern as compression. Pin a verified version in the renderer change; exclude native `sharp` from the Worker dependency graph.
3. Bundle the renderer's WASM as a module and initialize lazily once per isolate through a shared promise. Import a compiled WebAssembly module using the supported Workers approach, rather than assuming a browser-style URL fetch loader works. Release per-render allocations after producing a PNG byte array. [Workers WASM support](https://developers.cloudflare.com/workers/runtime-apis/webassembly/javascript/), [resvg WASM package](https://github.com/thx/resvg-js/tree/main/wasm)
4. Bundle licensed regular/bold fonts, for example Noto Sans with its license. Explicitly map SVG weights to those fonts; do not rely on operating-system fonts. Verify accented Pokémon names, punctuation, long descriptions and the intended non-Latin fallback policy. Font appearance will need review against existing PNGs.
5. Add golden-image/metadata tests for empty/long names, XML special characters, all badges and 0/100% progress. Exercise concurrent requests, first invocation, repeated renders and error recovery in built workerd. Require HTTP 200, PNG signature/dimensions, correct sharing access and no private notes.
6. Re-audit bundle/assets, CPU, memory and startup after adding WASM/fonts. Run the deployed share-image test and backup/export/auth matrix before setting compatibility to passed. If this renderer exceeds operating constraints, compare a separately hosted image service in a new proposal; do not silently redirect rendering to production Netlify.
## Remaining deployed benchmark
1. Supply equivalent staging credentials and disposable national/scoped-form fixtures, then publish this exact code to both preview hosts. Preserve actual deployed revision and environment metadata.
2. Establish improved Netlify document/data baselines. Consider a separate region-aligned Netlify preview as an additional experiment; do not change the primary paired comparison mid-run.
3. Exercise sign-in, token refresh, account switching, Google/Dropbox OAuth and refresh, export generation, public shares/images, compression, secrets/bindings, static cache headers, service-worker install/update/offline routing and custom preview domains on both real hosts.
4. Use the [comparison harness](pokedex-implementation.md#instrumentation-and-comparison-harness) for at least 30 warm samples per host × fixture × direct/client navigation. Keep code, Supabase project, fixtures, browser settings and client locations identical. Run additional deliberately idled samples and record the idle duration separately; first-observed is not a cold-start claim.
5. Recommend migration only when overall first-interactive p75 improves by **both 20% and 200 ms**, no fixture/navigation combination regresses by more than 10%, and functional checks pass. Include costs and operating requirements before making the final decision.
## Cost projection and operations
Actual monthly traffic, CPU usage and the account's current Netlify billing arrangement are not available, so no savings claim is justified.
Cloudflare Paid has a $5/month minimum, includes 10 million dynamic requests and 30 million CPU-ms, then charges $0.30/million requests and $0.02/million CPU-ms. Direct static asset requests are free; requests served through Workers Caching have different billing. Illustrative direct-static configuration: 1 million dynamic requests at 20 ms CPU each remains within $5; 10 million at 20 ms is approximately $8.40. These exclude other services and are assumptions, not measured app CPU. [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/)
For credit-based Netlify plans, estimate credits as `15 × production deploys + 10 × compute GB-hours + 20 × bandwidth GB + 2 × requests/10,000`. Confirm whether this account uses credit-based or legacy billing and apply its actual plan/allowance. Thumbnail byte savings affect bandwidth, while increased first-party sprite requests affect request metering. [Netlify pricing](https://www.netlify.com/pricing/)
Before a qualifying cutover: move database migrations out of host builds into one serialized CI release step; provision secrets and OAuth redirects; validate the custom domain and cookies; monitor error/latency/export metrics; retain the working Netlify deployment and a documented DNS rollback. These production changes remain conditional on completing the gates above.
+115
View File
@@ -0,0 +1,115 @@
# Pokédex performance implementation
Status: application changes implemented; hosting comparison remains gated. Measurements below are local production-build evidence, not deployed latency improvements. See [hosting evaluation](pokedex-hosting-evaluation.md) for the migration decision and remaining work.
## Application changes
- Ownership and saved scope links are read together. Scoped entry retrieval is shared by rows/count consumers; the full grid performs no count query. Catch joins use ID maps. Scope deduplication, named default forms, supplements and ordering remain covered by repository tests.
- The page awaits the entire compact grid and renders initial boxes on the server. Authentication state reaches SSR through the validated layout user. Successful navigation needs no grid API request; failed grid loads expose retry.
- `PokedexGridRow` contains identity, sprite resolution fields and catch flags. The page and authenticated `/api/pokedexes/[id]/grid` endpoint transport named tuples defined in `PokedexGridRow.ts`; `packGrid`/`unpackGrid` keep that wire format out of components. Instructions, notes, origin games and repeated owner/dex IDs are absent. Existing full combined-data consumers retain their contracts.
- The detail endpoint verifies ownership and membership before returning one full `CombinedData` row. The modal opens immediately with identity and full artwork, then loads editable details. Its account/dex/entry cache, abort/sequence checks and pending-patch merge protect rapid selection changes and optimistic edits.
- Status writes send changed fields. Bulk writes group records by supplied columns, preserving omitted notes and flags; explicit empty notes clear them. New records use database defaults. Full-record callers and exports remain supported. Bulk box actions still target all original 30 slots, including dimmed entries.
- Grid placeholders preserve geometry; visible boxes and one row of overscan mount populated cells. Focused boxes remain mounted, keyboard navigation crosses boundaries, modal close restores focus, and an accessible render-all option exposes the complete document. Each Pokémon uses one button with identity/status and a noninteractive tooltip.
- Density is persisted in a cookie for stable SSR geometry and in local storage. Resize/density changes preserve the current box anchor. Existing local-storage-only preferences are replaced by the cookie after choosing a density.
- Automatic snapshot/backup work starts at the interactive/idle boundary with a five-second fallback. Concurrent backup refreshes coalesce. Explicit sync and edit/account/reconnect invalidations retain freshness behavior. Backup-status GET no longer revalidates via `setSession`.
- Complete offline snapshot format 2 is retained. Offline modal details come only from the matching account snapshot; missing copies report unavailable details. Offline details are read-only, as before for offline mutations.
## Artwork and build behavior
`npm run sprites:grid` generates 3,170 first-party WebP thumbnails, at most 128 pixels per side, quality 80. Builds run this automatically. Generated assets live in ignored `static/sprites-grid/v1/`; the generator emits a manifest. Originals remain 512 pixels for details and explicit full-artwork downloads.
The generated catalog is 10,007,504 bytes versus 53,659,230 bytes for the source WebP catalog (81.4% smaller). Shiny, female and named-form resolution uses the existing key/fallback chain. The service worker recognizes versioned grid sprites in the existing artwork cache; neither thumbnail nor original catalogs are blanket precached. Root `_headers` supplies immutable caching on Netlify/Cloudflare. Bump the URL version whenever generation settings or source images change; generation skips existing files within a version.
## Local acceptance evidence
Fixtures: national (1,025 entries), Scarlet/Paldea forms (439 entries), mixed catch flags and nonempty notes. Integration tests additionally cover missing catches and overlapping scopes through repository tests. Browser viewport: 1,350 × 940, comfortable density, Chromium, local Node production build and local seeded Supabase.
The packed grid is approximately 81 KB national and 36 KB scoped, versus the investigation's approximately 593 KB and 255 KB full-data JSON. The same-fixture integration assertion separately verifies at least 60% reduction against full combined rows. These are serialized row sizes, not compressed HTML document sizes.
The local browser checks verify at most 180 populated mounted cells, fewer than 2,500 DOM elements and CLS at most 0.1; observed initial population is 120 cells. Density/mobile checks include all three densities at 1,350 and 390 pixels. Detail artwork is asserted to load at 512 pixels. No redundant grid request is allowed; intentional detail requests are allowed.
Warm results from the corrected harness (30 samples per row; [sanitized summary](pokedex-local-results.json)):
| Fixture | Navigation | First visible p75 | First interactive p75 | Decoded response bytes p75 | Maximum DOM |
| ------------ | ---------- | ----------------: | --------------------: | -------------------------: | ----------: |
| National | Direct | 123.5 ms | 145.4 ms | 248,668 | 2,088 |
| National | Client | 495.6 ms | 494.1 ms | 102,687 | 2,090 |
| Scoped forms | Direct | 114.5 ms | 133.1 ms | 203,601 | 2,073 |
| Scoped forms | Client | 491.7 ms | 490.6 ms | 45,645 | 2,075 |
Every warm sample had 120 populated cells, zero observed CLS, zero redundant grid requests and a measured response size. Direct response bytes include SSR markup and SvelteKit data; client bytes are SvelteKit data responses, not just the packed rows. Interactive marks can precede the next animation-frame visibility observation by a few milliseconds. Client timings include Playwright click overhead, as explained below. This benchmark preceded the final account-switch backup invalidation guard; the final production smoke suite also passed after that guard.
### Validation completed
| Check | Result |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Svelte/TypeScript | 0 errors; one existing `Tooltip.svelte` CSS `@apply` warning |
| Unit tests | 193 passed |
| Data tests | 18 passed |
| Coverage | Existing thresholds passed: 66.81% lines/statements, 88.79% branches, 90.99% functions |
| Local Supabase integration | 16 passed, including same-fixture payload reduction and note preservation |
| Authenticated browser regressions | 53 passed, including mock Google/Dropbox OAuth, exports and service-worker/offline flows |
| Build matrix | Four checks in each of Netlify/Node × generateSW/injectManifest passed |
| Production performance/behavior | Both fixtures, all densities/mobile, resize anchor, focus/keyboard, modal race, full artwork and offline snapshot isolation passed |
| Cloudflare | Final build and Wrangler dry-run passed; local workerd share-image generation failed as documented in the hosting report |
| Formatting/lint | All changed/new implementation files pass Prettier; ESLint and `git diff --check` pass |
Repository-wide `npm run lint` also scans the pre-existing untracked `dex.har` and `POKEDEX_PERFORMANCE_PROMPT.md`; those two files have formatting warnings and were intentionally left untouched. No coverage threshold was lowered. Disposable performance accounts and local test servers were cleaned up.
### Reproduce
```sh
npm ci
npx supabase start
npx playwright install chromium
npm run check
npm run test:unit
npm run test:integration
npm run test:build
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER=true npm run test:bdd
npm run test:performance
PERF_BENCHMARK=true npm run test:performance
```
Run builds sequentially: they share `.svelte-kit` and `build`. Performance tests create a disposable local-only account, use private temporary session files, clean up afterward, and copy sanitized reports/screenshots to ignored `test-results/performance/`. Cleanup failures retain recovery files. Never commit session/configuration files.
## Instrumentation and comparison harness
Set `POKEDEX_PERFORMANCE=true` on a preview to emit fixed-label `Server-Timing` stages: auth, ownership, scopes, entries, catches, preparation and total. Timings do not include IDs, cookies, notes or query text. Authentication includes the request hook's validation. Total measures the page/service span, not total platform request lifetime; stages may therefore not sum to total.
The browser harness measures first visible cells via animation frames and first interactive cells via `pokedex:first-interactive`, independently of response completion. It collects DOM/cells, layout shifts, intentional/redundant API counts and response sizes. For Chromium service-worker responses where Playwright cannot read the body, decoded Resource Timing bytes are used; absent measurements stay null. Decoded, encoded and transferred bytes are distinguished.
`benchmark.mjs` requires at least 30 warm samples per fixture/navigation combination. It records the first observed run separately; that is **not** evidence of a cold start or first-after-idle run. For real-host work, collect deliberately idled runs separately and record the idle interval. Client timing starts before Playwright's click, so includes its actionability overhead; preserve identical tooling/settings between hosts and report this limitation.
Private configuration example (replace all placeholders):
```json
{
"samples": 30,
"revision": "exact-deployed-commit",
"databaseLabel": "same-staging-project",
"clientLocation": "London-fixed-runner",
"environment": "deployed",
"compatibilityPassed": false,
"hosts": [
{
"label": "netlify",
"url": "https://NETLIFY-PREVIEW",
"storageState": "/PRIVATE/netlify-session.json",
"fixtures": [
{ "id": "NATIONAL-ID", "name": "Performance National", "label": "national" },
{ "id": "SCOPED-ID", "name": "Performance Scarlet Forms", "label": "scoped-forms" }
]
}
]
}
```
Add an equivalent `cloudflare` host using the same database and fixtures. Run:
```sh
node scripts/performance/benchmark.mjs /PRIVATE/config.json /PRIVATE/results.json
node scripts/performance/compare.mjs /PRIVATE/results.json
```
The comparison refuses insufficient samples and retains Netlify unless deployed evidence, compatibility and the agreed improvement thresholds all pass. Human review must still establish costs, environment equivalence and deployment prerequisites.
+227
View File
@@ -0,0 +1,227 @@
# Signed-in Pokédex performance investigation
Investigated 1415 September 2026 at commit `31fd959350962c1f1b973a06ecd5cf4eeef86514`.
## Findings
The production capture establishes a server-response bottleneck: **3,506 ms waiting versus 261 ms receiving** the Pokédex data response. The current loading design exposes all entry work to that wait on the installed Netlify adapter. It requests practically the entire dex, and the adapter buffers the response before returning it. Independently, mounting the entire grid causes avoidable browser work.
Local measurements confirm the request sequence, duplicate game-scoped queries, large payload and rendering cost. **They do not explain the exact allocation of production's 3.5 seconds.** Regions, function startup and production query timings remain unverified. At the user's direction, no further production/dashboard access was attempted; subsequent measurements used local Supabase.
No performance fixes, migrations, deployment settings, public APIs or types were changed. Temporary instrumentation and the diagnostic rendering cap were removed. Full-resolution artwork must remain available when a user opens a Pokémon's detail view.
The numerical evidence, including individual runs and sanitized server traces, is in [pokedex-results.json](pokedex-results.json).
## Production evidence and its limits
| Evidence | Result | Interpretation |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| Supplied `dex.har`, data navigation | Wait 3,506.100 ms; receive 260.671 ms; 401,199 uncompressed bytes | Waiting dominates transfer. This includes server and network effects; it is not a database timer. |
| Response headers | Brotli; `private,no-store`; Netlify request ID present | Compression already exists. User-specific content is not served as a public CDN cache hit. |
| HAR content | Response text absent | Cannot measure production field sizes or infer the dex's form/scope configuration from this file. |
| HAR sprites | 36 requests, 531,086 bytes total, `max-age=300` | Visible artwork has a substantial transfer cost independent of entry data. |
| Supplied Lighthouse summary | Root response 3,373 ms; about 386 KB serialized inline data; 10,442 DOM elements; 1.9 s rendering; CLS 0.127 | Corroborates both server wait and browser cost. These are user-supplied findings, not a new run. |
The HAR contains only one application request plus the 36 sprite requests. It does **not** contain the offline-snapshot or backup-status requests; their production sizes/durations come from the supplied Lighthouse summary. Extension findings were excluded.
### Why streaming currently fails to hide the work
[The server load](../../src/routes/pokedex/[id]/+page.server.ts) returns an unresolved `initialCombinedData` promise, but `INITIAL_PAGE_SIZE` is **9,999**, matching the client. Its `.then()` discards the count/pagination metadata after the shared service has calculated it.
[adapter.mjs](../../adapter.mjs) selects standard Netlify functions (`edge: false`), except for Node test builds. Installed `@sveltejs/adapter-netlify` **4.4.2**, in `node_modules/@sveltejs/adapter-netlify/files/esm/serverless.js`, awaits `response.text()` for text responses and `response.arrayBuffer()` for binary responses. Both buffer the body before the handler returns. This proves the behavior of the installed adapter; the exact deployed package/build was not accessible. The HAR's long wait is consistent with it.
[Compression](../../src/lib/server/compression.ts) already skips application compression in Lambda because Netlify handles it. Enabling more compression would not fix this buffering boundary. The page also assigns its entries client-side, so even genuine transport streaming does not mean the grid is server-rendered and interactive immediately.
An isolated local identity-encoding stream probe received first bytes before completion:
| Dex | First-byte median | Complete-body median |
| ------------- | ----------------: | -------------------: |
| National | 48.3 ms | 104.7 ms |
| Scarlet forms | 36.8 ms | 72.5 ms |
These are Vite measurements, not evidence that Netlify streams.
## Server request map and timings
Sources: [session hook](../../src/hooks.server.ts), [ownership repository](../../src/lib/repositories/PokedexRepository.ts), [scope service](../../src/lib/services/PokedexDexScopeService.ts), [combined-data service](../../src/lib/services/CombinedDataService.ts), [combined-data repository](../../src/lib/repositories/CombinedDataRepository.ts).
For an unexpired authenticated session, both measured dexes make **eight Supabase HTTP requests per page/data request**:
| Stage | National: 1,025 entries | Scarlet Paldea forms: 439 entries |
| ---------------------------------------- | ------------------------------------------ | ------------------------------------------ |
| Authentication | 1 `getUser` | 1 `getUser` |
| Ownership, then scope links | 1 `pokedexes`, then 1 `pokedex_dex_scopes` | Same |
| Entry branch | 2 sequential entry pages: 1,000 + 25 | 1 game-dex query, then 1 forms query |
| Count branch, parallel with entry branch | 1 HEAD exact-count query | Repeats the game-dex query and forms query |
| Catches, after entry retrieval | 2 sequential chunks: 1,000 + 25 | 1 chunk: 439 |
| Total / longest chain of requests | **8 / 7** | **8 / 6** |
The game query returns 400 rows. The named-form query returns 45 rows; six are already represented, leaving 39 supplements and 439 unique entries. The count branch repeats both reads, transferring another **277,047 bytes** from Supabase just to count those entries. The two branches run concurrently: eliminating duplication reduces work and traffic, but does not necessarily remove their summed durations from the critical path.
General case:
- National path: `3 + entry-page requests + 1 count + catch chunks`. Pagination continues until a short/empty batch or the requested limit. An exact multiple of 1,000 may require an empty terminal request. Catch chunks use at most 1,000 IDs.
- Explicit game scopes: `3 + 2 × (dex-page requests + applicable form-page requests) + catch chunks`. Counting fetches the full scope even when the requested page is small. Deduplication and slicing happen in JavaScript.
- A game scope without saved dex scopes adds two sequential reads (`games`, then `game_dexes`). National dexes still pay the scope-link query even though scope resolution itself returns immediately.
- Token refresh can add an authentication request. A separate probe using an expired saved cookie produced nine requests; the reported baseline was rerun with a fresh session. Replaying that stale cookie every time was a probe artifact, not normal browser session behavior.
- `safeGetSession` is shared within a request, not across navigation and background requests.
### Isolated local server timings
Five warm requests per dex, one first run excluded. Queries were instrumented at the Supabase fetch boundary, including response-body completion; repository methods were timed separately. The cloned response used for instrumentation adds overhead. All values below are medians, so columns are not additive.
| Measure | National | Scarlet forms |
| --------------------------------------------------- | -------: | ------------: |
| Auth `getUser` HTTP duration | 35.5 ms | 25.3 ms |
| Ownership + scope-link HTTP durations | 8.0 ms | 7.2 ms |
| `findCombinedData`, including entries and catches | 50.7 ms | 33.3 ms |
| `countCombinedData`, concurrent with find | 6.6 ms | 25.6 ms |
| Catch retrieval, included within find | 16.4 ms | 6.5 ms |
| Sum of all query durations | 92.9 ms | 86.0 ms |
| Elapsed request time to final query-body completion | 87.6 ms | 64.1 ms |
| Complete HTTP response | 104.7 ms | 72.5 ms |
| Supabase response-body bytes, summed | 917,728 | 709,996 |
The difference between summed query durations and elapsed completion reflects concurrency. Repository work, serialization, streaming and measurement overhead sit outside or between query timings. Do not subtract these local medians from production TTFB to invent a region/startup estimate.
### Query plans and indexes
`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` ran under the local `authenticated` role with the fixture user's JWT subject. These are SQL execution times, excluding HTTP, PostgREST JSON serialization and function/network latency.
| Query shape | Median of five warm executions |
| ------------------------------------------------- | -----------------------------: |
| National ordered entries, first 1,000 | 5.557 ms |
| National ordered entries, offset 1,000 | 5.866 ms |
| National exact count through the view | 2.484 ms |
| Scarlet game-dex detail rows | 10.706 ms |
| Named forms available in Scarlet | 2.934 ms |
| Catch query with the actual first 1,000 entry IDs | 0.476 ms |
The entry view joins and aggregates origin-game data before returning rows. The game-dex plan aggregates 1,390 Pokémon before joining the 400 scoped entries. The offset query repeats aggregation/sorting for the last 25 rows. These are real work amplification, but small absolute local costs.
The game membership index and existing catch-record dex index are used. The exact catch query is already sub-millisecond locally. **No new index is justified by these measurements.** Prefer avoiding duplicate/full-view work before speculative indexing. The evidence JSON also retains an earlier catch-plan probe with an ID subquery; the literal-ID result above is the closer match to the application.
## Payload and browser measurements
### Method and fixtures
- Existing local Supabase seed: 1,390 catalog entries, 1,025 default forms. No reset or migration was run.
- Dedicated disposable account: national living dex with 1,025 catch records; Scarlet/Paldea form dex with 1,390 seeded catch records, of which 439 match this view. Mixed deterministic statuses and empty personal notes. Existing user data was preserved.
- Headless Playwright Chromium, 1,350 × 940 viewport, device scale 1, no extensions, no CPU/network throttling. Vite 5.4.21 development server, Node 24.18.0, same-origin sprites.
- One first run plus five warm runs for each navigation/dex combination. Client navigation clicks the real card's View button from My Pokédexes. Each client sample starts from a loaded list page with a 1.1-second settling period.
- “Visible cells” uses Playwright's first-cell visibility check and includes automation overhead. Response completion is network response completion, not full page idle. Layout/style time is CDP `LayoutDuration + RecalcStyleDuration` over the sample and 1.5-second follow-up; it is not Lighthouse's entire rendering category.
| Dex / navigation | First-run visible | Five warm visible times | Median TTFB | Median response complete | Median visible | DOM |
| ---------------------- | ----------------: | -------------------------- | ----------: | -----------------------: | -------------: | -----: |
| National / direct | 798 ms | 549, 986, 519, 474, 432 ms | 84 ms | 268 ms | **519 ms** | 10,236 |
| National / client | 407 ms | 322, 335, 369, 423, 381 ms | 63 ms | 133 ms | **369 ms** | 10,236 |
| Scarlet forms / direct | 462 ms | 309, 278, 299, 312, 317 ms | 68 ms | 148 ms | **309 ms** | 4,476 |
| Scarlet forms / client | 224 ms | 272, 227, 233, 363, 274 ms | 59 ms | 111 ms | **272 ms** | 4,476 |
These development results must not be equated with optimized production performance. Local full-dataset fixture content, development scripts/styles and serialized session metadata differ from the production capture. The initial Vite dependency-optimization failure was resolved before collecting this matrix. One first-run streamed response could not be read through Chromium's body-capture API; its bytes are recorded as null, not zero.
### What is needed at first paint
At this viewport the national dex mounts 35 boxes and 1,025 populated cells, with only **60 cells visible**. Scarlet mounts 15 boxes and 439 cells, also with 60 visible. The national grid is 10,532 px tall. IntersectionObserver already defers distant sprite downloads; it does not prevent mounting those cells, tooltip components and loading spinners. There were 73 image elements, not 1,025 downloaded sprites.
The box and tooltip need identity/name/form/number/sprite key and catch-status flags. Evolution/catch instructions, origin games, dex notes and personal notes belong to the detail workflow. Repeated catch `userId`/`pokedexId` values are also expensive in ordinary JSON. The page still needs global totals and stable placement, so simply lowering the limit would break existing behavior.
| JSON measurement | National | Scarlet forms |
| ------------------------------------------------- | --------: | ------------: |
| Full combined rows | 592,997 B | 255,319 B |
| Display-only rows for every entry | 170,636 B | 74,154 B |
| Display-only first 60 entries | 9,751 B | 10,757 B |
| Detail-only Pokémon fields, separately serialized | 214,064 B | 91,887 B |
“Display-only” is a sizing experiment, **not a proposed drop-in API**: it excludes identifiers/fields that writes and details currently need. JSON partitions are serialized separately and are not additive. Actual SvelteKit data responses use a different serialization format: warm client response medians were 518,649 B and 222,892 B. Direct development documents were about 686 KB and 375 KB; their largest inline scripts were 545,897 B and 235,175 B.
### Controlled rendering comparison
A temporary diagnostic changed only the rendered box loop to its first two boxes, leaving the fetched full dataset intact. Both comparisons blocked the two background endpoints, used the existing offline worker, and measured five warm direct loads plus 2.5 seconds after visibility. The full loop was restored afterward.
| Measure | All 35 boxes | First 2 boxes only |
| ----------------------------------------------- | -----------: | -----------------: |
| Mounted populated cells | 1,025 | 60 |
| DOM elements | 10,236 | 728 |
| Median time to visible cells | 464 ms | 318 ms |
| Median layout/style work | 63.9 ms | 13.4 ms |
| Median main-thread task time during observation | 1,124 ms | 82.8 ms |
| Median accumulated long-task duration | 147 ms | 0 ms |
This isolates a substantial browser cost from mounting the full view; it does not demonstrate a complete virtualization implementation or guarantee the same production savings. Off-screen spinner components remain mounted in the full view, but this experiment does not separately attribute their cost.
Direct-load layout-shift observations varied (roughly 0.0820.657), with footer/content shifts recorded. Client-click samples recorded zero, partly because recent-input shifts are excluded. These short development observations are not interchangeable with production's Lighthouse CLS of 0.127. Reserve appropriate loading geometry when redesigning rendering.
## Background requests and artwork
[Offline synchronization](../../src/lib/stores/offlineSync.ts) schedules startup after one second and reuses a matching format-2 snapshot younger than 15 minutes. Explicit edits, retry, account changes and reconnect trigger a fresh copy. [The snapshot endpoint](../../src/routes/api/offline-snapshot/+server.ts) loads every owned dex in parallel, including full entry/detail/catch data. For this account that is ten Supabase calls with a fresh token, independent of the page's eight.
A separate local comparison activated the existing offline worker and controlled metadata age in the disposable browser cache. Five warm direct loads per condition:
| Condition | Median visible cells | Snapshot requests per load | Backup requests per load |
| -------------------------------------- | -------------------: | -------------------------: | -----------------------: |
| Fresh snapshot, enabled | 449 ms | 0 | 1 |
| Stale snapshot, enabled | 431 ms | 1 | 1 |
| Stale snapshot, both endpoints blocked | 464 ms | 0 completed | 0 completed |
The stale snapshot was **849,107 B**, typically around 130160 ms locally. It started around 1.2 seconds after navigation, after cells were visible. Backup status began around 0.20.3 seconds, overlapping rendering. Blocking requests did **not** improve time to visible cells consistently in these runs. Thus background work is a secondary bandwidth/CPU/DB contention concern, not a demonstrated explanation of initial local TTFB. An already-running snapshot could overlap a later client navigation; the production HAR cannot establish that.
[Backup status GET](../../src/routes/api/export-integrations/+server.ts) calls `requireAuth`, reads the memoized session, then calls `supabase.auth.setSession(session)`. Instrumentation observed **two `GET /auth/v1/user` requests plus one integration query**. Memoizing `safeGetSession` does not eliminate validation triggered by this separate `setSession` call.
The worker stores snapshot data without automatically fetching all artwork; artwork fetches are cached as viewed, and full artwork download is explicit. The initial dev navigation matrix had no registered worker, so its snapshot requests did not establish cache-reuse behavior; that is why the separate worker-enabled comparison was necessary.
### Preserve detail-image resolution
[Sprite URL resolution](../../src/lib/utils/spriteUrl.ts) selects local assets only when `PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER` is `true`; otherwise it uses GitHub raw URLs. The HAR confirms the latter for production. Local `home/1.webp` and `home/shiny/1.webp` are both 512 × 512. In-memory 128 px WebP experiments at quality 80 reduced 13,530 → 2,726 B and 12,634 → 2,522 B (about 80%). Two samples are not a catalog-wide saving estimate.
**Retain the 512 px originals for the clicked Pokémon detail view.** If pursuing this optimization, introduce separate grid thumbnails and retain full-resolution detail URLs, with fallback coverage for shiny, female and named forms. Update manifest/cache versioning deliberately; replacing assets at existing URLs conflicts with the worker's cache-forever assumption. The sprite build script currently defaults `SPRITE_MAX_SIZE` to zero, so “sprites-small” does not imply smaller dimensions. No sprite files were changed.
## Ranked recommendations
| Priority | Recommended next change | Evidence / confidence | Effort and risk |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Stop computing an unused count in the page preload; share one ordered scoped-entry retrieval when rows and count are both needed. Combine ownership and scope retrieval where practical. | Confirmed redundant work: one national count or two full scoped queries, plus serial metadata reads. High confidence in work reduction; production milliseconds unknown. | Lowmedium. Preserve ownership/RLS, scope deduplication, named default forms and ordering. |
| 2 | Reduce initial data and the number of mounted boxes together. Evaluate viewport rendering with reserved space, lightweight global totals/order, and on-demand detail data. | Only 60/1,025 cells visible; capped rendering reduced visible time 31%, layout/style work 79%, DOM 93%. Display-only JSON is about 71% smaller. | Mediumhigh. Preserve box numbering, scroll/keyboard access, global filters/counts, bulk edits, navigation races and offline details. Do not just change `9999` to `60`. |
| 3 | Resolve the Netlify buffering constraint as part of the loading design. Validate a supported streaming-capable adapter/runtime in a future deployment, or make the initial response intentionally small without relying on deferred promises. | Installed adapter demonstrably buffers. High confidence in the constraint; no deployed streaming alternative verified. | Medium. Keep Netlify as target; test both document and data navigation. A shell alone is not an interactive grid. |
| 4 | Remove redundant backup-session validation if authentication remains guaranteed; schedule nonessential background work after critical page work while preserving snapshot freshness and explicit syncs. | Three backup calls confirmed; stale snapshot 849 KB. No consistent local first-visibility gain when blocked. | Lowmedium. Verify reconnect status and account isolation; do not disable offline support. |
| 5 | Serve separate grid thumbnails, retaining full-resolution detail artwork. Evaluate first-party hosting/cache headers for those assets. | Two samples about 80% smaller; production HAR sprite transfer 531 KB. | Medium. Preserve all variants and offline cache behavior. Requires separate thumbnail URLs, not destructive resizing. |
| 6 | Replace per-entry linear catch lookup with a map when touching repository joins. | `.find()` per row has quadratic scaling, but current local processing is small. | Low. Lower priority than network, payload and DOM work. |
Do not propose a database index or a region move as an established fix from the available evidence. Production-specific stage instrumentation and region verification remain future work requiring access.
### Regression coverage and secondary findings
[Lighthouse CI](../../lighthouserc.cjs) audits only five signed-out pages using the Node adapter. [Signed-in BDD scenarios](../../tests/bdd/features/performance.feature) allow five seconds for direct load and three seconds per switch, assert first-cell visibility and prohibit a separate entry request. Those checks do not exercise Netlify's response buffering, assert authenticated data/DOM sizes, or distinguish shell, first cells and completed loading.
Future checks should use a full national fixture and a scoped form fixture, measure document and client navigation separately, and track response bytes, mounted cells, first useful view and background request counts. Preserve ordering, overlapping dex deduplication, named default forms, missing catches, filters, bulk status updates, modal notes, shiny/female artwork, account changes and fresh/stale offline behavior. A deliberate loading redesign may require replacing the “no separate entry request” assertion with a user-visible timing/data budget. Deployment checks must exercise actual Netlify responses, not infer parity from Node CI.
The nested `<button>` in [Tooltip](../../src/lib/components/Tooltip.svelte) is confirmed by source inside each Pokémon button. Address its accessible name and invalid nesting in follow-up UI work. Contrast, target-size and missing-meta-description findings are secondary to the performance diagnosis; contrast/target size require rendered theme/mobile verification, while the missing page description is visible in source. Preserve detail-view artwork quality as requested.
## Reproduction and cleanup
Raw captures, disposable credentials and diagnostic scripts are local-only under `/tmp/pokedex-perf-investigation` (directory mode 700); they are not included in the repository evidence. The evidence JSON omits sessions, fixture IDs, personal data and full query filters. The original `dex.har` and prompt remain untouched and untracked.
The seeded stack was already running. Instead of resetting it or invoking Tailwind output generation, the investigation used the project's loopback-checked Supabase environment wrapper and the same Vite dev server used by `dev:supabase`:
```sh
# From the repository root; these diagnostic scripts are retained locally, not installed tooling.
node /tmp/pokedex-perf-investigation/setup.mjs
node scripts/run-with-local-supabase.mjs node /tmp/pokedex-perf-investigation/seed.mjs
node scripts/run-with-local-supabase.mjs node /tmp/pokedex-perf-investigation/auth.mjs
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER=true node scripts/run-with-local-supabase.mjs npx vite dev --host 127.0.0.1 --port 4173 > /tmp/pokedex-perf-investigation/dev.log 2>&1
# In another terminal, after Vite dependency optimization has settled:
node /tmp/pokedex-perf-investigation/measure.mjs
node /tmp/pokedex-perf-investigation/background.mjs
node /tmp/pokedex-perf-investigation/server-probe.mjs
node /tmp/pokedex-perf-investigation/analyze-server.mjs
node /tmp/pokedex-perf-investigation/explain.mjs
node /tmp/pokedex-perf-investigation/catch-plan.mjs
# After stopping the dev server and restoring the instrumented files:
node scripts/run-with-local-supabase.mjs node /tmp/pokedex-perf-investigation/cleanup.mjs
```
The rendering-cap experiment temporarily changed `{#each boxNumbers as boxNumber}` to `{#each boxNumbers.slice(0, 2) as boxNumber}`, then ran `PERF_VARIANT=grid-cap node /tmp/pokedex-perf-investigation/grid-cap.mjs` and restored the file. Run comparisons sequentially, discard the first run and keep viewport/cache/fixture settings equal. The full methodology and sanitized per-run evidence above remain usable if the temporary scripts are later removed.
At completion, original application files were restored byte-for-byte, the investigation server was stopped, and only the dedicated fixture account and its cascading test records were deleted. Existing database data and production configuration were unchanged. No commit was created.
@@ -0,0 +1,60 @@
{
"capturedAt": "2026-09-15T16:28:41.386Z",
"environment": "local-node",
"revision": "local-working-tree",
"clientLocation": "local-loopback",
"groups": [
{
"fixture": "national",
"navigation": "direct",
"samples": 30,
"visibleP75": 123.5,
"interactiveP75": 145.40000009536743,
"responseBytesP75": 248668,
"missingBytes": 0,
"maxDOM": 2088,
"maxCells": 120,
"maxCLS": 0,
"gridRequests": 0
},
{
"fixture": "national",
"navigation": "client",
"samples": 30,
"visibleP75": 495.59999990463257,
"interactiveP75": 494.09999990463257,
"responseBytesP75": 102687,
"missingBytes": 0,
"maxDOM": 2090,
"maxCells": 120,
"maxCLS": 0,
"gridRequests": 0
},
{
"fixture": "scoped-forms",
"navigation": "direct",
"samples": 30,
"visibleP75": 114.5,
"interactiveP75": 133.10000014305115,
"responseBytesP75": 203601,
"missingBytes": 0,
"maxDOM": 2073,
"maxCells": 120,
"maxCLS": 0,
"gridRequests": 0
},
{
"fixture": "scoped-forms",
"navigation": "client",
"samples": 30,
"visibleP75": 491.7000000476837,
"interactiveP75": 490.60000014305115,
"responseBytesP75": 45645,
"missingBytes": 0,
"maxDOM": 2075,
"maxCells": 120,
"maxCLS": 0,
"gridRequests": 0
}
]
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
// Lighthouse CI: `npm run test:lighthouse` builds the Node adapter output, serves it and audits the
// public pages. Any category below 90 fails the run (and so the PR).
// Set LIGHTHOUSE_FORM_FACTOR=desktop to audit with the desktop profile; the default is Lighthouse's
// mobile profile (slow 4G + CPU throttling), which is the stricter of the two. The variable must not
// start with LHCI_: lhci treats those as CLI flags, so LHCI_PRESET was passed to `lhci assert` as
// an invalid `--preset`.
const preset = process.env.LIGHTHOUSE_FORM_FACTOR === 'desktop' ? 'desktop' : undefined;
module.exports = {
ci: {
collect: {
startServerCommand: 'npm run preview-node',
startServerReadyPattern: 'Listening on',
url: [
'http://localhost:4173/',
'http://localhost:4173/signin',
'http://localhost:4173/welcome',
'http://localhost:4173/offline-guide',
'http://localhost:4173/forgot-password'
],
// Median of three runs smooths out noise from shared CI runners.
numberOfRuns: 3,
settings: {
...(preset ? { preset } : {}),
chromeFlags: '--no-sandbox --headless=new'
}
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
// The app compresses its own responses (see src/lib/server/compression.ts and the
// precompressed build), so nothing may be served uncompressed.
'uses-text-compression': ['error', { minScore: 1 }],
// Regression budgets, set a little above what every audited page measured in September
// 2026 (mobile profile: LCP 1.4-2.6 s, TBT 0 ms, CLS 0, ~100 KB script, ~14 KB CSS and
// ~175 KB in total over the wire). A PR that makes pages meaningfully slower or heavier
// fails here even while the category scores stay above 90. When a change legitimately
// needs more, raise the number in the same PR so the cost is reviewed.
'largest-contentful-paint': ['error', { maxNumericValue: 3000 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.05 }],
'resource-summary:script:size': ['error', { maxNumericValue: 115 * 1024 }],
'resource-summary:stylesheet:size': ['error', { maxNumericValue: 20 * 1024 }],
'resource-summary:total:size': ['error', { maxNumericValue: 220 * 1024 }]
}
},
upload: {
target: 'filesystem',
outputDir: '.lighthouseci/reports'
}
}
};
+5249 -196
View File
File diff suppressed because it is too large Load Diff
+42 -19
View File
@@ -7,11 +7,13 @@
"dev-generate": "GENERATE_SW=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
"dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
"sprites:build": "node scripts/optimize-sprites.mjs",
"build-generate-sw": "GENERATE_SW=true vite build",
"build-generate-sw-node": "NODE_ADAPTER=true GENERATE_SW=true vite build",
"build": "npx tailwindcss -i ./static/input.css -o ./static/output.css && vite build",
"build-inject-manifest-node": "NODE_ADAPTER=true vite build",
"build-self-destroying": "SELF_DESTROYING_SW=true vite build",
"sprites:manifest": "node scripts/sprite-manifest.mjs",
"build-generate-sw": "npm run sprites:grid && npm run tailwind && GENERATE_SW=true vite build",
"build-generate-sw-node": "npm run sprites:grid && npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build",
"build": "npm run sprites:grid && npm run tailwind && vite build",
"build-inject-manifest": "npm run sprites:grid && npm run tailwind && vite build",
"build-inject-manifest-node": "npm run sprites:grid && npm run tailwind && NODE_ADAPTER=true vite build",
"build-self-destroying": "npm run sprites:grid && npm run tailwind && SELF_DESTROYING_SW=true vite build",
"preview": "vite preview --port=4173",
"preview-node": "PORT=4173 node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -19,23 +21,41 @@
"lint": "prettier --check . && eslint .",
"lint-fix": "npm run lint --fix",
"format": "prettier --write .",
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css",
"test-generate-sw": "npm run build-generate-sw && GENERATE_SW=true vitest run && GENERATE_SW=true playwright test",
"test-generate-sw-node": "npm run build-generate-sw-node && NODE_ADAPTER=true GENERATE_SW=true vitest run && NODE_ADAPTER=true GENERATE_SW=true playwright test",
"test-inject-manifest": "npm run build-inject-manifest && vitest run && playwright test",
"test-inject-manifest-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run && NODE_ADAPTER=true playwright test",
"test": "npm run test-generate-sw && npm run test-generate-sw-node && npm run test-inject-manifest && npm run test-inject-manifest-node",
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css --minify",
"test:lighthouse": "npm run build-inject-manifest-node && lhci autorun",
"test:unit": "vitest run tests/unit",
"test:data": "vitest run tests/data",
"test:coverage": "vitest run tests/unit --coverage",
"test:fast": "npm run test:coverage && npm run test:data",
"test:integration": "node scripts/run-with-local-supabase.mjs vitest run --config vitest.integration.config.mts",
"test:bdd:generate": "bddgen",
"test:bdd:inner": "npm run build-inject-manifest && npm run test:bdd:generate && playwright test --project=chromium",
"test:bdd": "ALLOW_PROVIDER_ENDPOINT_OVERRIDES=true GOOGLE_OAUTH_CLIENT_ID=mock-google GOOGLE_OAUTH_CLIENT_SECRET=mock-google-secret DROPBOX_OAUTH_CLIENT_ID=mock-dropbox DROPBOX_OAUTH_CLIENT_SECRET=mock-dropbox-secret GOOGLE_OAUTH_AUTHORIZE_URL=http://127.0.0.1:4199/google/authorize GOOGLE_OAUTH_TOKEN_URL=http://127.0.0.1:4199/google/token GOOGLE_DRIVE_API_URL=http://127.0.0.1:4199/google/drive GOOGLE_DRIVE_UPLOAD_URL=http://127.0.0.1:4199/google/upload DROPBOX_OAUTH_AUTHORIZE_URL=http://127.0.0.1:4199/dropbox/authorize DROPBOX_OAUTH_TOKEN_URL=http://127.0.0.1:4199/dropbox/token DROPBOX_UPLOAD_URL=http://127.0.0.1:4199/dropbox/files/upload node scripts/run-with-local-supabase.mjs npm run test:bdd:inner",
"test:build:generate-static": "npm run build-generate-sw && GENERATE_SW=true vitest run --config vitest.build.config.mts",
"test:build:generate-node": "npm run build-generate-sw-node && NODE_ADAPTER=true GENERATE_SW=true vitest run --config vitest.build.config.mts",
"test:build:inject-static": "npm run build-inject-manifest && vitest run --config vitest.build.config.mts",
"test:build:inject-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run --config vitest.build.config.mts",
"test:build": "npm run test:build:generate-static && npm run test:build:generate-node && npm run test:build:inject-static && npm run test:build:inject-node",
"test:ci": "npm run test:fast && npm run test:integration && npm run test:build && npm run test:bdd && npm run test:performance",
"test": "npm run test:ci",
"supabase:start": "supabase start",
"supabase:stop": "supabase stop",
"supabase:reset": "supabase db reset",
"supabase:studio": "supabase studio",
"migrate:convert-tsv": "node scripts/convert-tsv-to-sql.js",
"dev:local": "./scripts/dev-local.sh && npm run dev",
"dev:supabase": "supabase start && npm run dev"
"dev:supabase": "supabase start && npm run dev",
"sprites:grid": "node scripts/grid-thumbnails.mjs",
"build:cloudflare": "DEPLOY_TARGET=cloudflare npm run build",
"preview:cloudflare": "wrangler dev",
"check:cloudflare": "wrangler deploy --dry-run",
"test:performance": "node scripts/run-with-local-supabase.mjs node scripts/performance/run-local.mjs"
},
"devDependencies": {
"@playwright/test": "^1.37.1",
"@lhci/cli": "^0.15.1",
"@playwright/test": "1.55.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-cloudflare": "7.2.9",
"@sveltejs/adapter-netlify": "^4.1.0",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/adapter-static": "^3.0.0",
@@ -47,30 +67,33 @@
"@typescript-eslint/parser": "^7.0.0",
"@vite-pwa/assets-generator": "^0.2.4",
"@vite-pwa/sveltekit": "^0.4.0",
"@vitest/coverage-v8": "^1.6.1",
"autoprefixer": "^10.4.19",
"daisyui": "^4.10.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"playwright-bdd": "^8.5.1",
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"sharp": "^0.33.4",
"supabase": "2.72.7",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
"tailwindcss": "^3.4.3",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vitest": "^1.0.4"
"vitest": "^1.0.4",
"wrangler": "4.131.2"
},
"type": "module",
"dependencies": {
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0",
"nanoid": "^5.0.4"
"@supabase/ssr": "^0.12.7",
"@supabase/supabase-js": "^2.116.0",
"nanoid": "^5.0.4",
"sharp": "^0.33.4"
},
"engines": {
"node": ">=18.13.0"
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}
}
+90 -73
View File
@@ -1,10 +1,18 @@
import { defineConfig, devices } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test';
import { defineBddConfig } from 'playwright-bdd';
const url = 'http://localhost:4173'
const url = 'http://localhost:4173';
const testDir = defineBddConfig({
features: 'tests/bdd/features/**/*.feature',
steps: ['tests/bdd/steps/**/*.ts', 'tests/bdd/fixtures.ts'],
outputDir: '.features-gen',
missingSteps: 'fail-on-gen'
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { nodeAdapter } from './adapter.mjs'
import { nodeAdapter } from './adapter.mjs';
/**
* Read environment variables from file.
@@ -16,81 +24,90 @@ import { nodeAdapter } from './adapter.mjs'
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './client-test',
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: 'test-results/',
timeout: 5 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 1000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'line',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: url,
//offline: true,
testDir,
globalTeardown: './tests/bdd/globalTeardown.ts',
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: 'test-results/',
timeout: 90 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 10 * 1000
},
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: 0,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [['line'], ['./tests/support/no-skips-reporter.ts']],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: url,
//offline: true,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
screenshot: 'only-on-failure'
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: nodeAdapter ? 'pnpm run preview-node' : 'pnpm run preview',
url,
reuseExistingServer: !process.env.CI,
},
/* Run your local dev server before starting the tests */
webServer: [
{
command: 'node scripts/mock-provider-server.mjs',
url: 'http://127.0.0.1:4199/__mock/state',
reuseExistingServer: !process.env.CI
},
{
command: nodeAdapter ? 'npm run preview-node' : 'npm run preview',
url,
reuseExistingServer: !process.env.CI
}
]
});
-7717
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+2 -2
View File
@@ -1,3 +1,3 @@
import process from 'node:process'
import process from 'node:process';
export const generateSW = process.env.GENERATE_SW === 'true'
export const generateSW = process.env.GENERATE_SW === 'true';
+133 -119
View File
@@ -17,167 +17,181 @@ const __dirname = path.dirname(__filename);
* Parse CSV file into array of objects
*/
function parseCSV(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
const headers = lines[0].split(',');
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n').filter((line) => line.trim());
const headers = lines[0].split(',');
return lines.slice(1).map(line => {
const values = parseCSVLine(line);
const obj = {};
headers.forEach((header, i) => {
obj[header] = values[i] || null;
});
return obj;
});
return lines.slice(1).map((line) => {
const values = parseCSVLine(line);
const obj = {};
headers.forEach((header, i) => {
obj[header] = values[i] || null;
});
return obj;
});
}
/**
* Parse a single CSV line, handling quoted values
*/
function parseCSVLine(line) {
const values = [];
let current = '';
let inQuotes = false;
const values = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
values.push(current);
current = '';
} else {
current += char;
}
}
values.push(current);
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
values.push(current);
current = '';
} else {
current += char;
}
}
values.push(current);
return values;
return values;
}
/**
* Generate migration SQL from CSV data
*/
function generateMigration(region) {
console.log(`\nGenerating migration for ${region}...`);
console.log(`\nGenerating migration for ${region}...`);
// Determine generation number from region
const regionToGen = {
'Kanto': 1, 'Johto': 2, 'Hoenn': 3, 'Sinnoh': 4,
'Unova': 5, 'Kalos': 6, 'Alola': 7, 'Galar': 8,
'Hisui': 8, 'Paldea': 9
};
const gen = regionToGen[region] || 1;
// Determine generation number from region
const regionToGen = {
Kanto: 1,
Johto: 2,
Hoenn: 3,
Sinnoh: 4,
Unova: 5,
Kalos: 6,
Alola: 7,
Galar: 8,
Hisui: 8,
Paldea: 9
};
const gen = regionToGen[region] || 1;
// 1. Load CSV files
const pokemonPath = path.join(__dirname, '..', 'data', 'pokemon', `gen${gen}-${region.toLowerCase()}.csv`);
const gamesPath = path.join(__dirname, '..', 'data', 'pokemon', 'games.csv');
// 1. Load CSV files
const pokemonPath = path.join(
__dirname,
'..',
'data',
'pokemon',
`gen${gen}-${region.toLowerCase()}.csv`
);
const gamesPath = path.join(__dirname, '..', 'data', 'pokemon', 'games.csv');
if (!fs.existsSync(pokemonPath)) {
console.error(`Error: Pokemon CSV not found at ${pokemonPath}`);
process.exit(1);
}
if (!fs.existsSync(pokemonPath)) {
console.error(`Error: Pokemon CSV not found at ${pokemonPath}`);
process.exit(1);
}
const pokemon = parseCSV(pokemonPath);
const games = parseCSV(gamesPath);
const pokemon = parseCSV(pokemonPath);
const games = parseCSV(gamesPath);
// 2. Filter for this region
const regionGames = games.filter(g => g.region === region);
// 2. Filter for this region
const regionGames = games.filter((g) => g.region === region);
if (regionGames.length === 0) {
console.error(`Error: No games found for region ${region} in games.csv`);
process.exit(1);
}
if (regionGames.length === 0) {
console.error(`Error: No games found for region ${region} in games.csv`);
process.exit(1);
}
// 3. Generate SQL
let sql = `-- Seed ${region} region Pokémon (Gen ${gen})\n`;
sql += `-- Auto-generated from CSV files\n\n`;
// 3. Generate SQL
let sql = `-- Seed ${region} region Pokémon (Gen ${gen})\n`;
sql += `-- Auto-generated from CSV files\n\n`;
// Region-game mappings
sql += `-- Insert ${region} region-game mappings\n`;
sql += `INSERT INTO region_game_mappings (region, game) VALUES\n`;
sql += regionGames.map(g => ` ('${region}', '${g.displayName}')`).join(',\n');
sql += `\nON CONFLICT (region, game) DO NOTHING;\n\n`;
// Region-game mappings
sql += `-- Insert ${region} region-game mappings\n`;
sql += `INSERT INTO region_game_mappings (region, game) VALUES\n`;
sql += regionGames.map((g) => ` ('${region}', '${g.displayName}')`).join(',\n');
sql += `\nON CONFLICT (region, game) DO NOTHING;\n\n`;
// Pokemon entries (without regional dex number)
sql += `-- Insert ${region} Pokémon entries\n`;
sql += `INSERT INTO pokedex_entries (\n`;
sql += ` "pokedexNumber",\n`;
sql += ` pokemon,\n`;
sql += ` form,\n`;
sql += ` "canGigantamax",\n`;
sql += ` "regionToCatchIn",\n`;
sql += ` "gamesToCatchIn"\n`;
sql += `) VALUES\n`;
// Pokemon entries (without regional dex number)
sql += `-- Insert ${region} Pokémon entries\n`;
sql += `INSERT INTO pokedex_entries (\n`;
sql += ` "pokedexNumber",\n`;
sql += ` pokemon,\n`;
sql += ` form,\n`;
sql += ` "canGigantamax",\n`;
sql += ` "regionToCatchIn",\n`;
sql += ` "gamesToCatchIn"\n`;
sql += `) VALUES\n`;
const rows = pokemon.map(p => {
const form = p.form ? `'${p.form}'` : 'NULL';
// Use regionalDexGames for the database (regional dex availability)
// originGames column is for future origin dex feature
const gamesField = p.regionalDexGames || p.games; // Fallback to old 'games' column for compatibility
const gamesList = gamesField.split('|');
const gamesArray = `ARRAY[${gamesList.map(g => `'${g}'`).join(', ')}]`;
const rows = pokemon.map((p) => {
const form = p.form ? `'${p.form}'` : 'NULL';
// Use regionalDexGames for the database (regional dex availability)
// originGames column is for future origin dex feature
const gamesField = p.regionalDexGames || p.games; // Fallback to old 'games' column for compatibility
const gamesList = gamesField.split('|');
const gamesArray = `ARRAY[${gamesList.map((g) => `'${g}'`).join(', ')}]`;
return `(${p.pokedexNumber}, '${p.name}', ${form}, false, '${region}', ${gamesArray})`;
});
return `(${p.pokedexNumber}, '${p.name}', ${form}, false, '${region}', ${gamesArray})`;
});
sql += rows.join(',\n');
sql += '\nON CONFLICT ON CONSTRAINT unique_pokemon_form DO NOTHING;\n\n';
sql += rows.join(',\n');
sql += '\nON CONFLICT ON CONSTRAINT unique_pokemon_form DO NOTHING;\n\n';
// Regional dex numbers (separate table)
sql += `-- Insert ${region} regional dex numbers\n`;
// Regional dex numbers (separate table)
sql += `-- Insert ${region} regional dex numbers\n`;
const dexRows = pokemon
.filter(p => p.regionalNumber) // Only entries with regional dex numbers
.map(p => {
const formCondition = p.form
? `form = '${p.form}'`
: `form IS NULL`;
const dexRows = pokemon
.filter((p) => p.regionalNumber) // Only entries with regional dex numbers
.map((p) => {
const formCondition = p.form ? `form = '${p.form}'` : `form IS NULL`;
return ` ((SELECT id FROM pokedex_entries WHERE "pokedexNumber" = ${p.pokedexNumber} AND ${formCondition}), '${region}', ${p.regionalNumber})`;
});
return ` ((SELECT id FROM pokedex_entries WHERE "pokedexNumber" = ${p.pokedexNumber} AND ${formCondition}), '${region}', ${p.regionalNumber})`;
});
if (dexRows.length > 0) {
sql += `INSERT INTO regional_dex_numbers (\n`;
sql += ` pokedex_entry_id,\n`;
sql += ` region,\n`;
sql += ` dex_number\n`;
sql += `) VALUES\n`;
sql += dexRows.join(',\n');
sql += ';\n\n';
} else {
sql += '-- No regional dex numbers for this region\n\n';
}
if (dexRows.length > 0) {
sql += `INSERT INTO regional_dex_numbers (\n`;
sql += ` pokedex_entry_id,\n`;
sql += ` region,\n`;
sql += ` dex_number\n`;
sql += `) VALUES\n`;
sql += dexRows.join(',\n');
sql += ';\n\n';
} else {
sql += '-- No regional dex numbers for this region\n\n';
}
// Add metadata
sql += `-- Add metadata\n`;
sql += `INSERT INTO metadata (key, value) VALUES\n`;
sql += ` ('${region.toLowerCase()}_seeded', 'true'),\n`;
sql += ` ('${region.toLowerCase()}_seed_date', NOW()::TEXT),\n`;
sql += ` ('${region.toLowerCase()}_pokemon_count', '${pokemon.filter(p => !p.form).length}');\n`;
// Add metadata
sql += `-- Add metadata\n`;
sql += `INSERT INTO metadata (key, value) VALUES\n`;
sql += ` ('${region.toLowerCase()}_seeded', 'true'),\n`;
sql += ` ('${region.toLowerCase()}_seed_date', NOW()::TEXT),\n`;
sql += ` ('${region.toLowerCase()}_pokemon_count', '${pokemon.filter((p) => !p.form).length}');\n`;
// 4. Write file
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
const filename = `${timestamp}_seed_${region.toLowerCase()}.sql`;
const outputPath = path.join(__dirname, '..', 'supabase', 'migrations', filename);
// 4. Write file
const timestamp = new Date()
.toISOString()
.replace(/[-:T.]/g, '')
.slice(0, 14);
const filename = `${timestamp}_seed_${region.toLowerCase()}.sql`;
const outputPath = path.join(__dirname, '..', 'supabase', 'migrations', filename);
fs.writeFileSync(outputPath, sql);
fs.writeFileSync(outputPath, sql);
console.log(`✓ Generated ${filename}`);
console.log(` - ${pokemon.length} Pokemon entries`);
console.log(` - ${dexRows.length} regional dex numbers`);
console.log(` - ${regionGames.length} games\n`);
console.log(`✓ Generated ${filename}`);
console.log(` - ${pokemon.length} Pokemon entries`);
console.log(` - ${dexRows.length} regional dex numbers`);
console.log(` - ${regionGames.length} games\n`);
return filename;
return filename;
}
// Run
const region = process.argv[2];
if (!region) {
console.error('Usage: node csv-to-migration.js <Region>');
console.error('Example: node csv-to-migration.js Kanto');
process.exit(1);
console.error('Usage: node csv-to-migration.js <Region>');
console.error('Example: node csv-to-migration.js Kanto');
process.exit(1);
}
generateMigration(region);
+35
View File
@@ -0,0 +1,35 @@
import { readdir, mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
// Bump the URL version whenever dimensions, quality or source artwork changes.
const source = 'static/sprites-small/home';
const destination = 'static/sprites-grid/v1/home';
const files = [];
async function walk(relative = '') {
for (const entry of await readdir(path.join(source, relative), { withFileTypes: true })) {
const name = path.join(relative, entry.name);
if (entry.isDirectory()) await walk(name);
else if (entry.name.endsWith('.webp')) files.push(name);
}
}
await walk();
const manifest = [];
for (const relative of files.sort()) {
const target = path.join(destination, relative);
await mkdir(path.dirname(target), { recursive: true });
try {
await stat(target);
} catch {
await sharp(path.join(source, relative))
.resize(128, 128, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(target);
}
manifest.push({ path: relative.split(path.sep).join('/'), bytes: (await stat(target)).size });
}
await writeFile(
'static/sprites-grid/v1/manifest.json',
JSON.stringify({ version: 1, width: 128, quality: 80, files: manifest })
);
console.log(`Prepared ${files.length} versioned grid thumbnails; originals preserved.`);
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
import { createServer } from 'node:http';
const port = Number(process.env.MOCK_PROVIDER_PORT ?? 4199);
const state = { requests: [], failUploads: false, revokeRefresh: false, refreshes: 0 };
function send(response, status, body, headers = {}) {
response.writeHead(status, { 'Content-Type': 'application/json', ...headers });
response.end(typeof body === 'string' ? body : JSON.stringify(body));
}
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
let body = '';
for await (const chunk of request) body += chunk;
// Control-plane calls (including Playwright's webServer readiness polling of /__mock/state)
// must not show up as provider traffic the assertions then reason about.
if (!url.pathname.startsWith('/__mock/')) {
state.requests.push({ method: request.method, path: url.pathname, query: url.search, body });
}
if (url.pathname === '/__mock/state') return send(response, 200, state);
if (url.pathname === '/__mock/reset') {
state.requests = [];
state.failUploads = false;
state.revokeRefresh = false;
state.refreshes = 0;
return send(response, 200, { ok: true });
}
if (url.pathname === '/__mock/fail-uploads') {
state.failUploads = true;
return send(response, 200, { ok: true });
}
if (url.pathname === '/__mock/revoke-refresh') {
state.revokeRefresh = true;
return send(response, 200, { ok: true });
}
if (url.pathname.endsWith('/authorize')) {
const redirectUri = url.searchParams.get('redirect_uri');
const oauthState = url.searchParams.get('state');
if (!redirectUri || !oauthState) return send(response, 400, { error: 'missing redirect data' });
const callback = new URL(redirectUri);
callback.searchParams.set('code', 'mock-authorization-code');
callback.searchParams.set('state', oauthState);
response.writeHead(302, { Location: callback.toString() });
return response.end();
}
if (url.pathname.endsWith('/token')) {
if (body.includes('grant_type=refresh_token')) {
state.refreshes++;
// Mirrors Google and Dropbox answering a revoked or expired refresh token.
if (state.revokeRefresh) {
return send(response, 400, { error: 'invalid_grant', error_description: 'Bad Request' });
}
}
return send(response, 200, {
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
expires_in: 3600,
scope: 'files.content.write drive.file'
});
}
if (url.pathname.includes('/upload') || url.pathname.endsWith('/files/upload')) {
if (state.failUploads) return send(response, 503, { error: 'mock upload failure' });
return send(response, 200, { id: 'mock-file-id', name: 'pokedex.csv' });
}
if (url.pathname.endsWith('/drive/files')) {
if (request.method === 'GET') return send(response, 200, { files: [{ id: 'mock-folder-id' }] });
return send(response, 200, { id: 'mock-folder-id' });
}
return send(response, 404, { error: `No mock route for ${url.pathname}` });
});
server.listen(port, '127.0.0.1', () => {
console.log(`Mock provider server listening on http://127.0.0.1:${port}`);
});
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => server.close(() => process.exit(0)));
}
+3 -1
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import process from 'node:process';
import { mkdir, readdir, rename } from 'node:fs/promises';
import sharp from 'sharp';
import { writeSpriteManifest } from './sprite-manifest.mjs';
const inputDir = process.env.SPRITE_INPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites');
const outputDir =
@@ -69,4 +70,5 @@ for (const [index, file] of files.entries()) {
}
}
console.log('Sprite optimization complete.');
const manifest = await writeSpriteManifest(outputDir);
console.log(`Sprite optimization complete. Manifest lists ${manifest.files.length} sprites.`);
+181
View File
@@ -0,0 +1,181 @@
import { chromium } from '@playwright/test';
import { readFile, writeFile } from 'node:fs/promises';
// Configuration holds session-file paths, never passwords. Keep it outside the repository.
const [configPath, outputPath] = process.argv.slice(2);
if (!configPath || !outputPath)
throw new Error('Usage: node scripts/performance/benchmark.mjs CONFIG.json RESULTS.json');
const config = JSON.parse(await readFile(configPath, 'utf8'));
const samples = config.samples ?? 30;
if (samples < 30)
throw new Error('Host decisions require at least 30 warm samples per fixture/navigation');
if (!config.revision || !config.databaseLabel || !config.clientLocation)
throw new Error('Record revision, databaseLabel and clientLocation for comparable results');
const browser = await chromium.launch();
const results = [];
try {
for (const host of config.hosts) {
for (const fixture of host.fixtures) {
for (const navigation of ['direct', 'client']) {
const context = await browser.newContext({
baseURL: host.url,
storageState: host.storageState,
viewport: { width: 1350, height: 940 },
deviceScaleFactor: 1
});
await context.addInitScript(() => {
window.__perfShifts = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
if (!entry.hadRecentInput)
window.__perfShifts.push({ value: entry.value, time: entry.startTime });
}).observe({ type: 'layout-shift', buffered: true });
window.__watchGrid = () => {
window.__perfVisible = null;
function check() {
const cell = document.querySelector('[data-entry-index]');
if (
location.pathname.startsWith('/pokedex/') &&
cell &&
cell.getBoundingClientRect().top < innerHeight &&
cell.getBoundingClientRect().height > 0
) {
window.__perfVisible = performance.now();
performance.mark('pokedex:first-visible');
} else requestAnimationFrame(check);
}
requestAnimationFrame(check);
};
window.__watchGrid();
});
const page = await context.newPage();
for (let index = 0; index <= samples; index++) {
if (navigation === 'client') {
await page.goto('/my-pokedexes');
await page.getByText(fixture.name, { exact: true }).first().waitFor();
} else if (index > 0) await page.goto('about:blank');
const bodies = [];
const requests = { grid: 0, details: 0, snapshot: 0, backup: 0 };
const onRequest = (request) => {
const path = new URL(request.url()).pathname;
if (/\/pokedexes\/[^/]+\/(grid|combined-data)$/.test(path)) requests.grid++;
if (/\/pokedexes\/[^/]+\/entries\//.test(path)) requests.details++;
if (path === '/api/offline-snapshot') requests.snapshot++;
if (path === '/api/export-integrations') requests.backup++;
};
const onResponse = (response) => {
const path = new URL(response.url()).pathname;
if (!path.startsWith(`/pokedex/${fixture.id}`)) return;
bodies.push(
(async () => {
// Chromium may not expose bodies routed through a service worker.
const body = await response.body().catch(() => null);
return {
url: response.url(),
bytes: body?.length ?? null,
status: response.status(),
fromServiceWorker: response.fromServiceWorker(),
timings: response.request().timing(),
serverTiming: response.headers()['server-timing'] ?? null
};
})()
);
};
page.on('request', onRequest);
page.on('response', onResponse);
let started = 0;
if (navigation === 'direct')
await page.goto(`/pokedex/${fixture.id}`, { waitUntil: 'domcontentloaded' });
else {
started = await page.evaluate(() => {
performance.clearMarks('pokedex:first-interactive');
window.__watchGrid();
return performance.now();
});
await page
.locator('.card')
.filter({ hasText: fixture.name })
.first()
.getByRole('button', { name: 'View', exact: true })
.click();
}
await page.waitForSelector('[data-grid-interactive]');
await page.waitForFunction(
() =>
window.__perfVisible !== null &&
performance.getEntriesByName('pokedex:first-interactive').length > 0
);
await page.waitForTimeout(1500);
const browserData = await page.evaluate(
(start) => ({
visibleMs: window.__perfVisible - start,
interactiveMs:
performance.getEntriesByName('pokedex:first-interactive').at(-1).startTime - start,
cells: document.querySelectorAll('[data-entry-index]').length,
dom: document.querySelectorAll('*').length,
cls: window.__perfShifts
.filter((entry) => entry.time >= start)
.reduce((total, entry) => total + entry.value, 0)
}),
started
);
page.off('request', onRequest);
page.off('response', onResponse);
const resources = await page.evaluate(() =>
performance.getEntriesByType('resource').map((entry) => ({
url: entry.name,
decodedBytes: entry.decodedBodySize,
encodedBytes: entry.encodedBodySize,
transferBytes: entry.transferSize
}))
);
const responses = (await Promise.all(bodies)).map(({ url, ...response }) => {
const resource = resources.findLast((entry) => entry.url === url);
return {
...response,
bytes: response.bytes ?? (resource?.decodedBytes || null),
byteSource: response.bytes !== null ? 'response-body' : 'resource-timing',
encodedBytes: resource?.encodedBytes ?? null,
transferBytes: resource?.transferBytes ?? null
};
});
results.push({
host: host.label,
fixture: fixture.label,
navigation,
run: index === 0 ? 'first-observed' : 'warm',
...browserData,
requests,
responses
});
}
await context.close();
console.log(
`Completed ${host.label}/${fixture.label}/${navigation}: ${samples} warm samples.`
);
}
}
}
await writeFile(
outputPath,
JSON.stringify(
{
version: 1,
capturedAt: new Date().toISOString(),
revision: config.revision,
databaseLabel: config.databaseLabel,
clientLocation: config.clientLocation,
environment: config.environment ?? 'deployed',
compatibilityPassed: config.compatibilityPassed ?? false,
results
},
null,
2
)
);
console.log(
`Recorded ${results.length} samples; first-observed runs are excluded from warm statistics.`
);
} finally {
await browser.close();
}
+46
View File
@@ -0,0 +1,46 @@
import { readFile } from 'node:fs/promises';
const data = JSON.parse(await readFile(process.argv[2], 'utf8'));
const warm = data.results.filter((row) => row.run === 'warm');
const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.75) - 1];
const groups = [...new Set(warm.map((row) => `${row.fixture}/${row.navigation}`))];
const paired = groups.map((group) => {
const rows = (host) =>
warm.filter((row) => `${row.fixture}/${row.navigation}` === group && row.host === host);
for (const host of ['netlify', 'cloudflare'])
if (rows(host).length < 30) throw new Error(`Insufficient ${host} warm samples for ${group}`);
return {
group,
netlify: p75(rows('netlify').map((row) => row.interactiveMs)),
cloudflare: p75(rows('cloudflare').map((row) => row.interactiveMs))
};
});
if (groups.length !== 4)
throw new Error(
'Expected national and scoped-form fixtures, each with direct and client navigation'
);
const netlify = p75(warm.filter((row) => row.host === 'netlify').map((row) => row.interactiveMs));
const cloudflare = p75(
warm.filter((row) => row.host === 'cloudflare').map((row) => row.interactiveMs)
);
const improvement = netlify - cloudflare;
const passes =
improvement >= 200 &&
improvement / netlify >= 0.2 &&
paired.every((row) => row.cloudflare <= row.netlify * 1.1);
console.log(
JSON.stringify(
{
overallP75: { netlify, cloudflare },
improvementMs: improvement,
improvementPercent: (100 * improvement) / netlify,
groups: paired,
performanceGatePassed: passes,
recommendation:
passes && data.compatibilityPassed && data.environment === 'deployed'
? 'Cloudflare qualifies for a migration proposal; review operating cost before cutover.'
: 'Retain Netlify: performance, deployed evidence or compatibility gate is unmet.'
},
null,
2
)
);
+113
View File
@@ -0,0 +1,113 @@
import { createClient } from '@supabase/supabase-js';
import { createServerClient } from '@supabase/ssr';
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
const directory = process.env.PERF_FIXTURE_DIR ?? '/tmp/livingdex-grid-performance';
const url = process.env.PUBLIC_SUPABASE_URL;
if (!url || !['localhost', '127.0.0.1', '[::1]'].includes(new URL(url).hostname))
throw new Error('Fixtures require the local Supabase wrapper');
const admin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false }
});
const check = ({ data, error }) => {
if (error) throw error;
return data;
};
await mkdir(directory, { recursive: true, mode: 0o700 });
const fixturePath = `${directory}/fixture.json`;
if (process.argv.includes('--cleanup')) {
const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
const { user } = check(await admin.auth.admin.getUserById(fixture.userId));
if (user.email !== fixture.email || !user.email.startsWith('grid-performance-'))
throw new Error('Refusing to remove a non-fixture account');
check(await admin.auth.admin.deleteUser(fixture.userId));
await unlink(fixturePath);
await unlink(`${directory}/storage-state.json`).catch(() => {});
console.log('Removed disposable performance account and session.');
process.exit(0);
}
try {
await readFile(fixturePath);
throw new Error('An existing fixture needs cleanup first');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
const email = `grid-performance-${randomUUID()}@example.test`;
const password = randomUUID() + 'aA1!';
const { user } = check(await admin.auth.admin.createUser({ email, password, email_confirm: true }));
const fixture = { userId: user.id, email, password, dexes: [] };
await writeFile(fixturePath, JSON.stringify(fixture), { mode: 0o600 });
fixture.dexes = check(
await admin
.from('pokedexes')
.insert([
{ userId: user.id, name: 'Performance National', isLivingDex: true, isFormDex: false },
{
userId: user.id,
name: 'Performance Scarlet Forms',
isLivingDex: true,
isFormDex: true,
gameScope: 'Scarlet'
}
])
.select()
);
const game = fixture.dexes.find((dex) => dex.gameScope);
check(
await admin.from('pokedex_dex_scopes').insert({ pokedexId: game.id, dexId: 'scarlet-paldea' })
);
const entries = [];
for (let from = 0; ; from += 1000) {
const page = check(
await admin
.from('pokemon')
.select('id,isDefaultForm')
.order('id')
.range(from, from + 999)
);
entries.push(...page);
if (page.length < 1000) break;
}
for (const dex of fixture.dexes) {
const selected = entries.filter((entry) => dex.isFormDex || entry.isDefaultForm);
for (let from = 0; from < selected.length; from += 500) {
check(
await admin.from('catch_records').insert(
selected.slice(from, from + 500).map((entry, index) => ({
userId: user.id,
pokedexId: dex.id,
pokemonId: entry.id,
caught: (from + index) % 3 === 0,
haveToEvolve: (from + index) % 3 === 1,
inHome: (from + index) % 7 === 0,
personalNotes: index === 0 ? 'Performance fixture note: preserve me' : ''
}))
)
);
}
}
await writeFile(fixturePath, JSON.stringify(fixture), { mode: 0o600 });
const cookies = [];
const client = createServerClient(url, process.env.PUBLIC_SUPABASE_ANON_KEY, {
cookies: { getAll: () => [], setAll: (values) => cookies.push(...values) }
});
check(await client.auth.signInWithPassword({ email, password }));
const base = new URL(process.env.PERF_BASE_URL ?? 'http://127.0.0.1:4173');
await writeFile(
`${directory}/storage-state.json`,
JSON.stringify({
cookies: cookies.map(({ name, value }) => ({
name,
value,
domain: base.hostname,
path: '/',
httpOnly: false,
secure: base.protocol === 'https:',
sameSite: 'Lax'
})),
origins: []
}),
{ mode: 0o600 }
);
console.log('Prepared national and scoped-form fixtures and a private browser session.');
+105
View File
@@ -0,0 +1,105 @@
import { spawn } from 'node:child_process';
import { mkdtemp, mkdir, cp, rm, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const directory = await mkdtemp(path.join(os.tmpdir(), 'livingdex-grid-tests-'));
const port = process.env.PERF_PORT ?? '4185';
const env = {
...process.env,
NODE_ADAPTER: 'true',
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true',
POKEDEX_PERFORMANCE: 'true',
PERF_FIXTURE_DIR: directory,
PERF_BASE_URL: `http://127.0.0.1:${port}`,
PORT: port,
HOST: '127.0.0.1'
};
function run(command, args) {
return new Promise((resolve, reject) => {
const process = spawn(command, args, { env, stdio: 'inherit' });
process.once('error', reject);
process.once('exit', (code) =>
code === 0 ? resolve() : reject(new Error(`${command} failed (${code})`))
);
});
}
let server;
let fixture = false;
try {
await run('npm', ['run', 'build-inject-manifest-node']);
fixture = true;
await run('node', ['scripts/performance/fixture.mjs']);
server = spawn('node', ['build'], { env, stdio: 'inherit' });
await new Promise((resolve, reject) => {
const timer = setInterval(async () => {
try {
if ((await fetch(env.PERF_BASE_URL)).ok) {
clearInterval(timer);
clearTimeout(timeout);
resolve();
}
} catch {}
}, 200);
const timeout = setTimeout(() => {
clearInterval(timer);
reject(new Error('Local production server did not start'));
}, 15000);
server.once('error', (error) => {
clearInterval(timer);
clearTimeout(timeout);
reject(error);
});
server.once('exit', (code) => {
clearInterval(timer);
clearTimeout(timeout);
reject(new Error(`Local server exited (${code})`));
});
});
if (process.env.PERF_BENCHMARK === 'true') {
const data = JSON.parse(await readFile(path.join(directory, 'fixture.json'), 'utf8'));
await writeFile(
path.join(directory, 'benchmark-config.json'),
JSON.stringify({
samples: 30,
revision: process.env.PERF_REVISION ?? 'local-working-tree',
databaseLabel: 'local-seeded-supabase',
clientLocation: 'local-loopback',
environment: 'local-node',
hosts: [
{
label: 'node',
url: env.PERF_BASE_URL,
storageState: path.join(directory, 'storage-state.json'),
fixtures: data.dexes.map((dex) => ({
id: dex.id,
name: dex.name,
label: dex.gameScope ? 'scoped-forms' : 'national'
}))
}
]
})
);
await run('node', [
'scripts/performance/benchmark.mjs',
path.join(directory, 'benchmark-config.json'),
path.join(directory, 'benchmark.json')
]);
await mkdir('test-results/performance', { recursive: true });
await cp(path.join(directory, 'benchmark.json'), 'test-results/performance/benchmark.json');
}
await run('node', ['scripts/performance/verify.mjs']);
await run('node', ['scripts/performance/verify-behavior.mjs']);
await mkdir('test-results/performance', { recursive: true });
for (const file of ['verification.json', 'behavior.json', 'national.png', 'scoped.png'])
await cp(path.join(directory, file), path.join('test-results/performance', file));
} finally {
server?.kill('SIGTERM');
if (fixture)
await run('node', ['scripts/performance/fixture.mjs', '--cleanup']).catch((error) => {
throw new Error(`Fixture cleanup failed; retained recovery files in ${directory}`, {
cause: error
});
});
await rm(directory, { recursive: true, force: true });
}
+165
View File
@@ -0,0 +1,165 @@
import { chromium } from '@playwright/test';
import { readFile, writeFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
const dir = process.env.PERF_FIXTURE_DIR ?? '/tmp/livingdex-grid-performance';
const fixture = JSON.parse(await readFile(`${dir}/fixture.json`, 'utf8'));
const browser = await chromium.launch();
const context = await browser.newContext({
baseURL: process.env.PERF_BASE_URL ?? 'http://127.0.0.1:4173',
storageState: `${dir}/storage-state.json`,
viewport: { width: 1350, height: 940 }
});
await context.addInitScript(() => {
window.__cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) if (!entry.hadRecentInput) window.__cls += entry.value;
}).observe({ type: 'layout-shift', buffered: true });
});
const page = await context.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const national = fixture.dexes.find((dex) => !dex.gameScope);
const scoped = fixture.dexes.find((dex) => dex.gameScope);
const results = [];
try {
await page.goto(`/pokedex/${national.id}`);
await page.waitForSelector('[data-grid-interactive]');
for (const width of [1350, 390]) {
await page.setViewportSize({ width, height: 940 });
for (const density of ['comfortable', 'compact', 'ultra']) {
await page.getByLabel('Choose box view layout density').selectOption(density);
await page.reload();
await page.waitForSelector('[data-grid-interactive]');
await page.waitForTimeout(350);
assert.equal(await page.getByLabel('Choose box view layout density').inputValue(), density);
const geometry = await page.evaluate(() => {
const cell = document.querySelector('[data-entry-index="0"]').getBoundingClientRect();
const shell = document.querySelector('[data-box-number="1"]').getBoundingClientRect();
return {
cellWidth: cell.width,
cellHeight: cell.height,
cls: window.__cls,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth,
shellHeight: shell.height
};
});
assert.ok(Math.abs(geometry.cellWidth - geometry.cellHeight) < 1, JSON.stringify(geometry));
assert.ok(!geometry.horizontalOverflow, `Horizontal overflow at ${width}/${density}`);
assert.ok(geometry.cls <= 0.1, `CLS ${geometry.cls} at ${width}/${density}`);
results.push({ width, density, ...geometry });
}
}
await page.setViewportSize({ width: 1350, height: 940 });
await page.getByLabel('Choose box view layout density').selectOption('comfortable');
await page.getByLabel('Not caught', { exact: true }).check();
assert.equal(await page.locator('[data-entry-index="0"]').getAttribute('aria-disabled'), 'true');
assert.equal(await page.locator('[data-entry-index="0"]').getAttribute('data-entry-id'), '1');
await page.getByLabel('Not caught', { exact: true }).uncheck();
// Resizing across the mobile breakpoint keeps the same box at the scroll anchor.
await page.locator('[data-box-number="15"]').evaluate((node) => node.scrollIntoView());
await page.waitForTimeout(100);
const anchorTop = await page
.locator('[data-box-number="15"]')
.evaluate((node) => node.getBoundingClientRect().top);
await page.setViewportSize({ width: 390, height: 940 });
await page.waitForTimeout(200);
const resizedTop = await page
.locator('[data-box-number="15"]')
.evaluate((node) => node.getBoundingClientRect().top);
assert.ok(
Math.abs(anchorTop - resizedTop) < 2,
`Scroll anchor moved: ${anchorTop} to ${resizedTop}`
);
await page.setViewportSize({ width: 1350, height: 940 });
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForSelector('[data-entry-index="1024"]');
assert.ok((await page.locator('[data-entry-index]').count()) < 300);
await page.locator('[data-entry-index="1024"]').focus();
await page.keyboard.press('ArrowRight');
assert.equal(
await page.evaluate(() => document.activeElement.getAttribute('data-entry-index')),
'1024'
);
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForSelector('[data-entry-index="0"]');
// Closing an in-flight modal must prevent its response from replacing the next selection.
let release;
const held = new Promise((resolve) => {
release = resolve;
});
await page.route(`**/api/pokedexes/${national.id}/entries/1`, async (route) => {
await held;
await route.continue().catch(() => {});
});
await page.locator('[data-entry-index="0"]').click();
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
await page.locator('[data-entry-index="1"]').click();
release();
await page.getByRole('dialog').getByLabel('Notes:', { exact: true }).waitFor();
assert.equal(
await page.getByRole('dialog').getByRole('heading', { name: 'Ivysaur', exact: true }).count(),
1
);
assert.equal(
await page.getByRole('dialog').getByRole('heading', { name: 'Bulbasaur', exact: true }).count(),
0
);
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
await page.unrouteAll({ behavior: 'wait' });
// Navigate through actual list cards, preserving fixture-specific totals and positions.
await page.getByRole('link', { name: 'My Pokédexes' }).click();
await page
.locator('.card')
.filter({ hasText: scoped.name })
.first()
.getByRole('button', { name: 'View', exact: true })
.click();
await page.waitForSelector('[data-grid-interactive]');
assert.ok((await page.locator('body').innerText()).includes('Showing 439 of 439'));
await page.goBack();
await page
.locator('.card')
.filter({ hasText: national.name })
.first()
.getByRole('button', { name: 'View', exact: true })
.click();
await page.waitForSelector('[data-grid-interactive]');
// Wait for the existing worker's full snapshot, then read an uncached detail without a network.
await page.waitForFunction(
async () => {
const meta = await (
await caches.open('livingdex-offline-meta-v1')
).match('/__offline/current');
return !!(await meta?.json())?.dataCache;
},
{ timeout: 15000 }
);
await context.setOffline(true);
await page.locator('[data-entry-index="5"]').click();
await page.waitForFunction(() =>
document.querySelector('[role="dialog"]')?.textContent.includes('Where to catch:')
);
assert.equal(await page.getByRole('dialog').locator('textarea').count(), 0);
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
// A mismatched cache owner is never used as fallback.
await page.evaluate(async () => {
const cache = await caches.open('livingdex-offline-meta-v1');
const response = await cache.match('/__offline/current');
const meta = await response.json();
await cache.put(
'/__offline/current',
new Response(JSON.stringify({ ...meta, userId: 'other-account' }))
);
});
await page.locator('[data-entry-index="6"]').click();
await page.getByRole('alert').filter({ hasText: 'not saved for offline use' }).waitFor();
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
assert.deepEqual(errors, []);
await writeFile(`${dir}/behavior.json`, JSON.stringify(results, null, 2));
console.log(
'Passed density/mobile geometry, filtered placement, virtual boundaries, modal races, client navigation and isolated offline details.'
);
console.log(JSON.stringify(results, null, 2));
} finally {
await browser.close();
}
+129
View File
@@ -0,0 +1,129 @@
import { chromium } from '@playwright/test';
import { readFile, writeFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
const directory = process.env.PERF_FIXTURE_DIR ?? '/tmp/livingdex-grid-performance';
const fixture = JSON.parse(await readFile(`${directory}/fixture.json`, 'utf8'));
const baseURL = process.env.PERF_BASE_URL ?? 'http://127.0.0.1:4173';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
baseURL,
storageState: `${directory}/storage-state.json`,
viewport: { width: 1350, height: 940 }
});
await context.addInitScript(() => {
window.__layoutShifts = [];
window.__firstVisible = null;
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
if (!entry.hadRecentInput) window.__layoutShifts.push(entry.value);
}).observe({ type: 'layout-shift', buffered: true });
function visible() {
const cell = document.querySelector('[data-entry-index]');
if (
cell &&
cell.getBoundingClientRect().height > 0 &&
cell.getBoundingClientRect().top < innerHeight
)
window.__firstVisible ??= performance.now();
if (window.__firstVisible === null) requestAnimationFrame(visible);
}
requestAnimationFrame(visible);
});
const page = await context.newPage();
const readJson = (path) =>
page.evaluate(async (path) => {
const response = await fetch(path);
if (!response.ok) throw new Error(`Fixture API failed: ${response.status}`);
return response.json();
}, path);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const requests = [];
page.on('request', (request) => requests.push(new URL(request.url()).pathname));
const results = [];
try {
for (const dex of fixture.dexes) {
requests.length = 0;
const response = await page.goto(`/pokedex/${dex.id}`);
assert.equal(response.status(), 200);
const html = await response.text();
assert.ok(/data-entry-index=["']?0["'\s>]/.test(html), 'Initial cells must be server rendered');
await page.waitForSelector('[data-grid-interactive]');
await page.waitForTimeout(1000);
const stats = await page.evaluate(() => ({
cells: document.querySelectorAll('[data-entry-index]').length,
dom: document.querySelectorAll('*').length,
cls: window.__layoutShifts.reduce((sum, value) => sum + value, 0),
firstVisible: window.__firstVisible,
interactive: performance.getEntriesByName('pokedex:first-interactive').at(-1)?.startTime
}));
assert.ok(stats.cells <= 180, `Mounted cells: ${stats.cells}`);
assert.ok(stats.dom < 2500, `DOM elements: ${stats.dom}`);
assert.ok(stats.cls <= 0.1, `CLS: ${stats.cls}`);
assert.equal(
requests.filter((path) => /\/api\/pokedexes\/[^/]+\/(grid|combined-data)$/.test(path)).length,
0
);
assert.equal(await page.locator('button button').count(), 0);
await page.screenshot({ path: `${directory}/${dex.gameScope ? 'scoped' : 'national'}.png` });
const first = page.locator('[data-entry-index="0"]');
const entryId = await first.getAttribute('data-entry-id');
const notes = await readJson(`/api/pokedexes/${dex.id}/entries/${entryId}`);
await first.click();
const modal = page.getByRole('dialog', { name: 'Pokémon details' });
await modal.getByLabel('Notes:', { exact: true }).waitFor();
assert.equal(
await modal.getByLabel('Notes:', { exact: true }).inputValue(),
notes.catchRecord.personalNotes
);
const image = modal.locator('img').first();
await image.waitFor();
await page.waitForFunction(
() => document.querySelector('[role="dialog"] img')?.naturalWidth > 0
);
assert.equal(await image.evaluate((node) => node.naturalWidth), 512);
assert.ok(!(await image.getAttribute('src')).includes('sprites-grid'));
await modal.getByRole('button', { name: 'Close', exact: true }).click();
assert.equal(await first.evaluate((node) => document.activeElement === node), true);
// Reopening uses cached details; bulk edits must retain the unloaded personal notes.
const detailCalls = () =>
requests.filter((path) => path.endsWith(`/entries/${entryId}`)).length;
const before = detailCalls();
await first.click();
await modal.getByLabel('Notes:', { exact: true }).waitFor();
assert.equal(detailCalls(), before);
await modal.getByRole('button', { name: 'Close', exact: true }).click();
await page.getByRole('button', { name: 'Open bulk actions menu' }).first().click();
await page.getByRole('button', { name: 'Mark box as In HOME', exact: true }).click();
await page.waitForResponse(
(r) =>
r.url().endsWith(`/pokedexes/${dex.id}/catch-records`) && r.request().method() === 'POST'
);
const after = await readJson(`/api/pokedexes/${dex.id}/entries/${entryId}`);
assert.equal(after.catchRecord.personalNotes, notes.catchRecord.personalNotes);
assert.equal(after.catchRecord.inHome, true);
// Focus navigation must reach entries that were not initially mounted.
await page.locator('[data-entry-index="29"]').focus();
for (let index = 0; index < 22; index++) await page.keyboard.press('ArrowDown');
assert.equal(
await page.evaluate(() => document.activeElement?.getAttribute('data-entry-index')),
'161'
);
await page.getByLabel('Render all boxes').check();
const grid = await readJson(`/api/pokedexes/${dex.id}/grid`);
assert.equal(await page.locator('[data-entry-index]').count(), grid.grid.length);
await page.getByLabel('Render all boxes').uncheck();
results.push({
fixture: dex.gameScope ? 'scoped-forms' : 'national',
...stats,
documentBytes: Buffer.byteLength(html),
gridBytes: Buffer.byteLength(JSON.stringify(grid))
});
}
assert.deepEqual(errors, [], 'Browser errors');
await writeFile(`${directory}/verification.json`, JSON.stringify(results, null, 2));
console.log(JSON.stringify(results, null, 2));
} finally {
await browser.close();
}
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
const [, , command, ...args] = process.argv;
if (!command) {
console.error('Usage: node scripts/run-with-local-supabase.mjs <command> [...args]');
process.exit(2);
}
const SETUP_HINT = 'Run "npm run supabase:start" followed by "npm run supabase:reset".';
// npx is a shell script on Windows, where spawn needs a shell to find it.
const useShell = process.platform === 'win32';
function fail(message, detail) {
console.error(message);
if (detail) console.error(String(detail).trim());
process.exit(1);
}
const status = spawnSync('npx', ['supabase', 'status', '--output', 'json'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
shell: useShell
});
if (status.error) {
fail(
'Unable to run "npx supabase status" - is the supabase CLI installed?',
status.error.message
);
}
if (status.status !== 0) {
const stderr = status.stderr ?? '';
// Distinguish a stopped stack from a genuinely broken CLI invocation, so the hint is only
// printed when it is actually the advice the reader needs.
if (/not running|supabase start/i.test(stderr)) {
fail(`Local Supabase is required but is not running.\n${SETUP_HINT}`, stderr);
}
fail(`"supabase status" failed with exit code ${status.status}.`, stderr);
}
let values;
try {
values = JSON.parse(status.stdout);
} catch (error) {
fail('Unable to parse "supabase status --output json".', error);
}
const apiUrl = values.API_URL ?? values.api_url ?? 'http://127.0.0.1:54321';
const anonKey = values.ANON_KEY ?? values.PUBLISHABLE_KEY ?? values.anon_key;
const serviceRoleKey = values.SERVICE_ROLE_KEY ?? values.SECRET_KEY ?? values.service_role_key;
if (!anonKey || !serviceRoleKey) {
fail(`Supabase status did not return an anonymous and service-role key.\n${SETUP_HINT}`);
}
function isLoopbackUrl(value) {
try {
return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(value).hostname);
} catch {
return false;
}
}
if (!isLoopbackUrl(apiUrl)) {
fail(`Refusing to run local-stack tests against non-loopback Supabase URL: ${apiUrl}`);
}
const providerUrlVariables = [
'MOCK_PROVIDER_URL',
'GOOGLE_OAUTH_AUTHORIZE_URL',
'GOOGLE_OAUTH_TOKEN_URL',
'GOOGLE_DRIVE_API_URL',
'GOOGLE_DRIVE_UPLOAD_URL',
'DROPBOX_OAUTH_AUTHORIZE_URL',
'DROPBOX_OAUTH_TOKEN_URL',
'DROPBOX_UPLOAD_URL'
];
for (const name of providerUrlVariables) {
const value = process.env[name];
if (value && !isLoopbackUrl(value)) {
fail(`Refusing to run provider tests with non-loopback ${name}: ${value}`);
}
}
// A running-but-unseeded database is the most common broken state, and it surfaces downstream as
// a confusing assertion failure. Check it here instead.
const probe = await fetch(`${apiUrl}/rest/v1/pokedex_entries?select=id&limit=1`, {
headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` }
}).catch((error) => {
fail(`Unable to reach the local Supabase REST API at ${apiUrl}.\n${SETUP_HINT}`, error);
});
if (!probe.ok) {
fail(
`The local Supabase database has no readable pokedex_entries (HTTP ${probe.status}).\n${SETUP_HINT}`,
await probe.text()
);
}
if (((await probe.json()) ?? []).length === 0) {
fail(`The local Supabase database is empty - migrations or seeds have not run.\n${SETUP_HINT}`);
}
const child = spawnSync(command, args, {
stdio: 'inherit',
shell: useShell,
env: {
...process.env,
// Never allow an exported production value to redirect a local integration run.
PUBLIC_SUPABASE_URL: apiUrl,
PUBLIC_SUPABASE_ANON_KEY: anonKey,
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
TEST_SUPABASE_URL: apiUrl,
TEST_SUPABASE_ANON_KEY: anonKey,
E2E_SERVICE_ROLE_KEY: serviceRoleKey
}
});
if (child.error) fail(`Unable to run "${command}".`, child.error.message);
// A signalled child reports status === null; exiting 0 there would hide the failure.
process.exit(child.signal ? 1 : child.status ?? 1);
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Lists every sprite (all forms, shiny and female variants) with its size, so the offline worker can
// save the complete set on request and tell whether anything is still missing.
import path from 'node:path';
import process from 'node:process';
import { readdir, stat, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export const SPRITE_MANIFEST_NAME = 'manifest.json';
async function walk(dir, files = []) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(fullPath, files);
else if (entry.isFile() && entry.name.endsWith('.webp')) files.push(fullPath);
}
return files;
}
/** Builds the manifest for `<spritesDir>/home`, with paths relative to that folder. */
export async function buildSpriteManifest(spritesDir) {
const homeDir = path.join(spritesDir, 'home');
const files = await walk(homeDir);
const entries = await Promise.all(
files.map(async (file) => [
path.relative(homeDir, file).split(path.sep).join('/'),
(await stat(file)).size
])
);
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return { version: 1, files: entries };
}
export async function writeSpriteManifest(spritesDir) {
const manifest = await buildSpriteManifest(spritesDir);
await writeFile(path.join(spritesDir, SPRITE_MANIFEST_NAME), `${JSON.stringify(manifest)}\n`);
return manifest;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const spritesDir =
process.env.SPRITE_OUTPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites-small');
const manifest = await writeSpriteManifest(spritesDir);
const bytes = manifest.files.reduce((total, [, size]) => total + size, 0);
console.log(
`Wrote ${manifest.files.length} sprites (${(bytes / 1048576).toFixed(1)} MB) to ${SPRITE_MANIFEST_NAME}`
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+1
View File
@@ -13,6 +13,7 @@ declare global {
// interface Error {}
interface Locals {
supabase: SupabaseClient;
pokedexAuthMs?: number;
safeGetSession(): Promise<{ session: Session | null; user: User | null }>;
userid: string;
buildDate: string;
-1
View File
@@ -18,7 +18,6 @@
})();
</script>
%sveltekit.head%
<link rel="stylesheet" href="%sveltekit.assets%/output.css" />
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
+32 -23
View File
@@ -1,22 +1,21 @@
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { createServerClient } from '@supabase/ssr';
import type { Handle } from '@sveltejs/kit';
import { compressResponse } from '$lib/server/compression';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
get: (key) => event.cookies.get(key),
getAll: () => event.cookies.getAll(),
/**
* Note: You have to add the `path` variable to the
* set and remove method due to sveltekit's cookie API
* requiring this to be set, setting the path to an empty string
* will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
* Note: You have to add the `path` variable to the set method due to sveltekit's cookie
* API requiring this to be set, setting the path to '/' will replicate previous/standard
* behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
*/
set: (key, value, options) => {
event.cookies.set(key, value, { ...options, path: '/' });
},
remove: (key, options) => {
event.cookies.delete(key, { ...options, path: '/' });
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
}
}
});
@@ -26,24 +25,34 @@ export const handle: Handle = async ({ event, resolve }) => {
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.safeGetSession = async () => {
const {
data: { user },
error
} = await event.locals.supabase.auth.getUser();
if (error) {
return { session: null, user: null };
}
// getUser is a network round trip to Supabase Auth. Layout and page loads (and API routes) all
// ask for the session, so validate once per request and share the result.
let sessionPromise: ReturnType<App.Locals['safeGetSession']> | null = null;
event.locals.safeGetSession = () => {
sessionPromise ??= (async () => {
const authStarted = performance.now();
const {
data: { user },
error
} = await event.locals.supabase.auth.getUser();
if (error) {
event.locals.pokedexAuthMs = performance.now() - authStarted;
return { session: null, user: null };
}
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
return { session, user };
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
event.locals.pokedexAuthMs = performance.now() - authStarted;
return { session, user };
})();
return sessionPromise;
};
return resolve(event, {
const response = await resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range';
}
});
return compressResponse(event.request, response);
};
+2 -2
View File
@@ -11,8 +11,8 @@
currentPage = Math.max(currentPage - 1, 1);
}
function setItemsPerPage(event: any) {
itemsPerPage = parseInt(event.target.value, 10);
function setItemsPerPage(event: Event) {
itemsPerPage = parseInt((event.target as HTMLSelectElement).value, 10);
}
</script>
+39 -51
View File
@@ -1,6 +1,40 @@
<script lang="ts">
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
import { inView } from '$lib/actions/inView';
import { resolveSpriteUrl, resolveGridSpriteUrl } from '$lib/utils/spriteUrl';
export let variant: 'detail' | 'grid' = 'detail';
let failedPaths = new Set<string>();
let fallbackIndex = 0;
let candidates: string[] = [];
$: {
const entry = { pokedexNumber: Number(pokedexNumber), form, spriteKey };
const full = resolveSpriteUrl(
entry,
!!shiny,
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
);
candidates = [
...new Set([
...(variant === 'grid' ? [resolveGridSpriteUrl(entry, !!shiny)] : []),
full,
resolveSpriteUrl(
{ ...entry, form: form?.replace(/^female[-\s]*/i, '') },
!!shiny,
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
),
resolveSpriteUrl(
{ pokedexNumber: Number(pokedexNumber) },
!!shiny,
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
)
])
];
fallbackIndex = candidates.findIndex((url) => !failedPaths.has(url));
}
function imageFailed() {
failedPaths = new Set([...failedPaths, imagePath!]);
}
export let pokemonName: string;
export let pokedexNumber: string | number;
@@ -12,46 +46,7 @@
let imagePath = null as string | null;
let isInView = false;
function isFemaleForm(value?: string) {
return /^female\b/i.test((value ?? '').trim());
}
function buildFallbackKey() {
const strippedPokedexNumber = pokedexNumber.toString().replace(/^0+/, '') || '0';
if (!form) return strippedPokedexNumber;
let formValue = form.trim();
formValue = formValue.replace(/^female[-\s]*/i, '');
formValue = formValue
.replace(/\s*\(.*?\)/g, '')
.replace(/\s*\[.*?\]/g, '')
.trim();
if (!formValue || formValue.toLowerCase() === 'male') return strippedPokedexNumber;
formValue = formValue
.toLowerCase()
.replace(/%/g, '')
.replace(/\balolan\b/g, 'alola')
.replace(/\bgalarian\b/g, 'galar')
.replace(/\bhisuian\b/g, 'hisui')
.replace(/\bpaldean\b/g, 'paldea')
.replace(/\bform(e)?$/, '')
.replace(/\bability$/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.replace(/2/g, 'two')
.replace(/3/g, 'three')
.replace(/4/g, 'four');
return formValue ? `${strippedPokedexNumber}-${formValue}` : strippedPokedexNumber;
}
$: {
const rootFolderBase =
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
? '/sprites-small/home'
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
const resolvedSpriteKey = spriteKey?.trim() || buildFallbackKey();
if (!spriteKey?.trim()) {
console.warn('Missing sprite key for pokemon entry', {
pokemonName,
@@ -59,17 +54,9 @@
form
});
}
let rootFolder = rootFolderBase;
if (shiny) {
rootFolder += '/shiny';
}
if (isFemaleForm(form)) {
rootFolder += '/female';
}
imagePath = `${rootFolder}/${resolvedSpriteKey}.webp`;
}
$: imagePath = candidates[fallbackIndex] ?? null;
$: if (loadingStrategy !== 'inView') {
isInView = true;
}
@@ -91,16 +78,17 @@
}}
>
{#if loadingStrategy === 'inView' && !isInView}
<span class="loading loading-spinner loading-xs"></span>
<span class="inline-block w-full h-full" aria-hidden="true"></span>
{:else}
<img
src={imagePath}
alt="sprite"
alt=""
on:error={imageFailed}
loading={loadingStrategy === 'lazy' ? 'lazy' : 'eager'}
decoding="async"
/>
{/if}
</span>
{:else}
<span class="loading loading-spinner loading-xs"></span>
<span class="inline-block w-full h-full" aria-hidden="true"></span>
{/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { SupabaseClient } from '@supabase/supabase-js';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@@ -12,7 +13,7 @@
let errorMessage = '';
// Access the supabase client from the layout data
export let supabase: any;
export let supabase: SupabaseClient;
async function signInWithEmail() {
isLoading = true;
+31 -5
View File
@@ -1,20 +1,46 @@
<script lang="ts">
import type { SupabaseClient } from '@supabase/supabase-js';
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import { clearOfflineData } from '$lib/stores/offlineSync';
const dispatch = createEventDispatcher();
let errorMessage = '';
let isSigningOut = false;
function emitSignedOutEvent() {
dispatch('signedOut', {});
}
// Access the supabase client from the layout data
export let supabase: any;
export let supabase: SupabaseClient;
async function signOut() {
// TODO use the error from the response
const { error } = await supabase.auth.signOut().then(() => {
errorMessage = '';
isSigningOut = true;
try {
const { error } = await supabase.auth.signOut();
if (error) {
errorMessage = `Sign out failed: ${error.message || 'Please try again.'}`;
dispatch('signOutFailed', { message: errorMessage });
return;
}
try {
await clearOfflineData();
} catch (cacheError) {
console.error('Signed out, but failed to clear offline data', cacheError);
}
emitSignedOutEvent();
});
await goto('/', { invalidateAll: true });
} catch (error) {
console.error('Sign out failed', error);
errorMessage = 'Sign out failed. Please try again.';
dispatch('signOutFailed', { message: errorMessage });
} finally {
isSigningOut = false;
}
}
</script>
<button on:click={signOut}>Sign Out</button>
<button on:click={signOut} disabled={isSigningOut}>
{isSigningOut ? 'Signing Out…' : 'Sign Out'}
</button>
+6 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { SupabaseClient } from '@supabase/supabase-js';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@@ -6,17 +7,16 @@
let password = '';
// Access the supabase client from the layout data
export let supabase: any;
export let supabase: SupabaseClient;
async function signUpNewUser() {
try {
// `redirectTo` is not a signUp option - it was silently ignored, so the confirmation
// link has always used Supabase's configured site URL. Sending the user to /welcome
// would need `emailRedirectTo` with an absolute, allow-listed URL.
const { data, error } = await supabase.auth.signUp({
email: email,
password: password,
options: {
// Redirect URL after successful sign-up
redirectTo: '/welcome'
}
password: password
});
if (error) {
@@ -1,5 +1,6 @@
<script lang="ts">
import type { CatchRecord } from '$lib/models/CatchRecord';
import type { SharedCatchStatus } from '$lib/models/SharedPokedex';
import type { CatchInformationItem, PokedexEntry } from '$lib/models/PokedexEntry';
import PokemonSprite from '../PokemonSprite.svelte';
import { createEventDispatcher } from 'svelte';
@@ -11,9 +12,11 @@
export let showShiny: boolean;
export let userId: string | null = null;
export let pokedexId: string;
export let readOnly = false;
export let sharedCatchStatus: SharedCatchStatus | null = null;
// Create a default catch record if none exists
$: if (!catchRecord) {
$: if (!readOnly && !catchRecord) {
catchRecord = {
_id: '', // Empty string, not temp ID - will be created by server
userId: userId || '',
@@ -35,28 +38,36 @@
value: string | CatchInformationItem
): value is CatchInformationItem => typeof value !== 'string';
function updateCatchRecord(source: UpdateCatchSource) {
dispatch('updateCatch', { pokedexEntry, catchRecord, source });
function updateCatchRecord(source: UpdateCatchSource, changes?: Partial<CatchRecord>) {
if (readOnly) return;
dispatch('updateCatch', { pokedexEntry, catchRecord, source, changes });
}
function onCaughtChange() {
if (readOnly) return;
if (!catchRecord) return;
// Mutually exclusive with "needs to evolve"
if (catchRecord.caught) {
catchRecord.haveToEvolve = false;
}
updateCatchRecord('toggle');
updateCatchRecord('toggle', {
caught: catchRecord.caught,
haveToEvolve: catchRecord.haveToEvolve
});
}
function onNeedsToEvolveChange() {
if (readOnly) return;
if (!catchRecord) return;
// Mutually exclusive with "caught"
if (catchRecord.haveToEvolve) {
catchRecord.caught = false;
}
updateCatchRecord('toggle');
updateCatchRecord('toggle', {
caught: catchRecord.caught,
haveToEvolve: catchRecord.haveToEvolve
});
}
</script>
<div
@@ -96,78 +107,95 @@
{/if}
</div>
{#if catchRecord}
{#if readOnly || catchRecord}
<div
class="dex-column catch-record-container bg-base-100 text-base-content rounded-lg p-4 mb-4 md:mb-0"
>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Caught:</span>
<input
type="checkbox"
bind:checked={catchRecord.caught}
class="checkbox checkbox-primary"
on:change={onCaughtChange}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Needs to evolve:</span>
<input
type="checkbox"
bind:checked={catchRecord.haveToEvolve}
class="checkbox checkbox-primary"
on:change={onNeedsToEvolveChange}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">In Home:</span>
<input
type="checkbox"
bind:checked={catchRecord.inHome}
class="checkbox checkbox-primary"
on:change={() => updateCatchRecord('toggle')}
/>
</label>
</div>
</div>
{#if pokedexEntry.canGigantamax && showForms}
{#if readOnly}
<h3 class="text-lg font-semibold mb-2">Progress</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
<dt>Caught</dt>
<dd class="font-semibold">{sharedCatchStatus?.caught ? 'Yes' : 'No'}</dd>
<dt>Needs to evolve</dt>
<dd class="font-semibold">{sharedCatchStatus?.haveToEvolve ? 'Yes' : 'No'}</dd>
<dt>In HOME</dt>
<dd class="font-semibold">{sharedCatchStatus?.inHome ? 'Yes' : 'No'}</dd>
{#if pokedexEntry.canGigantamax && showForms}
<dt>Has Gigantamaxed</dt>
<dd class="font-semibold">{sharedCatchStatus?.hasGigantamaxed ? 'Yes' : 'No'}</dd>
{/if}
</dl>
{:else if catchRecord}
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
<span class="block font-bold mr-2">Caught:</span>
<input
type="checkbox"
bind:checked={catchRecord.hasGigantamaxed}
bind:checked={catchRecord.caught}
class="checkbox checkbox-primary"
on:change={() => updateCatchRecord('toggle')}
on:change={onCaughtChange}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Needs to evolve:</span>
<input
type="checkbox"
bind:checked={catchRecord.haveToEvolve}
class="checkbox checkbox-primary"
on:change={onNeedsToEvolveChange}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">In Home:</span>
<input
type="checkbox"
bind:checked={catchRecord.inHome}
class="checkbox checkbox-primary"
on:change={() => updateCatchRecord('toggle', { inHome: catchRecord?.inHome })}
/>
</label>
</div>
</div>
{#if pokedexEntry.canGigantamax && showForms}
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
<input
type="checkbox"
bind:checked={catchRecord.hasGigantamaxed}
class="checkbox checkbox-primary"
on:change={() =>
updateCatchRecord('toggle', { hasGigantamaxed: catchRecord?.hasGigantamaxed })}
/>
</label>
</div>
</div>
{/if}
<p>
<label
class="block font-bold mb-1"
for={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}>Notes:</label
>
<textarea
bind:value={catchRecord.personalNotes}
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
class="textarea textarea-bordered w-full"
style="min-height: 120px;"
on:input={() => updateCatchRecord('notes')}
on:change={() => updateCatchRecord('notes-blur')}
></textarea>
</p>
{/if}
<p>
<label
class="block font-bold mb-1"
for={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}>Notes:</label
>
<textarea
bind:value={catchRecord.personalNotes}
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
class="textarea textarea-bordered w-full"
style="min-height: 120px;"
on:input={() => updateCatchRecord('notes')}
on:change={() => updateCatchRecord('notes-blur')}
></textarea>
</p>
</div>
{/if}
+12 -14
View File
@@ -59,10 +59,8 @@
// Validation
$: hasAtLeastOneType =
pokedex.isLivingDex || pokedex.isShinyDex || pokedex.isOriginDex || pokedex.isFormDex;
$: hasDexScope =
!pokedex.gameScope || (pokedex.dexScopes && pokedex.dexScopes.length > 0);
$: canSubmit =
pokedex.name && pokedex.name.trim() !== '' && hasAtLeastOneType && hasDexScope;
$: hasDexScope = !pokedex.gameScope || (pokedex.dexScopes && pokedex.dexScopes.length > 0);
$: canSubmit = pokedex.name && pokedex.name.trim() !== '' && hasAtLeastOneType && hasDexScope;
$: if (pokedex.gameScope !== lastGameScope) {
const shouldResetDexes = mode === 'create' || hasSeenGameScope;
@@ -162,16 +160,14 @@
<option value={null}>All Games</option>
{#if loadingDexes}
<option disabled>Loading games...</option>
{:else if gameList.length > 0}
{#each gameList as game}
<option value={game.displayName}>{game.displayName}</option>
{/each}
{:else}
{#if gameList.length > 0}
{#each gameList as game}
<option value={game.displayName}>{game.displayName}</option>
{/each}
{:else}
{#each gameOrder.length > 0 ? gameOrder : Object.keys(gameDexes) as game}
<option value={game}>{game}</option>
{/each}
{/if}
{#each gameOrder.length > 0 ? gameOrder : Object.keys(gameDexes) as game}
<option value={game}>{game}</option>
{/each}
{/if}
</select>
</div>
@@ -181,7 +177,9 @@
<fieldset class="w-full">
<legend class="label">
<span class="label-text">Dex Scope</span>
<span class="label-text-alt text-error">{!hasDexScope ? 'Select at least one dex' : ''}</span>
<span class="label-text-alt text-error"
>{!hasDexScope ? 'Select at least one dex' : ''}</span
>
</legend>
{#if availableDexes.length === 0}
<p class="text-sm text-error">No dexes found for this game.</p>
+39 -8
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount } from 'svelte';
export let isOpen: boolean;
export let onClose: () => void;
@@ -14,24 +14,55 @@
}
}
let dialog: HTMLDivElement;
onMount(() => {
window.addEventListener('keydown', handleKeyDown);
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown);
const previous = document.activeElement as HTMLElement | null;
const close = dialog.querySelector<HTMLButtonElement>('.close-button');
close?.focus();
const trapFocus = (event: KeyboardEvent) => {
handleKeyDown(event);
if (event.key !== 'Tab') return;
const nodes = Array.from(
dialog.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
)
).filter((node) => node.getClientRects().length);
const first = nodes[0],
last = nodes.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
};
window.addEventListener('keydown', trapFocus);
return () => {
window.removeEventListener('keydown', trapFocus);
if (previous?.isConnected) previous.focus();
};
});
</script>
{#if isOpen}
<div class="modal modal-open" role="dialog" aria-modal="true">
<div
bind:this={dialog}
class="modal modal-open"
role="dialog"
aria-label="Pokémon details"
aria-modal="true"
>
<div class="modal-box-custom bg-primary text-primary-content">
<button class="close-button" on:click={onClose} aria-label="Close"></button>
<button data-offline-action class="close-button" on:click={onClose} aria-label="Close">
</button>
<div class="modal-content">
<slot />
</div>
</div>
<button
data-offline-action
type="button"
class="modal-backdrop bg-black/50"
aria-label="Close modal"
+415 -241
View File
@@ -1,24 +1,123 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { CatchRecord } from '$lib/models/CatchRecord';
import type { CombinedData } from '$lib/models/CombinedData';
import { onMount, tick } from 'svelte';
import { calculateBoxPlacement } from '$lib/utils/boxPlacement';
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
import type { PokedexGridRow } from '$lib/models/PokedexGridRow';
import { markGridInteractive } from '$lib/utils/criticalPageWork';
export let virtualize = false;
export let retryLoad: (() => void) | null = null;
let renderAll = false;
let visibleBoxes = new Set([1, 2, 3, 4]);
let focusedBox: number | null = null;
let grid: HTMLDivElement;
const shells = new Map<number, HTMLElement>();
let viewportFrame = 0;
let mounted = false;
export let gridKey = '';
let markedKey: string | null = null;
let scrollAnchor: { number: number; top: number } | null = null;
function measureViewport() {
viewportFrame = 0;
const next = new Set<number>();
scrollAnchor = null;
for (const [number, node] of shells) {
const rect = node.getBoundingClientRect();
if (!scrollAnchor && rect.bottom > 0) scrollAnchor = { number, top: rect.top };
const overscan = rect.height + 16;
if (rect.bottom >= -overscan && rect.top <= window.innerHeight + overscan) next.add(number);
}
visibleBoxes = next;
}
function resizeViewport() {
if (scrollAnchor && window.scrollY > 0) {
const node = shells.get(scrollAnchor.number);
if (node) window.scrollBy(0, node.getBoundingClientRect().top - scrollAnchor.top);
}
scheduleViewport();
}
function trackFocus(event: FocusEvent) {
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest('[data-box-number], [role="dialog"]')) focusedBox = null;
}
function scheduleViewport() {
if (!viewportFrame) viewportFrame = requestAnimationFrame(measureViewport);
}
function boxShell(node: HTMLElement, number: number) {
shells.set(number, node);
scheduleViewport();
return {
destroy() {
shells.delete(number);
}
};
}
async function focusEntry(index: number) {
if (!combinedData || index < 0 || index >= combinedData.length) return;
focusedBox = Math.floor(index / 30) + 1;
await tick();
grid.querySelector<HTMLElement>(`[data-entry-index="${index}"]`)?.focus();
}
function navigateEntry(event: KeyboardEvent, index: number) {
const offset = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -6, ArrowDown: 6 }[event.key];
if (offset !== undefined) {
event.preventDefault();
void focusEntry(index + offset);
} else if (event.key === 'Tab') {
const next = index + (event.shiftKey ? -1 : 1);
if (
next >= 0 &&
next < (combinedData?.length ?? 0) &&
!grid.querySelector(`[data-entry-index="${next}"]`)
) {
event.preventDefault();
void focusEntry(next);
}
}
}
$: if (mounted && combinedData && gridKey !== markedKey) {
markedKey = gridKey;
grid?.removeAttribute('data-grid-interactive');
void tick().then(() => {
if (virtualize) markGridInteractive();
});
}
onMount(() => {
mounted = true;
const observer = new ResizeObserver(resizeViewport);
if (grid) observer.observe(grid);
window.addEventListener('scroll', scheduleViewport, { passive: true });
window.addEventListener('resize', resizeViewport);
window.addEventListener('focusin', trackFocus);
measureViewport();
return () => {
observer.disconnect();
cancelAnimationFrame(viewportFrame);
window.removeEventListener('scroll', scheduleViewport);
window.removeEventListener('resize', resizeViewport);
window.removeEventListener('focusin', trackFocus);
};
});
export let showShiny = false;
export let combinedData: CombinedData[] | null;
type DisplayData = PokedexGridRow | SharedCombinedData;
type DisplayStatus = DisplayData['catchRecord'];
export let combinedData: DisplayData[] | null;
export let readOnly = false;
export let boxNumbers: number[] = [];
export let creatingRecords = false;
export let totalRecordsCreated = 0;
export let failedToLoad = false;
export let markBoxAsNotCaught = (boxNumber: number) => {};
export let markBoxAsCaught = (boxNumber: number) => {};
export let markBoxAsNeedsToEvolve = (boxNumber: number) => {};
export let markBoxAsInHome = (boxNumber: number) => {};
export let markBoxAsNotInHome = (boxNumber: number) => {};
export let markBoxAsNotCaught: (boxNumber: number) => void = () => {};
export let markBoxAsCaught: (boxNumber: number) => void = () => {};
export let markBoxAsNeedsToEvolve: (boxNumber: number) => void = () => {};
export let markBoxAsInHome: (boxNumber: number) => void = () => {};
export let markBoxAsNotInHome: (boxNumber: number) => void = () => {};
export let createCatchRecords = () => {};
export let onPokemonClick: (pokemon: CombinedData) => void = () => {};
export let onPokemonClick: (pokemon: DisplayData) => void = () => {};
let filterNotCaught = false;
let filterNeedsToEvolve = false;
@@ -36,7 +135,7 @@
return () => window.removeEventListener('click', close);
});
let filteredCombinedData: CombinedData[] = [];
let filteredCombinedData: DisplayData[] = [];
let filteredTotal = 0;
let overallTotal = 0;
let overallCaughtCount = 0;
@@ -47,7 +146,7 @@
let filtersActive = false;
let filtersKey = '';
function normalizedStatus(catchRecord: CatchRecord | null) {
function normalizedStatus(catchRecord: DisplayStatus) {
return {
caught: !!catchRecord?.caught,
needsToEvolve: !!catchRecord?.haveToEvolve,
@@ -55,7 +154,7 @@
};
}
function matchesFilters(catchRecord: CatchRecord | null) {
function matchesFilters(catchRecord: DisplayStatus) {
const status = normalizedStatus(catchRecord);
if (!filtersActive) return true;
@@ -114,21 +213,21 @@
const BOX_VIEW_LAYOUT_STORAGE_KEY = 'livingdex:boxViewLayout:v1';
type BoxViewLayout = 'comfortable' | 'compact' | 'ultra';
let boxViewLayout: BoxViewLayout = 'comfortable';
onMount(() => {
try {
const stored = localStorage.getItem(BOX_VIEW_LAYOUT_STORAGE_KEY);
if (stored === 'comfortable' || stored === 'compact' || stored === 'ultra') {
boxViewLayout = stored;
}
} catch {
// ignore (privacy mode / disabled storage)
}
});
export let initialLayout: BoxViewLayout = 'comfortable';
let boxViewLayout: BoxViewLayout = initialLayout;
function persistBoxViewLayout(next: BoxViewLayout) {
const anchor = [...shells.entries()].find(
([, node]) => node.getBoundingClientRect().bottom > 0
);
const top = anchor?.[1].getBoundingClientRect().top;
boxViewLayout = next;
document.cookie = `boxViewLayout=${next};path=/;max-age=31536000;SameSite=Lax`;
void tick().then(() => {
if (anchor && top !== undefined && window.scrollY > 0)
window.scrollBy(0, anchor[1].getBoundingClientRect().top - top);
measureViewport();
});
try {
localStorage.setItem(BOX_VIEW_LAYOUT_STORAGE_KEY, next);
} catch {
@@ -148,7 +247,7 @@
boxViewLayout === 'comfortable' ? 1 : boxViewLayout === 'compact' ? 0.6 : 0.45;
$: spriteSizePx = boxViewLayout === 'comfortable' ? 64 : boxViewLayout === 'compact' ? 52 : 44;
function cellStatusClasses(catchRecord: CatchRecord | null) {
function cellStatusClasses(catchRecord: DisplayStatus) {
// Keep borders/layout unchanged; rely on clearer fills + badges instead.
if (catchRecord?.caught) {
// Match legend (green-600) while keeping sprites readable.
@@ -161,7 +260,7 @@
return '';
}
function statusLabel(catchRecord: CatchRecord | null) {
function statusLabel(catchRecord: DisplayStatus) {
const parts: string[] = [];
if (catchRecord?.caught) parts.push('Caught');
if (catchRecord?.haveToEvolve) parts.push('Needs to evolve');
@@ -169,7 +268,7 @@
return parts.length ? parts.join(', ') : 'Not caught';
}
function cellBackgroundColourStyle(index: number, catchRecord: CatchRecord | null) {
function cellBackgroundColourStyle(index: number, catchRecord: DisplayStatus) {
if (catchRecord?.caught || catchRecord?.haveToEvolve) {
return '';
} else {
@@ -197,7 +296,7 @@
</script>
<main class="flex-1 p-4 w-full">
<div class="max-w-fit mx-auto">
<div class="max-w-[1440px] w-full mx-auto">
{#if combinedData && combinedData.length > 0}
<div class="container mx-auto">
<div class="card bg-base-100 shadow mb-4">
@@ -207,6 +306,7 @@
<span class="label-text font-semibold">Box view layout</span>
</label>
<select
data-offline-action
id="box-view-layout"
class="select select-bordered select-sm"
bind:value={boxViewLayout}
@@ -217,6 +317,16 @@
<option value="compact">Compact (3 boxes/row)</option>
<option value="ultra">Ultra (4 boxes/row)</option>
</select>
{#if virtualize}
<label class="label cursor-pointer gap-2"
><input
data-offline-action
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={renderAll}
/>Render all boxes</label
>
{/if}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold">Legend:</span>
@@ -283,6 +393,7 @@
<span class="font-semibold">Filters:</span>
<label class="label cursor-pointer gap-2 p-0">
<input
data-offline-action
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={filterNotCaught}
@@ -291,6 +402,7 @@
</label>
<label class="label cursor-pointer gap-2 p-0">
<input
data-offline-action
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={filterNeedsToEvolve}
@@ -299,6 +411,7 @@
</label>
<label class="label cursor-pointer gap-2 p-0">
<input
data-offline-action
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={filterInHome}
@@ -308,6 +421,7 @@
</label>
<label class="label cursor-pointer gap-2 p-0">
<input
data-offline-action
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={filterNotInHome}
@@ -345,233 +459,252 @@
</div>
<div
bind:this={grid}
class="boxes-grid"
style="--boxes-per-row: {boxesPerRow}; --cell-padding: {cellPaddingRem}rem; --sprite-size: {spriteSizePx}px;"
>
{#each boxNumbers as boxNumber}
{#each boxNumbers as boxNumber (boxNumber)}
{@const bulkMenuId = `box-${boxNumber}-bulk-menu`}
<div class="mb-8">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4 relative z-20">
<h2 class="text-xl font-bold">Box {boxNumber}</h2>
<div class="relative">
<button
type="button"
class="btn btn-sm btn-outline relative z-[210]"
aria-label="Open bulk actions menu"
aria-haspopup="menu"
aria-controls={bulkMenuId}
aria-expanded={openBulkMenuForBox === boxNumber}
on:click={(event) => {
event.stopPropagation();
openBulkMenuForBox = openBulkMenuForBox === boxNumber ? null : boxNumber;
}}
on:keydown={(event) => {
if (event.key === 'Escape') openBulkMenuForBox = null;
}}
<div class="box-shell" use:boxShell={boxNumber} data-box-number={boxNumber}>
{#if !virtualize || renderAll || visibleBoxes.has(boxNumber) || focusedBox === boxNumber}
<div class="box-content">
<div
class="box-heading flex items-center justify-between gap-3 mb-4 relative z-20"
>
</button>
<h2 class="text-xl font-bold">Box {boxNumber}</h2>
{#if !readOnly}<div class="relative">
<button
type="button"
class="btn btn-sm btn-outline relative z-[210]"
aria-label="Open bulk actions menu"
aria-haspopup="menu"
aria-controls={bulkMenuId}
aria-expanded={openBulkMenuForBox === boxNumber}
on:click={(event) => {
event.stopPropagation();
openBulkMenuForBox =
openBulkMenuForBox === boxNumber ? null : boxNumber;
}}
on:keydown={(event) => {
if (event.key === 'Escape') openBulkMenuForBox = null;
}}
>
</button>
{#if openBulkMenuForBox === boxNumber}
<ul
id={bulkMenuId}
class="menu bg-base-100 rounded-box absolute right-0 mt-2 z-[220] w-56 p-2 shadow border border-base-300"
>
<li>
{#if openBulkMenuForBox === boxNumber}
<ul
id={bulkMenuId}
class="menu bg-base-100 rounded-box absolute right-0 mt-2 z-[220] w-56 p-2 shadow border border-base-300"
>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsNotCaught(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as Not caught
</button>
</li>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsCaught(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as Caught
</button>
</li>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsNeedsToEvolve(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as Needs to evolve
</button>
</li>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsInHome(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as In HOME
</button>
</li>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsNotInHome(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as Not in HOME
</button>
</li>
</ul>
{/if}
</div>{/if}
</div>
<div class="grid grid-cols-6">
{#each BOX_POSITIONS as positionInBox}
{@const globalIndex = (boxNumber - 1) * POKEMON_PER_BOX + positionInBox}
{@const placement = calculateBoxPlacement(globalIndex)}
{@const entry = combinedData?.[globalIndex]}
{@const pokedexEntry = entry?.pokedexEntry}
{@const catchRecord = entry?.catchRecord ?? null}
{@const isFilteredOut =
!!entry && filtersActive && !!filtersKey && !matchesFilters(catchRecord)}
{#if entry && pokedexEntry}
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsNotCaught(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as Not caught
</button>
</li>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsCaught(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as Caught
</button>
</li>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsNeedsToEvolve(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as Needs to evolve
</button>
</li>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsInHome(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as In HOME
</button>
</li>
<li>
<button
type="button"
on:click|stopPropagation={() => {
markBoxAsNotInHome(boxNumber);
openBulkMenuForBox = null;
}}
>
Mark box as Not in HOME
</button>
</li>
</ul>
{/if}
</div>
</div>
<div class="grid grid-cols-6">
{#each BOX_POSITIONS as positionInBox}
{@const globalIndex = (boxNumber - 1) * POKEMON_PER_BOX + positionInBox}
{@const placement = calculateBoxPlacement(globalIndex)}
{@const entry = combinedData?.[globalIndex]}
{@const pokedexEntry = entry?.pokedexEntry}
{@const catchRecord = entry?.catchRecord ?? null}
{@const isFilteredOut =
!!entry && filtersActive && !!filtersKey && !matchesFilters(catchRecord)}
{#if entry && pokedexEntry}
<button
type="button"
class="pokemon-box {cellStatusClasses(catchRecord)} {isFilteredOut
? 'pokemon-box--filtered-out'
: 'hover:scale-105 hover:shadow-lg hover:z-50'} transition-all cursor-pointer relative"
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
class="pokemon-box {cellStatusClasses(catchRecord)} {isFilteredOut
? 'pokemon-box--filtered-out'
: 'hover:scale-105 hover:shadow-lg hover:z-50'} transition-all cursor-pointer relative"
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
{cellBackgroundColourStyle(globalIndex, catchRecord)}"
aria-disabled={isFilteredOut}
on:click={() => {
if (!isFilteredOut) onPokemonClick({ pokedexEntry, catchRecord });
}}
aria-label="View details for {pokedexEntry.pokemon}. Status: {statusLabel(
catchRecord
)}"
>
<Tooltip>
<div slot="hover-target" class="w-full h-full">
{#if catchRecord?.caught}
<span
class="status-badge status-badge--caught absolute left-0.5 status-badge-top z-10"
title="Caught"
>
<svg
class="status-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" />
</svg>
<span class="sr-only">Caught</span>
</span>
{:else if catchRecord?.haveToEvolve}
<span
class="status-badge status-badge--evolve absolute left-0.5 status-badge-top z-10"
title="Caught but needs to evolve"
>
<svg
class="status-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
<span class="sr-only">Caught but needs to evolve</span>
</span>
{/if}
{#if catchRecord?.inHome}
<span
class="status-badge status-badge--home absolute right-0.5 status-badge-top z-10"
title="In Pokémon HOME"
>
<svg
class="status-icon"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M12 3 3 10.5V21a1 1 0 0 0 1 1h5v-6h6v6h5a1 1 0 0 0 1-1V10.5L12 3Z"
data-offline-action
data-entry-index={globalIndex}
data-entry-id={pokedexEntry._id}
on:focus={() => (focusedBox = boxNumber)}
on:keydown={(event) => navigateEntry(event, globalIndex)}
aria-disabled={isFilteredOut}
on:click={() => {
if (!isFilteredOut) onPokemonClick(entry);
}}
aria-label="View details for {pokedexEntry.pokemon}{pokedexEntry.form
? ` (${pokedexEntry.form})`
: ''}. Status: {statusLabel(catchRecord)}"
>
<span class="cell-tooltip">
<span class="block w-full h-full">
{#if catchRecord?.caught}
<span
class="status-badge status-badge--caught absolute left-0.5 status-badge-top z-10"
title="Caught"
>
<svg
class="status-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" />
</svg>
<span class="sr-only">Caught</span>
</span>
{:else if catchRecord?.haveToEvolve}
<span
class="status-badge status-badge--evolve absolute left-0.5 status-badge-top z-10"
title="Caught but needs to evolve"
>
<svg
class="status-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
<span class="sr-only">Caught but needs to evolve</span>
</span>
{/if}
{#if catchRecord?.inHome}
<span
class="status-badge status-badge--home absolute right-0.5 status-badge-top z-10"
title="In Pokémon HOME"
>
<svg
class="status-icon"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M12 3 3 10.5V21a1 1 0 0 0 1 1h5v-6h6v6h5a1 1 0 0 0 1-1V10.5L12 3Z"
/>
</svg>
<span class="sr-only">In HOME</span>
</span>
{/if}
<div class="pokemon-box-inner">
<PokemonSprite
pokemonName={pokedexEntry.pokemon}
pokedexNumber={pokedexEntry.pokedexNumber}
form={pokedexEntry.form}
spriteKey={pokedexEntry.spriteKey}
shiny={showShiny}
variant="grid"
/>
</svg>
<span class="sr-only">In HOME</span>
</div>
</span>
{/if}
<div class="pokemon-box-inner">
<PokemonSprite
pokemonName={pokedexEntry.pokemon}
pokedexNumber={pokedexEntry.pokedexNumber}
form={pokedexEntry.form}
spriteKey={pokedexEntry.spriteKey}
shiny={showShiny}
/>
</div>
</div>
<div slot="tooltip">
<div class="font-bold">
{pokedexEntry.pokemon}
{pokedexEntry.form ? `(${pokedexEntry.form})` : ''}
</div>
<div>{pokedexEntry.pokedexNumber.toString().padStart(3, '0')}</div>
<div>
Caught: {catchRecord?.caught ? 'Yes' : 'No'} <br />
Caught but needs to Evolve: {catchRecord?.haveToEvolve ? 'Yes' : 'No'}
<br />
In Home: {catchRecord?.inHome ? 'Yes' : 'No'}
</div>
</div>
</Tooltip>
</button>
{:else}
<button
type="button"
class="pokemon-box pokemon-box--empty"
disabled
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
<span class="cell-tooltip-text" role="tooltip">
<div class="font-bold">
{pokedexEntry.pokemon}
{pokedexEntry.form ? `(${pokedexEntry.form})` : ''}
</div>
<div>{pokedexEntry.pokedexNumber.toString().padStart(3, '0')}</div>
<div>
Caught: {catchRecord?.caught ? 'Yes' : 'No'} <br />
Caught but needs to Evolve: {catchRecord?.haveToEvolve
? 'Yes'
: 'No'}
<br />
In Home: {catchRecord?.inHome ? 'Yes' : 'No'}
</div>
</span>
</span>
</button>
{:else}
<button
type="button"
class="pokemon-box pokemon-box--empty"
disabled
style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
{cellBackgroundColourStyle(globalIndex, null)}"
aria-label="Empty box slot"
>
<div class="pokemon-box-inner" aria-hidden="true">
<span class="sprite-placeholder" />
</div>
</button>
{/if}
{/each}
</div>
aria-label="Empty box slot"
>
<div class="pokemon-box-inner" aria-hidden="true">
<span class="sprite-placeholder" />
</div>
</button>
{/if}
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
{:else if failedToLoad}
{#if creatingRecords && totalRecordsCreated > 0}
{#if retryLoad}
<p role="alert">Unable to load Pokédex.</p>
<button class="btn" on:click={retryLoad}>Retry loading Pokédex</button>
{:else if creatingRecords && totalRecordsCreated > 0}
<p>Processed {totalRecordsCreated} Pokédex entries so far...</p>
<p>Please be patient, this may take some time.</p>
{:else if creatingRecords}
@@ -583,8 +716,12 @@
If you're seeing this, you probably haven't created your Pokédex data yet. Please do so by
clicking this button.
</p>
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button>
{#if !readOnly}
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button>
{/if}
{/if}
{:else if combinedData}
<p>No entries match this Pokédex.</p>
{:else}
<div class="min-w-max mx-auto">
<h1>Loading Pokédex</h1>
@@ -595,6 +732,43 @@
</main>
<style>
.box-shell {
position: relative;
min-width: 0;
}
.box-shell::before {
content: '';
display: block;
padding-top: calc(83.333333% + 80px);
}
.box-content {
position: absolute;
inset: 0 0 32px;
}
.box-heading {
height: 32px;
}
.cell-tooltip {
display: block;
width: 100%;
height: 100%;
}
.cell-tooltip-text {
display: none;
position: absolute;
z-index: 100;
pointer-events: none;
background: #1f2937;
color: white;
border-radius: 4px;
padding: 8px;
width: 13rem;
}
.pokemon-box:hover .cell-tooltip-text,
.pokemon-box:focus-visible .cell-tooltip-text {
display: block;
}
/*
Theme-aware backgrounds for non-caught box slots.
- Light mode (`pokeball`) keeps the original exact colors.
+16
View File
@@ -0,0 +1,16 @@
import type { CombinedData } from './CombinedData';
import type { Pokedex } from './Pokedex';
export const OFFLINE_SNAPSHOT_VERSION = 1;
export type OfflinePokedexSnapshot = {
pokedex: Pokedex;
entries: CombinedData[];
};
export type OfflineSnapshot = {
version: typeof OFFLINE_SNAPSHOT_VERSION;
generatedAt: string;
userId: string;
pokedexes: OfflinePokedexSnapshot[];
};
+2
View File
@@ -1,5 +1,6 @@
export interface Pokedex {
_id: string;
shareToken: string;
userId: string;
name: string;
description: string;
@@ -13,6 +14,7 @@ export interface Pokedex {
export interface PokedexDB {
id: string;
shareToken: string;
userId: string;
name: string;
description: string;
@@ -15,6 +15,8 @@ export interface PokedexExportIntegration {
metadata: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
/** Set by a database trigger on every write, so it doubles as the row's version. */
updatedAt: string | null;
}
export interface PokedexExportIntegrationDB {
@@ -32,4 +34,5 @@ export interface PokedexExportIntegrationDB {
metadata: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
updatedAt: string | null;
}
+58
View File
@@ -0,0 +1,58 @@
import type { PokedexEntry } from './PokedexEntry';
import type { CatchRecord } from './CatchRecord';
/** Complete ordering/status data, without detail text or repeated ownership fields. */
export type PokedexGridRow = {
pokedexEntry: Pick<
PokedexEntry,
'_id' | 'pokemon' | 'pokedexNumber' | 'form' | 'spriteKey' | 'canGigantamax'
>;
catchRecord: Pick<
CatchRecord,
'_id' | 'caught' | 'haveToEvolve' | 'inHome' | 'hasGigantamaxed'
> | null;
};
export type CatchRecordPatch = Partial<CatchRecord> &
Pick<CatchRecord, 'userId' | 'pokedexId' | 'pokemonId'>;
/** Version 1 transport rows: avoid repeating field names 1,000+ times. IDs remain available. */
export type PackedGridRow = [
entryId: string,
number: number,
name: string,
form: string,
spriteKey: string,
canGigantamax: boolean,
catchId: string | null,
status: number
];
export function packGrid(rows: PokedexGridRow[]): PackedGridRow[] {
return rows.map(({ pokedexEntry: e, catchRecord: c }) => [
e._id,
e.pokedexNumber,
e.pokemon,
e.form,
e.spriteKey,
e.canGigantamax,
c?._id ?? null,
(c?.caught ? 1 : 0) |
(c?.haveToEvolve ? 2 : 0) |
(c?.inHome ? 4 : 0) |
(c?.hasGigantamaxed ? 8 : 0)
]);
}
export function unpackGrid(rows: PackedGridRow[]): PokedexGridRow[] {
return rows.map(([id, number, name, form, spriteKey, canGigantamax, catchId, status]) => ({
pokedexEntry: { _id: id, pokedexNumber: number, pokemon: name, form, spriteKey, canGigantamax },
catchRecord:
catchId === null
? null
: {
_id: catchId,
caught: !!(status & 1),
haveToEvolve: !!(status & 2),
inHome: !!(status & 4),
hasGigantamaxed: !!(status & 8)
}
}));
}
+36
View File
@@ -0,0 +1,36 @@
import type { PokedexEntry } from './PokedexEntry';
export interface SharedCatchStatus {
pokemonId: string;
caught: boolean;
haveToEvolve: boolean;
inHome: boolean;
hasGigantamaxed: boolean;
}
export interface SharedPokedexMetadata {
name: string;
description: string;
isLivingDex: boolean;
isShinyDex: boolean;
isOriginDex: boolean;
isFormDex: boolean;
gameScope: string | null;
dexScopes: string[];
}
export interface SharedPokedexRpcData extends SharedPokedexMetadata {
catchStatuses: SharedCatchStatus[];
}
export interface SharedCombinedData {
pokedexEntry: PokedexEntry;
catchRecord: SharedCatchStatus | null;
}
export interface SharedPokedexData extends SharedPokedexMetadata {
combinedData: SharedCombinedData[];
total: number;
caught: number;
completionPercentage: number;
}
+20 -12
View File
@@ -90,19 +90,27 @@ class CatchRecordRepository {
return mapped;
});
const { data: result, error } = await this.supabase
.from('catch_records')
.upsert(dbRows, {
onConflict: '"userId","pokedexId","pokemonId"'
})
.select();
if (error) {
console.error('Supabase error bulk upserting catch records:', error);
throw new Error(`Failed to bulk upsert catch records: ${error.message}`);
const groups = new Map<string, Partial<CatchRecordDB>[]>();
for (const row of dbRows) {
const key = Object.keys(row).sort().join(',');
const group = groups.get(key) ?? [];
group.push(row);
groups.set(key, group);
}
return (result ?? []).map((row) => this.transformCatchRecord(row));
const saved: CatchRecord[] = [];
for (const group of groups.values()) {
const { data, error } = await this.supabase
.from('catch_records')
.upsert(group, {
onConflict: '"userId","pokedexId","pokemonId"',
defaultToNull: false
})
.select();
if (error) throw new Error(`Failed to bulk upsert catch records: ${error.message}`);
saved.push(...(data ?? []).map((row) => this.transformCatchRecord(row)));
}
const byPokemon = new Map(saved.map((row) => [row.pokemonId, row]));
return records.map((row) => byPokemon.get(row.pokemonId)!);
}
async findById(id: string): Promise<CatchRecord | null> {
+104 -28
View File
@@ -1,3 +1,4 @@
import type { PokedexGridRow } from '$lib/models/PokedexGridRow';
import { type PokedexEntry, type PokedexEntryDB } from '$lib/models/PokedexEntry';
import { type CatchRecord, type CatchRecordDB } from '$lib/models/CatchRecord';
import { type CombinedData } from '$lib/models/CombinedData';
@@ -20,9 +21,17 @@ class CombinedDataRepository {
constructor(
private supabase: SupabaseClient,
private userId: string | null,
private pokedexId: string | null
private pokedexId: string | null,
private compact = false
) {}
private scopedEntries = new Map<string, Promise<PokedexEntryDB[]>>();
private get entryColumns() {
return this.compact
? 'id,pokedexNumber,pokemon,form,spriteKey,canGigantamax,unownSortOrder,formSortBucket,formSortRegionOrder,formSortRegionalSub,formSortLabel'
: '*';
}
// Transform Supabase data to match frontend expectations (minimal transformation)
private transformPokedexEntry(entry: PokedexEntryDB): PokedexEntry {
return {
@@ -57,7 +66,7 @@ class CombinedDataRepository {
}
private buildEntriesQuery(enableForms: boolean, region: string, game: string) {
let query = this.supabase.from('pokedex_entries').select('*');
let query = this.supabase.from('pokedex_entries').select(this.entryColumns);
if (!enableForms) {
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
@@ -86,7 +95,10 @@ class CombinedDataRepository {
}
private buildDexEntriesQuery(dexScopes: string[], enableForms: boolean, region: string) {
let query = this.supabase.from('game_pokedex_entry_details').select('*').in('dexId', dexScopes);
let query = this.supabase
.from('game_pokedex_entry_details')
.select(this.compact ? `${this.entryColumns},dexNumber,dexSortOrder` : '*')
.in('dexId', dexScopes);
if (!enableForms) {
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
@@ -126,9 +138,12 @@ class CombinedDataRepository {
let start = 0;
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
while (true) {
for (;;) {
const end = start + maxRows - 1;
let query = this.supabase.from('pokedex_entries').select('*').not('form', 'is', null);
let query = this.supabase
.from('pokedex_entries')
.select(this.entryColumns)
.not('form', 'is', null);
if (game) {
query = query.contains('gamesToCatchIn', [game]);
@@ -140,13 +155,12 @@ class CombinedDataRepository {
const { data, error } = await query.order('id', { ascending: true }).range(start, end);
if (error) {
console.error('Error fetching forms for game:', error);
return [];
throw new Error('Unable to load form entries');
}
if (!data || data.length === 0) break;
allForms.push(...(data as PokedexEntryDB[]).filter((e) => !excludeIds.has(e.id)));
allForms.push(...(data as unknown as PokedexEntryDB[]).filter((e) => !excludeIds.has(e.id)));
if (data.length < maxRows) break;
start = end + 1;
@@ -155,7 +169,17 @@ class CombinedDataRepository {
return allForms;
}
private async fetchAllDexEntries(
private fetchAllDexEntries(dexScopes: string[], enableForms: boolean, region: string, game = '') {
const key = JSON.stringify([dexScopes, enableForms, region, game]);
let entries = this.scopedEntries.get(key);
if (!entries) {
entries = this.readAllDexEntries(dexScopes, enableForms, region, game);
this.scopedEntries.set(key, entries);
}
return entries;
}
private async readAllDexEntries(
dexScopes: string[],
enableForms: boolean,
region: string,
@@ -167,7 +191,7 @@ class CombinedDataRepository {
let start = 0;
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
while (true) {
for (;;) {
const end = start + maxRows - 1;
const { data, error } = await this.buildDexEntriesQuery(dexScopes, enableForms, region).range(
start,
@@ -175,15 +199,14 @@ class CombinedDataRepository {
);
if (error) {
console.error('Error finding dex-scoped combined data:', error);
return [];
throw new Error('Unable to load dex entries');
}
if (!data || data.length === 0) {
break;
}
entries.push(...(data as RawDexEntry[]));
entries.push(...(data as unknown as RawDexEntry[]));
if (data.length < maxRows) {
break;
@@ -283,15 +306,14 @@ class CombinedDataRepository {
);
if (error) {
console.error('Error finding paginated combined data:', error);
return [];
throw new Error('Unable to load dex entries');
}
if (!data || data.length === 0) {
break;
}
entries.push(...data);
entries.push(...(data as unknown as PokedexEntryDB[]));
if (data.length < end - start + 1) {
break;
@@ -312,7 +334,7 @@ class CombinedDataRepository {
let start = 0;
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
while (true) {
for (;;) {
const end = start + maxRows - 1;
const { data, error } = await this.buildEntriesQuery(enableForms, region, game).range(
start,
@@ -320,15 +342,14 @@ class CombinedDataRepository {
);
if (error) {
console.error('Error finding combined data:', error);
return [];
throw new Error('Unable to load dex entries');
}
if (!data || data.length === 0) {
break;
}
entries.push(...data);
entries.push(...(data as unknown as PokedexEntryDB[]));
if (data.length < maxRows) {
break;
@@ -351,24 +372,79 @@ class CombinedDataRepository {
const chunk = entryIds.slice(i, i + chunkSize);
const { data, error } = await this.supabase
.from('catch_records')
.select('*')
.select(this.compact ? 'id,pokemonId,caught,haveToEvolve,inHome,hasGigantamaxed' : '*')
.eq('userId', userId)
.eq('pokedexId', this.pokedexId)
.in('pokemonId', chunk);
if (error) {
console.error('Error loading catch records:', error);
continue;
throw new Error('Unable to load catch records');
}
if (data) {
records.push(...data);
records.push(...(data as unknown as CatchRecordDB[]));
}
}
return records;
}
async findGridEntries(enableForms: boolean, game: string, dexScopes: string[]) {
if (!this.compact) throw new Error('Grid reads require a compact repository');
const entries = dexScopes.length
? this.dedupeEntries(await this.fetchAllDexEntries(dexScopes, enableForms, '', game))
: await this.fetchAllEntries(enableForms, '', game);
return entries;
}
async joinGridCatches(entries: PokedexEntryDB[]): Promise<PokedexGridRow[]> {
const catches = new Map(
(
await this.fetchCatchRecords(
entries.map((e) => e.id),
this.userId!
)
).map((r) => [r.pokemonId, r])
);
return entries.map((e) => {
const c = catches.get(e.id);
return {
pokedexEntry: {
_id: String(e.id),
pokemon: e.pokemon,
pokedexNumber: e.pokedexNumber,
form: e.form || '',
spriteKey: e.spriteKey || '',
canGigantamax: e.canGigantamax
},
catchRecord: c
? {
_id: c.id,
caught: c.caught,
haveToEvolve: c.haveToEvolve,
inHome: c.inHome,
hasGigantamaxed: c.hasGigantamaxed
}
: null
};
});
}
async findEntryDetail(entryId: number): Promise<CombinedData | null> {
const { data, error } = await this.supabase
.from('pokedex_entries')
.select('*')
.eq('id', entryId)
.maybeSingle();
if (error) throw new Error('Unable to load entry details');
if (!data) return null;
const catches = await this.fetchCatchRecords([entryId], this.userId!);
return {
pokedexEntry: this.transformPokedexEntry(data),
catchRecord: catches[0] ? this.transformCatchRecord(catches[0]) : null
};
}
async findAllCombinedData(
userId: string,
enableForms: boolean = true,
@@ -392,9 +468,9 @@ class CombinedDataRepository {
catchRecords = await this.fetchCatchRecords(entryIds, userId);
}
// Combine the data exactly like master branch
const catchesById = new Map(catchRecords.map((record) => [record.pokemonId, record]));
const combinedData = entries.map((entry) => {
const userCatchRecord = catchRecords.find((record) => record.pokemonId === entry.id) || null;
const userCatchRecord = catchesById.get(entry.id) || null;
const transformedEntry = this.transformPokedexEntry(entry);
const transformedCatchRecord = userCatchRecord
@@ -440,9 +516,9 @@ class CombinedDataRepository {
catchRecords = await this.fetchCatchRecords(entryIds, userId);
}
// Combine the data exactly like master branch
const catchesById = new Map(catchRecords.map((record) => [record.pokemonId, record]));
const combinedData = entries.map((entry) => {
const userCatchRecord = catchRecords.find((record) => record.pokemonId === entry.id) || null;
const userCatchRecord = catchesById.get(entry.id) || null;
const transformedEntry = this.transformPokedexEntry(entry);
const transformedCatchRecord = userCatchRecord
@@ -8,9 +8,7 @@ import type { SupabaseClient } from '@supabase/supabase-js';
class PokedexEntryRepository {
constructor(private supabase: SupabaseClient) {}
private parseCatchInformation(
values: string[] | null
): Array<string | CatchInformationItem> {
private parseCatchInformation(values: string[] | null): Array<string | CatchInformationItem> {
if (!values) return [];
return values.map((value) => {
const trimmed = value.trim();
@@ -4,6 +4,10 @@ import type {
PokedexExportIntegrationDB
} from '$lib/models/PokedexExportIntegration';
// `from()` returns a table builder; only `select()` yields the filter builder that `eq`/`is`
// live on. Typing the scope helper with the table builder made `data` untyped downstream.
type IntegrationQuery = ReturnType<ReturnType<SupabaseClient['from']>['select']>;
class PokedexExportIntegrationRepository {
constructor(
private supabase: SupabaseClient,
@@ -26,7 +30,8 @@ class PokedexExportIntegrationRepository {
accessTokenExpiresAt: db.accessTokenExpiresAt,
metadata: db.metadata,
lastExportedAt: db.lastExportedAt,
lastError: db.lastError
lastError: db.lastError,
updatedAt: db.updatedAt ?? null
};
}
@@ -34,7 +39,7 @@ class PokedexExportIntegrationRepository {
return this.supabase.from('pokedex_export_integrations').select('*').eq('userId', this.userId);
}
private addPokedexScope(query: ReturnType<SupabaseClient['from']>) {
private addPokedexScope(query: IntegrationQuery): IntegrationQuery {
if (this.pokedexId) {
return query.eq('pokedexId', this.pokedexId);
}
@@ -49,7 +54,8 @@ class PokedexExportIntegrationRepository {
throw new Error(`Failed to load export integrations: ${error.message}`);
}
if (!data) return [];
return data.map((row) => this.transform(row));
// PostgREST rows are untyped without generated database types.
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
}
async listAll(): Promise<PokedexExportIntegration[]> {
@@ -60,12 +66,12 @@ class PokedexExportIntegrationRepository {
throw new Error(`Failed to load export integrations: ${error.message}`);
}
if (!data) return [];
return data.map((row) => this.transform(row));
// PostgREST rows are untyped without generated database types.
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
}
async upsert(
data: Partial<PokedexExportIntegrationDB> &
Pick<PokedexExportIntegrationDB, 'provider'>
data: Partial<PokedexExportIntegrationDB> & Pick<PokedexExportIntegrationDB, 'provider'>
): Promise<PokedexExportIntegration> {
const payload: Partial<PokedexExportIntegrationDB> = {
userId: this.userId,
@@ -105,7 +111,8 @@ class PokedexExportIntegrationRepository {
throw new Error(`Failed to load export integrations: ${error.message}`);
}
if (!data) return [];
return data.map((row) => this.transform(row));
// PostgREST rows are untyped without generated database types.
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
}
async updateTokens(
@@ -130,28 +137,38 @@ class PokedexExportIntegrationRepository {
}
}
/**
* Returns whether a row was updated. With `ifUpdatedAt`, the write only applies if the row is
* unchanged since it was read, so a stale export can't overwrite credentials a reconnect saved.
*/
async updateExportStatus(
id: string,
patch: {
enabled?: boolean;
lastExportedAt?: string | null;
lastError?: string | null;
metadata?: Record<string, unknown> | null;
folderId?: string | null;
path?: string | null;
}
): Promise<void> {
const query = this.supabase
},
ifUpdatedAt?: string
): Promise<boolean> {
let query = this.supabase
.from('pokedex_export_integrations')
.update(patch)
.eq('id', id)
.eq('userId', this.userId);
const { error } = this.pokedexId
? await query.eq('pokedexId', this.pokedexId)
: await query.is('pokedexId', null);
if (ifUpdatedAt) query = query.eq('updatedAt', ifUpdatedAt);
const scoped = this.pokedexId
? query.eq('pokedexId', this.pokedexId)
: query.is('pokedexId', null);
const { data, error } = await scoped.select('id');
if (error) {
console.error('Failed to update export integration status:', error);
return false;
}
return (data?.length ?? 0) > 0;
}
}
+6 -3
View File
@@ -10,6 +10,7 @@ class PokedexRepository {
private transform(db: PokedexDB, dexScopes: string[] = []): Pokedex {
return {
_id: db.id,
shareToken: db.shareToken,
userId: db.userId,
name: db.name,
description: db.description || '',
@@ -54,14 +55,16 @@ class PokedexRepository {
async findById(id: string): Promise<Pokedex | null> {
const { data, error } = await this.supabase
.from('pokedexes')
.select('*')
.select('*, pokedex_dex_scopes(dexId)')
.eq('id', id)
.eq('userId', this.userId)
.single();
if (error || !data) return null;
const dexScopesMap = await this.fetchDexScopesMap([data.id]);
return this.transform(data, dexScopesMap.get(data.id) || []);
return this.transform(
data,
(data.pokedex_dex_scopes ?? []).map((scope: { dexId: string }) => scope.dexId)
);
}
async findAll(): Promise<Pokedex[]> {
+4
View File
@@ -0,0 +1,4 @@
/** Cloudflare performs transport compression; preserve the streaming Response body. */
export function compressResponse(_request: Request, response: Response): Response {
return response;
}
+73
View File
@@ -0,0 +1,73 @@
import { Readable } from 'node:stream';
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
import { constants, createBrotliCompress, createGzip } from 'node:zlib';
export type Encoding = 'br' | 'gzip';
// Text responses the app renders or returns from API routes. Images, fonts and other binary
// content is already compressed, so re-compressing it only costs CPU.
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json)|image\/svg\+xml)/i;
/** Picks the best encoding the client accepts, preferring brotli. Honours `q=0` refusals. */
export function pickEncoding(acceptEncoding: string | null): Encoding | null {
if (!acceptEncoding) return null;
const accepted = new Map<string, number>();
for (const part of acceptEncoding.split(',')) {
const [name, ...params] = part.trim().toLowerCase().split(';');
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
accepted.set(name, q ? Number(q.slice(2)) : 1);
}
const allows = (name: Encoding) => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0;
if (allows('br')) return 'br';
if (allows('gzip')) return 'gzip';
return null;
}
// adapter-netlify's Lambda handler serialises text responses with `response.text()`, which would
// corrupt a compressed body. Netlify compresses function responses itself, so skip it there.
// Everywhere else (the Node build, local preview, CI) the app compresses its own responses.
const serialisesBodiesAsText = () => Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
function shouldCompress(request: Request, response: Response): boolean {
if (serialisesBodiesAsText()) return false;
if (!response.body || request.method === 'HEAD') return false;
if (response.status < 200 || response.status === 204 || response.status === 304) return false;
if (response.headers.has('content-encoding')) return false;
return COMPRESSIBLE.test(response.headers.get('content-type') ?? '');
}
/**
* Compresses a rendered page or API response so its transfer size doesn't depend on the host.
* Every chunk is flushed as soon as it is written, so SvelteKit's streamed load data still reaches
* the browser progressively instead of waiting for the whole body.
*/
export function compressResponse(request: Request, response: Response): Response {
if (!shouldCompress(request, response)) return response;
const encoding = pickEncoding(request.headers.get('accept-encoding'));
const headers = new Headers(response.headers);
// Caches must key on the request encoding even when this response isn't compressed.
headers.append('vary', 'Accept-Encoding');
if (!encoding) return new Response(response.body, { status: response.status, headers });
const compressor =
encoding === 'br'
? createBrotliCompress({
flush: constants.BROTLI_OPERATION_FLUSH,
// Quality 11 is for build-time precompression; 5 is fast enough per request.
params: { [constants.BROTLI_PARAM_QUALITY]: 5 }
})
: createGzip({ flush: constants.Z_SYNC_FLUSH, level: 6 });
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream);
source.on('error', (error) => compressor.destroy(error));
source.pipe(compressor);
headers.set('content-encoding', encoding);
headers.delete('content-length');
return new Response(Readable.toWeb(compressor) as unknown as ReadableStream, {
status: response.status,
statusText: response.statusText,
headers
});
}
+37
View File
@@ -0,0 +1,37 @@
import { env } from '$env/dynamic/private';
/** Opt-in, fixed labels only: never log IDs, query strings, cookies or entry content. */
export class PokedexPerformance {
private started = performance.now();
private durations: Record<string, number> = {};
readonly enabled = env.POKEDEX_PERFORMANCE === 'true';
async measure<T>(
stage: 'auth' | 'ownership' | 'scopes' | 'entries' | 'catches',
run: () => Promise<T>
): Promise<T> {
const start = performance.now();
try {
return await run();
} finally {
if (this.enabled) this.durations[stage] = performance.now() - start;
}
}
recordAuth(duration: number | undefined) {
if (this.enabled && duration !== undefined) this.durations.auth = duration;
}
prepare<T>(run: () => T): T {
const start = performance.now();
try {
return run();
} finally {
if (this.enabled) this.durations.prepare = performance.now() - start;
}
}
finish(): string | undefined {
if (!this.enabled) return undefined;
this.durations.total = performance.now() - this.started;
return Object.entries(this.durations)
.map(([name, duration]) => `${name};dur=${duration.toFixed(1)}`)
.join(', ');
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import type { Pokedex } from '$lib/models/Pokedex';
export type CombinedDataQuery = {
page: number;
limit: number;
enableForms: boolean;
region?: string;
game?: string;
};
/**
* Loads one page of a Pokédex's entries joined with the owner's catch records. Shared by the
* combined-data API and the Pokédex page's server load so both return exactly the same data.
* The caller must already have checked that `userId` owns `pokedex`.
*/
export async function loadCombinedDataPage(
supabase: SupabaseClient,
userId: string,
pokedex: Pokedex,
{ page, limit, enableForms, region = '', game = '' }: CombinedDataQuery
) {
// Use the pokédex's gameScope as the default filter if no manual game filter is set.
const effectiveGame = game || pokedex.gameScope || '';
const dexScopes = await resolveDexScopes(supabase, pokedex);
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
// The rows and the count are independent queries, so run them together.
const [combinedData, totalCount] = await Promise.all([
repo.findCombinedData(userId, page, limit, enableForms, region, effectiveGame, dexScopes),
repo.countCombinedData(enableForms, region, effectiveGame, dexScopes)
]);
return {
combinedData,
totalPages: Math.ceil(totalCount / limit),
currentPage: page,
totalCount
};
}
+1 -3
View File
@@ -84,9 +84,7 @@ export async function setPokedexDexScopes(
}
}
export async function listGameDexes(
supabase: SupabaseClient
): Promise<{
export async function listGameDexes(supabase: SupabaseClient): Promise<{
gameDexes: Record<string, GameDexRow[]>;
gameOrder: string[];
games: { displayName: string; releaseYear: number }[];
@@ -0,0 +1,73 @@
import type { CombinedData } from '$lib/models/CombinedData';
export function csvEscape(value: unknown): string {
if (value === null || value === undefined) return '';
const str = String(value);
if (/[",\n\r]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
return str;
}
export function sanitizeFileName(name: string, fallback: string): string {
const trimmed = name.trim();
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
if (!safe) return fallback;
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
}
export function buildCsv(combinedData: CombinedData[]): string {
const headers = [
'pokemonId',
'pokedexNumber',
'pokemon',
'form',
'caught',
'haveToEvolve',
'inHome',
'personalNotes'
];
const lines = [headers.map(csvEscape).join(',')];
for (const row of combinedData) {
const entry = row.pokedexEntry;
const catchRecord = row.catchRecord ?? {
caught: false,
haveToEvolve: false,
inHome: false,
personalNotes: ''
};
lines.push(
[
entry._id,
entry.pokedexNumber,
entry.pokemon,
entry.form || '',
catchRecord.caught,
catchRecord.haveToEvolve,
catchRecord.inHome,
catchRecord.personalNotes || ''
]
.map(csvEscape)
.join(',')
);
}
return lines.join('\r\n');
}
/** OAuth providers answer a revoked or expired refresh token with `invalid_grant`. */
export function isRevokedGrant(status: number, body: string): boolean {
if (status !== 400 && status !== 401) return false;
try {
const parsed = JSON.parse(body) as { error?: unknown } | null;
return parsed?.error === 'invalid_grant';
} catch {
return false;
}
}
export function shouldRefreshToken(expiresAt: string | null): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
if (!Number.isFinite(expiry)) return false;
return expiry - Date.now() < 60_000;
}
+56 -83
View File
@@ -1,18 +1,28 @@
import { randomUUID } from 'node:crypto';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CombinedData } from '$lib/models/CombinedData';
import type { Pokedex } from '$lib/models/Pokedex';
import type { ExportProvider, PokedexExportIntegration } from '$lib/models/PokedexExportIntegration';
import type {
ExportProvider,
PokedexExportIntegration
} from '$lib/models/PokedexExportIntegration';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import { getEnv } from '$lib/utils/env';
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
import {
buildCsv,
isRevokedGrant,
sanitizeFileName,
shouldRefreshToken
} from '$lib/services/PokedexExportFormatting';
type ExportFailure = {
integrationId: string;
provider: ExportProvider;
error: string;
reconnectRequired: boolean;
};
export type PokedexExportResult = {
@@ -21,77 +31,21 @@ export type PokedexExportResult = {
failed: ExportFailure[];
};
function csvEscape(value: unknown): string {
if (value === null || value === undefined) return '';
const str = String(value);
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
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.'
};
function sanitizeFileName(name: string, fallback: string): string {
const trimmed = name.trim();
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
if (!safe) return fallback;
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
}
function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string {
const headers = [
'pokemonId',
'pokedexNumber',
'pokemon',
'form',
'caught',
'haveToEvolve',
'inHome',
'personalNotes'
];
const lines = [headers.map(csvEscape).join(',')];
for (const row of combinedData) {
const entry = row.pokedexEntry;
const catchRecord = row.catchRecord ?? {
caught: false,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false,
personalNotes: ''
};
const values = [
entry._id,
entry.pokedexNumber,
entry.pokemon,
entry.form || '',
catchRecord.caught,
catchRecord.haveToEvolve,
catchRecord.inHome,
catchRecord.personalNotes || ''
];
lines.push(values.map(csvEscape).join(','));
}
return lines.join('\r\n');
}
function shouldRefreshToken(expiresAt: string | null): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
if (!Number.isFinite(expiry)) return false;
// Refresh if within 60 seconds of expiry.
return expiry - Date.now() < 60_000;
}
/** The provider rejected the stored grant, so only a fresh OAuth connection can resume exports. */
class ReconnectRequiredError extends Error {}
async function refreshGoogleToken(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!integration.refreshToken) {
throw new Error('Missing Google refresh token');
throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive);
}
const env = getEnv();
@@ -108,7 +62,7 @@ async function refreshGoogleToken(
grant_type: 'refresh_token'
});
const response = await fetch('https://oauth2.googleapis.com/token', {
const response = await fetch(getProviderEndpoints().google.token, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
@@ -116,6 +70,9 @@ async function refreshGoogleToken(
if (!response.ok) {
const text = await response.text();
if (isRevokedGrant(response.status, text)) {
throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive);
}
throw new Error(`Google token refresh failed: ${response.status} ${text}`);
}
@@ -145,7 +102,7 @@ async function refreshDropboxToken(
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!integration.refreshToken) {
throw new Error('Missing Dropbox refresh token');
throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox);
}
const env = getEnv();
@@ -162,7 +119,7 @@ async function refreshDropboxToken(
grant_type: 'refresh_token'
});
const response = await fetch('https://api.dropbox.com/oauth2/token', {
const response = await fetch(getProviderEndpoints().dropbox.token, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
@@ -170,6 +127,9 @@ async function refreshDropboxToken(
if (!response.ok) {
const text = await response.text();
if (isRevokedGrant(response.status, text)) {
throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox);
}
throw new Error(`Dropbox token refresh failed: ${response.status} ${text}`);
}
@@ -217,7 +177,10 @@ type GoogleDriveMetadata = {
files?: Record<string, string>;
};
function getGoogleFileId(metadata: Record<string, unknown> | null, pokedexId: string): string | null {
function getGoogleFileId(
metadata: Record<string, unknown> | null,
pokedexId: string
): string | null {
const data = metadata as GoogleDriveMetadata | null;
const fileId = data?.files?.[pokedexId];
return typeof fileId === 'string' && fileId ? fileId : null;
@@ -262,7 +225,7 @@ async function uploadToGoogleDrive(
if (!folderId) {
try {
const folderResponse = await fetch(
'https://www.googleapis.com/drive/v3/files?' +
`${getProviderEndpoints().google.driveApi}/files?` +
new URLSearchParams({
q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false",
fields: 'files(id,name)',
@@ -286,7 +249,7 @@ async function uploadToGoogleDrive(
if (!folderId) {
try {
const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', {
const createResponse = await fetch(`${getProviderEndpoints().google.driveApi}/files`, {
method: 'POST',
headers: {
Authorization: `Bearer ${refreshed.accessToken}`,
@@ -332,12 +295,11 @@ async function uploadToGoogleDrive(
].join('\r\n');
const url = currentFileId
? `https://www.googleapis.com/upload/drive/v3/files/${currentFileId}?uploadType=multipart`
: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart';
? `${getProviderEndpoints().google.driveUpload}/files/${currentFileId}?uploadType=multipart`
: `${getProviderEndpoints().google.driveUpload}/files?uploadType=multipart`;
const method = currentFileId ? 'PATCH' : 'POST';
const uploadUrl = currentFileId && folderId
? `${url}&addParents=${encodeURIComponent(folderId)}`
: url;
const uploadUrl =
currentFileId && folderId ? `${url}&addParents=${encodeURIComponent(folderId)}` : url;
const response = await fetch(uploadUrl, {
method,
@@ -392,7 +354,7 @@ async function uploadToDropbox(
targetPath = `${targetPath}/${fileName}`;
}
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
const response = await fetch(getProviderEndpoints().dropbox.upload, {
method: 'POST',
headers: {
Authorization: `Bearer ${refreshed.accessToken}`,
@@ -453,7 +415,7 @@ export async function exportPokedexIfConfigured(
pokedex.gameScope || '',
dexScopes
);
const csv = buildCsv(pokedex, combinedData);
const csv = buildCsv(combinedData);
const failures: ExportFailure[] = [];
let successes = 0;
@@ -473,13 +435,24 @@ export async function exportPokedexIfConfigured(
successes++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
let reconnectRequired = false;
if (error instanceof ReconnectRequiredError) {
// A revoked grant never succeeds on retry, so pause this integration until the user
// reconnects - but only if its row is unchanged since this export read it. A reconnect
// in the meantime saved new credentials, which this stale failure must not disable.
reconnectRequired = await scopedRepo.updateExportStatus(
integration._id,
{ lastError: message, enabled: false },
integration.updatedAt ?? undefined
);
} else {
await scopedRepo.updateExportStatus(integration._id, { lastError: message });
}
failures.push({
integrationId: integration._id,
provider: integration.provider,
error: message
});
await scopedRepo.updateExportStatus(integration._id, {
lastError: message
error: message,
reconnectRequired
});
console.error('Failed to export pokedex:', integration.provider, message);
}
+38
View File
@@ -0,0 +1,38 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Pokedex } from '$lib/models/Pokedex';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import { resolveDexScopes } from './PokedexDexScopeService';
import type { PokedexPerformance } from '$lib/server/pokedexPerformance';
export async function loadPokedexGrid(
supabase: SupabaseClient,
userId: string,
pokedex: Pokedex,
timings?: PokedexPerformance
) {
const measure = <T>(stage: 'scopes' | 'entries' | 'catches', run: () => Promise<T>) =>
timings ? timings.measure(stage, run) : run();
const scopes = await measure('scopes', () => resolveDexScopes(supabase, pokedex));
const repo = new CombinedDataRepository(supabase, userId, pokedex._id, true);
const entries = await measure('entries', () =>
repo.findGridEntries(pokedex.isFormDex, pokedex.gameScope || '', scopes)
);
return measure('catches', () => repo.joinGridCatches(entries));
}
export async function loadPokedexEntryDetail(
supabase: SupabaseClient,
userId: string,
pokedex: Pokedex,
entryId: number
) {
const scopes = await resolveDexScopes(supabase, pokedex);
const membership = new CombinedDataRepository(supabase, userId, pokedex._id, true);
const entries = await membership.findGridEntries(
pokedex.isFormDex,
pokedex.gameScope || '',
scopes
);
if (!entries.some((entry) => entry.id === entryId)) return null;
return new CombinedDataRepository(supabase, userId, pokedex._id).findEntryDetail(entryId);
}
+88
View File
@@ -0,0 +1,88 @@
import sharp from 'sharp';
import type { SharedPokedexData } from '$lib/models/SharedPokedex';
export const SHARE_PREVIEW_WIDTH = 1200;
export const SHARE_PREVIEW_HEIGHT = 630;
export function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
export function truncatePreviewText(value: string, maximumLength: number): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (normalized.length <= maximumLength) return normalized;
return `${normalized.slice(0, Math.max(0, maximumLength - 1)).trimEnd()}`;
}
function previewBadges(shared: SharedPokedexData): string[] {
return [
shared.isLivingDex && 'Living',
shared.isShinyDex && 'Shiny',
shared.isOriginDex && 'Origin',
shared.isFormDex && 'Form',
shared.gameScope || 'All Games'
].filter((value): value is string => Boolean(value));
}
export function buildSharePreviewSvg(shared: SharedPokedexData): string {
const name = escapeXml(truncatePreviewText(shared.name, 48));
const description = escapeXml(truncatePreviewText(shared.description, 92));
const badges = previewBadges(shared).slice(0, 5);
const badgeMarkup = badges
.map((badge, index) => {
const label = escapeXml(truncatePreviewText(badge, 22));
const width = Math.max(112, Math.min(220, 44 + badge.length * 15));
const previousWidth = badges
.slice(0, index)
.reduce((sum, value) => sum + Math.max(112, Math.min(220, 44 + value.length * 15)) + 16, 0);
return `<g transform="translate(${76 + previousWidth} 270)">
<rect width="${width}" height="52" rx="26" fill="#fee2e2" />
<text x="${width / 2}" y="34" text-anchor="middle" class="badge">${label}</text>
</g>`;
})
.join('');
const progressWidth = Math.round(
(870 * Math.min(100, Math.max(0, shared.completionPercentage))) / 100
);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SHARE_PREVIEW_WIDTH}" height="${SHARE_PREVIEW_HEIGHT}" viewBox="0 0 ${SHARE_PREVIEW_WIDTH} ${SHARE_PREVIEW_HEIGHT}">
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7f1d1d" />
<stop offset="1" stop-color="#dc2626" />
</linearGradient>
</defs>
<style>
.title { font: 700 64px system-ui, -apple-system, sans-serif; fill: #fff; }
.description { font: 400 27px system-ui, -apple-system, sans-serif; fill: #fecaca; }
.badge { font: 650 22px system-ui, -apple-system, sans-serif; fill: #991b1b; }
.progress { font: 750 52px system-ui, -apple-system, sans-serif; fill: #fff; }
.percent { font: 800 82px system-ui, -apple-system, sans-serif; fill: #fff; }
.brand { font: 650 24px system-ui, -apple-system, sans-serif; fill: #fecaca; letter-spacing: 1px; }
</style>
<rect width="1200" height="630" fill="url(#background)" />
<circle cx="1070" cy="90" r="190" fill="#fff" opacity=".08" />
<circle cx="1070" cy="90" r="62" fill="none" stroke="#fff" stroke-width="26" opacity=".16" />
<path d="M880 90h380" stroke="#fff" stroke-width="26" opacity=".16" />
<text x="76" y="118" class="brand">LIVING DEX TRACKER</text>
<text x="76" y="205" class="title">${name}</text>
${description ? `<text x="76" y="246" class="description">${description}</text>` : ''}
${badgeMarkup}
<text x="76" y="425" class="progress">${shared.caught} of ${shared.total} Pokémon caught</text>
<text x="1090" y="425" text-anchor="end" class="percent">${shared.completionPercentage}%</text>
<rect x="76" y="472" width="870" height="28" rx="14" fill="#450a0a" opacity=".65" />
<rect x="76" y="472" width="${progressWidth}" height="28" rx="14" fill="#fff" />
<text x="76" y="574" class="brand">pokedex.jcreek.co.uk</text>
</svg>`;
}
export async function renderSharePreview(shared: SharedPokedexData): Promise<Buffer> {
return sharp(Buffer.from(buildSharePreviewSvg(shared)))
.png()
.toBuffer();
}
+69
View File
@@ -0,0 +1,69 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import type {
SharedCatchStatus,
SharedPokedexData,
SharedPokedexRpcData
} from '$lib/models/SharedPokedex';
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isShareToken(value: string): boolean {
return UUID_PATTERN.test(value);
}
export function calculateSharedProgress(statuses: SharedCatchStatus[], total: number) {
const caught = statuses.reduce(
(sum, status) => sum + (status.caught || status.haveToEvolve ? 1 : 0),
0
);
return {
caught,
completionPercentage: total === 0 ? 0 : Math.round((caught / total) * 100)
};
}
export async function loadSharedPokedex(
supabase: SupabaseClient,
shareToken: string
): Promise<SharedPokedexData | null> {
if (!isShareToken(shareToken)) return null;
const { data, error } = await supabase.rpc('get_shared_pokedex', {
p_share_token: shareToken
});
if (error || !data) return null;
const shared = data as SharedPokedexRpcData;
const repo = new CombinedDataRepository(supabase, null, null);
const entries = await repo.findAllCombinedData(
'',
shared.isFormDex,
'',
shared.gameScope || '',
shared.dexScopes
);
const statuses = new Map(shared.catchStatuses.map((status) => [status.pokemonId, status]));
const combinedData = entries.map(({ pokedexEntry }) => ({
pokedexEntry,
catchRecord: statuses.get(pokedexEntry._id) ?? null
}));
const visibleStatuses = combinedData.flatMap(({ catchRecord }) =>
catchRecord ? [catchRecord] : []
);
const progress = calculateSharedProgress(visibleStatuses, combinedData.length);
return {
name: shared.name,
description: shared.description,
isLivingDex: shared.isLivingDex,
isShinyDex: shared.isShinyDex,
isOriginDex: shared.isOriginDex,
isFormDex: shared.isFormDex,
gameScope: shared.gameScope,
dexScopes: shared.dexScopes,
combinedData,
total: combinedData.length,
...progress
};
}
+72
View File
@@ -0,0 +1,72 @@
import { getEnv } from '$lib/utils/env';
export type ProviderEndpoints = {
google: { authorize: string; token: string; driveApi: string; driveUpload: string };
dropbox: { authorize: string; token: string; upload: string };
};
const DEFAULTS: ProviderEndpoints = {
google: {
authorize: 'https://accounts.google.com/o/oauth2/v2/auth',
token: 'https://oauth2.googleapis.com/token',
driveApi: 'https://www.googleapis.com/drive/v3',
driveUpload: 'https://www.googleapis.com/upload/drive/v3'
},
dropbox: {
authorize: 'https://www.dropbox.com/oauth2/authorize',
token: 'https://api.dropbox.com/oauth2/token',
upload: 'https://content.dropboxapi.com/2/files/upload'
}
};
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
/**
* These endpoints receive the OAuth client secret and the user's refresh token, so an override
* is only ever a local test seam - never a deployment knob. The guards require the explicit
* override flag, the BDD service-role context, and a loopback test stack. Each override must also
* be loopback, so a single injected variable cannot redirect credentials to an arbitrary host.
* Values are read at runtime from `$env/dynamic/private`.
*/
function isLocalOverride(value: string): boolean {
try {
const url = new URL(value);
return (
(url.protocol === 'http:' || url.protocol === 'https:') && LOOPBACK_HOSTS.has(url.hostname)
);
} catch {
return false;
}
}
export function resolveProviderEndpoints(
env: Record<string, string | undefined>
): ProviderEndpoints {
const overridesAllowed =
env.ALLOW_PROVIDER_ENDPOINT_OVERRIDES === 'true' &&
!!env.E2E_SERVICE_ROLE_KEY &&
!!env.TEST_SUPABASE_URL &&
isLocalOverride(env.TEST_SUPABASE_URL);
const pick = (override: string | undefined, fallback: string) =>
overridesAllowed && override && isLocalOverride(override) ? override : fallback;
return {
google: {
authorize: pick(env.GOOGLE_OAUTH_AUTHORIZE_URL, DEFAULTS.google.authorize),
token: pick(env.GOOGLE_OAUTH_TOKEN_URL, DEFAULTS.google.token),
driveApi: pick(env.GOOGLE_DRIVE_API_URL, DEFAULTS.google.driveApi),
driveUpload: pick(env.GOOGLE_DRIVE_UPLOAD_URL, DEFAULTS.google.driveUpload)
},
dropbox: {
authorize: pick(env.DROPBOX_OAUTH_AUTHORIZE_URL, DEFAULTS.dropbox.authorize),
token: pick(env.DROPBOX_OAUTH_TOKEN_URL, DEFAULTS.dropbox.token),
upload: pick(env.DROPBOX_UPLOAD_URL, DEFAULTS.dropbox.upload)
}
};
}
export function getProviderEndpoints(): ProviderEndpoints {
return resolveProviderEndpoints(getEnv());
}
export const PROVIDER_ENDPOINT_DEFAULTS = DEFAULTS;
+62
View File
@@ -0,0 +1,62 @@
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));
}
let refreshInFlight: { generation: number; promise: Promise<void> } | null = null;
export function refreshBackupStatus(): Promise<void> {
if (refreshInFlight?.generation === mutationGeneration) return refreshInFlight.promise;
const generation = mutationGeneration;
const promise = performRefresh().finally(() => {
if (refreshInFlight?.promise === promise) refreshInFlight = null;
});
refreshInFlight = { generation, promise };
return promise;
}
async function performRefresh(): 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([]);
}
+291
View File
@@ -0,0 +1,291 @@
import { afterCriticalPageWork } from '$lib/utils/criticalPageWork';
import { writable } from 'svelte/store';
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
import { resolveSpriteUrl, spriteRoot } from '$lib/utils/spriteUrl';
export type OfflineSyncStatus = {
state: 'idle' | 'syncing' | 'ready' | 'error';
generatedAt: string | null;
message: string | null;
};
export type ArtworkDownloadStatus = {
// unknown: not checked yet; missing: some sprites aren't saved; done: every sprite is saved.
state: 'unknown' | 'missing' | 'downloading' | 'done' | 'error';
missingBytes: number | null;
message: string | null;
};
export const offlineSyncStatus = writable<OfflineSyncStatus>({
state: 'idle',
generatedAt: null,
message: null
});
export const artworkDownloadStatus = writable<ArtworkDownloadStatus>({
state: 'unknown',
missingBytes: null,
message: null
});
const SYNC_EVENT = 'livingdex:offline-sync';
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
const OFFLINE_META_URL = '/__offline/current';
// Must match OFFLINE_META_FORMAT in static/offline-worker.js. Older copies are always re-synced so
// the worker can migrate them (e.g. drop the full-size artwork cache).
const OFFLINE_META_FORMAT = 2;
// A page load reuses an offline copy this recent instead of downloading the whole collection again.
// Changes made in the app request a sync explicitly, so this only delays picking up edits made on
// another device.
const SNAPSHOT_FRESH_MS = 15 * 60 * 1000;
type OfflineMeta = { userId: string; generatedAt?: string; format?: number };
async function workerMessage(
message: unknown,
timeoutMs = 120_000,
waitForReady = true
): Promise<Record<string, unknown>> {
if (!('serviceWorker' in navigator)) throw new Error('Service workers are unavailable');
const registration = waitForReady
? await navigator.serviceWorker.ready
: await navigator.serviceWorker.getRegistration();
if (!registration?.active) throw new Error('Offline worker is not active');
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = window.setTimeout(
() => reject(new Error('Offline worker timed out')),
timeoutMs
);
channel.port1.onmessage = (event) => {
window.clearTimeout(timeout);
const result = event.data as Record<string, unknown>;
if (result?.ok) resolve(result);
else reject(new Error(String(result?.error ?? 'Offline worker failed')));
};
registration.active?.postMessage(message, [channel.port2]);
});
}
export function requestOfflineSync(): void {
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT));
}
async function readOfflineMeta(): Promise<OfflineMeta | null> {
if (!('caches' in window)) return null;
if (!(await caches.keys()).includes(OFFLINE_META_CACHE)) return null;
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
const meta = await response?.json().catch(() => null);
return typeof meta?.userId === 'string' ? meta : null;
}
async function deleteOfflineCaches(): Promise<void> {
if (typeof window === 'undefined' || !('caches' in window)) return;
const names = await caches.keys();
await Promise.all(
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
);
}
export async function claimOfflineData(userId: string): Promise<void> {
if (typeof window === 'undefined') return;
if ('caches' in window && (await caches.keys()).includes(OFFLINE_META_CACHE)) {
const meta = await readOfflineMeta();
if (meta?.userId !== userId) await deleteOfflineCaches();
}
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
}
}
export async function clearOfflineData(): Promise<void> {
if (typeof window === 'undefined') return;
await deleteOfflineCaches();
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false);
}
// Sprites are kept across sign-out (they aren't account data), so the artwork status stays valid.
offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null });
}
function currentSpriteRoot(): string {
return spriteRoot(PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true');
}
function applyArtworkResult(result: Record<string, unknown>): void {
const missing = Number(result.missing ?? 0);
const failed = Number(result.failedArtwork ?? 0);
if (failed > 0) {
artworkDownloadStatus.set({
state: 'error',
missingBytes: Number(result.missingBytes ?? 0),
message: `${failed} artwork files could not be saved`
});
} else {
artworkDownloadStatus.set({
state: missing > 0 ? 'missing' : 'done',
missingBytes: Number(result.missingBytes ?? 0),
message: null
});
}
}
/** Checks whether every sprite (all forms, shiny and female) is already saved on this device. */
export async function checkArtworkStatus(): Promise<void> {
if (typeof window === 'undefined') return;
try {
applyArtworkResult(
await workerMessage({ type: 'ARTWORK_STATUS', spriteRoot: currentSpriteRoot() }, 30_000)
);
} catch (error) {
// Leave the link hidden rather than offering a download that can't be checked.
console.error('Unable to check saved artwork', error);
}
}
/**
* Artwork is normally cached as it is viewed. This saves every sprite that exists - all forms,
* shiny and female variants, not just the saved dexes - skipping any that are already cached.
*/
export async function downloadAllArtwork(): Promise<void> {
if (typeof window === 'undefined' || !navigator.onLine) return;
artworkDownloadStatus.update((status) => ({ ...status, state: 'downloading', message: null }));
try {
// Generous timeout: the worker fetches each missing sprite with its own 15s limit.
applyArtworkResult(
await workerMessage(
{ type: 'CACHE_ALL_ARTWORK', spriteRoot: currentSpriteRoot() },
30 * 60 * 1000
)
);
} catch (error) {
artworkDownloadStatus.update((status) => ({
...status,
state: 'error',
message: error instanceof Error ? error.message : String(error)
}));
}
}
export function startOfflineSync(getUserId: () => string | null): () => void {
let timer: number | null = null;
let stopped = false;
let running = false;
let rerun = false;
let lastGeneratedAt: string | null = null;
const synchronize = async (reuseFreshCopy: boolean) => {
if (stopped || !navigator.onLine) return;
if (running) {
rerun = true;
return;
}
const userId = getUserId();
if (!userId || !('serviceWorker' in navigator)) return;
running = true;
try {
await claimOfflineData(userId);
if (reuseFreshCopy) {
const meta = await readOfflineMeta();
const age = meta?.generatedAt ? Date.now() - Date.parse(meta.generatedAt) : Infinity;
if (
meta?.userId === userId &&
meta.format === OFFLINE_META_FORMAT &&
age >= 0 &&
age < SNAPSHOT_FRESH_MS
) {
lastGeneratedAt = meta.generatedAt ?? null;
offlineSyncStatus.set({ state: 'ready', generatedAt: lastGeneratedAt, message: null });
void checkArtworkStatus();
return;
}
}
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
const response = await fetch('/api/offline-snapshot', {
credentials: 'include',
headers: { Accept: 'application/json' }
});
if (!response.ok) throw new Error(`Snapshot request failed (${response.status})`);
const snapshot = (await response.json()) as OfflineSnapshot;
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
// The worker derives which artwork belongs to the collection from these URLs, and the offline
// viewer renders them.
for (const { pokedex, entries } of snapshot.pokedexes) {
for (const { pokedexEntry } of entries) {
(pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }).offlineSpriteUrl =
resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
}
}
if (getUserId() !== userId) throw new Error('Session changed during offline synchronization');
await workerMessage({ type: 'SYNC_OFFLINE_SNAPSHOT', snapshot });
offlineSyncStatus.set({ state: 'ready', generatedAt: snapshot.generatedAt, message: null });
lastGeneratedAt = snapshot.generatedAt;
void checkArtworkStatus();
} catch (error) {
offlineSyncStatus.set({
state: 'error',
generatedAt: lastGeneratedAt,
message: error instanceof Error ? error.message : String(error)
});
} finally {
running = false;
if (rerun && !stopped) {
rerun = false;
schedule();
}
}
};
const scheduleSync = (reuseFreshCopy: boolean) => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
timer = null;
void synchronize(reuseFreshCopy);
}, 1_000);
};
// Explicit requests (edits, Retry, a new sign-in) and reconnecting always fetch a new copy.
const schedule = () => {
cancelStartup();
scheduleSync(false);
};
// Best effort: ask the browser not to evict the offline artwork cache under storage pressure.
void navigator.storage?.persist?.().catch(() => undefined);
window.addEventListener(SYNC_EVENT, schedule);
window.addEventListener('online', schedule);
const cancelStartup = afterCriticalPageWork(() => scheduleSync(true));
return () => {
stopped = true;
cancelStartup();
if (timer !== null) window.clearTimeout(timer);
window.removeEventListener(SYNC_EVENT, schedule);
window.removeEventListener('online', schedule);
};
}
/** Read details only from the snapshot currently claimed by this account. */
export async function readOfflineEntry(userId: string, pokedexId: string, entryId: string) {
if (typeof window === 'undefined' || !('caches' in window)) return null;
const meta = (await readOfflineMeta()) as (OfflineMeta & { dataCache?: string }) | null;
if (
meta?.userId !== userId ||
meta.format !== OFFLINE_META_FORMAT ||
!meta.dataCache?.startsWith(`${OFFLINE_CACHE_PREFIX}data-v1-${userId}-`)
)
return null;
const response = await (
await caches.open(meta.dataCache)
).match(`/__offline/snapshot/${encodeURIComponent(userId)}`);
const snapshot = (await response?.json()) as OfflineSnapshot | undefined;
const current = await readOfflineMeta();
if (current?.userId !== userId || snapshot?.userId !== userId || snapshot.version !== 1)
return null;
return (
snapshot.pokedexes
.find((dex) => dex.pokedex._id === pokedexId)
?.entries.find((row) => row.pokedexEntry._id === entryId) ?? null
);
}
+4 -4
View File
@@ -7,11 +7,11 @@ import { error } from '@sveltejs/kit';
*/
export async function requireAuth(event: RequestEvent): Promise<string> {
const { session, user } = await event.locals.safeGetSession();
if (!session || !user) {
throw error(401, 'Authentication required');
}
return user.id;
}
@@ -21,9 +21,9 @@ export async function requireAuth(event: RequestEvent): Promise<string> {
*/
export async function getOptionalUserId(event: RequestEvent): Promise<string | null> {
try {
const { session, user } = await event.locals.safeGetSession();
const { user } = await event.locals.safeGetSession();
return user?.id || null;
} catch {
return null;
}
}
}
-1
View File
@@ -13,7 +13,6 @@ export function calculateBoxPlacement(index: number): {
} {
const POKEMON_PER_BOX = 30;
const COLUMNS_PER_BOX = 6;
const ROWS_PER_BOX = 5;
// Calculate which box this Pokémon belongs to (1-indexed)
const box = Math.floor(index / POKEMON_PER_BOX) + 1;
+19 -8
View File
@@ -1,5 +1,5 @@
import { writable, type Readable } from 'svelte/store';
import type { CatchRecord } from '$lib/models/CatchRecord';
import type { CatchRecordPatch } from '$lib/models/PokedexGridRow';
export type CatchRecordWriteQueueStatus = {
pending: number;
@@ -10,7 +10,7 @@ export type CatchRecordWriteQueueStatus = {
};
type QueueItem = {
record: CatchRecord;
record: CatchRecordPatch;
attempts: number;
notBefore: number; // unix ms
debounceTimer: ReturnType<typeof setTimeout> | null;
@@ -31,6 +31,8 @@ export type CreateCatchRecordWriteQueueOptions = {
batchSize?: number;
/** Max number of concurrent in-flight requests. */
concurrency?: number;
/** Prevent queued work from crossing an account change. */
isCurrentUser?: () => boolean;
};
export type EnqueueCatchRecordWriteOptions = {
@@ -53,7 +55,7 @@ export type FlushOptions = {
limit?: number;
};
function keyFor(record: CatchRecord): string {
function keyFor(record: CatchRecordPatch): string {
return `${record.userId}:${record.pokedexId}:${record.pokemonId}`;
}
@@ -66,11 +68,12 @@ function backoffMs(attempts: number): number {
}
export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueueOptions): {
enqueue: (record: CatchRecord, opts?: EnqueueCatchRecordWriteOptions) => void;
enqueue: (record: CatchRecordPatch, opts?: EnqueueCatchRecordWriteOptions) => void;
flushNow: (opts?: FlushOptions) => Promise<void>;
getStatus: Readable<CatchRecordWriteQueueStatus>;
getPendingCount: () => number;
clearError: () => void;
getPendingPatch: (pokemonId: string) => CatchRecordPatch | undefined;
} {
const { endpointUrl, fetchFn, batchSize = 100, concurrency = 1 } = options;
@@ -109,7 +112,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
}
function scheduleFlush() {
if (scheduled) return;
if (scheduled || (typeof navigator !== 'undefined' && navigator.onLine === false)) return;
const next = computeNextWakeup();
if (next === null) return;
const delay = Math.max(0, next - Date.now());
@@ -120,6 +123,12 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
}
async function flushBatch(opts?: FlushOptions): Promise<void> {
if (options.isCurrentUser && !options.isCurrentUser()) {
for (const item of items.values()) if (item.debounceTimer) clearTimeout(item.debounceTimer);
items.clear();
updateStatus({ lastError: null });
return;
}
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
// Stay queued; caller can retry when online.
return;
@@ -206,7 +215,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
scheduleFlush();
}
function enqueue(record: CatchRecord, opts?: EnqueueCatchRecordWriteOptions) {
function enqueue(record: CatchRecordPatch, opts?: EnqueueCatchRecordWriteOptions) {
const k = keyFor(record);
const now = Date.now();
const existing = items.get(k);
@@ -232,7 +241,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
}
items.set(k, {
record,
record: { ...existing?.record, ...record },
attempts: existing?.attempts ?? 0,
notBefore,
debounceTimer,
@@ -255,6 +264,8 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
flushNow,
getStatus: { subscribe: statusStore.subscribe },
getPendingCount,
clearError
clearError,
getPendingPatch: (pokemonId: string) =>
[...items.values()].find((item) => item.record.pokemonId === pokemonId)?.record
};
}
+50
View File
@@ -0,0 +1,50 @@
/** Shared scheduling boundary for optional page-start work. Explicit refreshes bypass it. */
const READY_EVENT = 'livingdex:grid-interactive';
export function markGridInteractive() {
if (typeof window === 'undefined') return;
if (!document.querySelector('[data-entry-index]')) return;
if (!document.querySelector('[data-grid-interactive]')) {
performance.mark('pokedex:first-interactive');
}
document
.querySelector('[data-entry-index]')
?.closest('.boxes-grid')
?.setAttribute('data-grid-interactive', 'true');
window.dispatchEvent(new Event(READY_EVENT));
}
export function afterCriticalPageWork(run: () => void): () => void {
let cancelled = false;
let scheduled = false;
let idle: number | undefined;
let frame: number | undefined;
const perform = () => {
if (cancelled) return;
cancel();
run();
};
const schedule = () => {
if (scheduled || cancelled) return;
scheduled = true;
frame = requestAnimationFrame(() => {
if ('requestIdleCallback' in window)
idle = window.requestIdleCallback(perform, { timeout: 5000 });
else frame = requestAnimationFrame(perform);
});
};
const fallback = window.setTimeout(perform, 5000);
const cancel = () => {
cancelled = true;
window.clearTimeout(fallback);
window.removeEventListener(READY_EVENT, schedule);
if (idle !== undefined) window.cancelIdleCallback(idle);
if (frame !== undefined) cancelAnimationFrame(frame);
};
window.addEventListener(READY_EVENT, schedule);
if (
!location.pathname.startsWith('/pokedex/') ||
document.querySelector('[data-grid-interactive]')
)
schedule();
return cancel;
}
+51 -51
View File
@@ -24,62 +24,62 @@ type RegionalDexKey =
const gameToRegionalDexMap: Record<string, RegionalDexKey> = {
// Kanto region
'Red': 'kanto',
'Blue': 'kanto',
'Yellow': 'kanto',
'FireRed': 'kanto',
'LeafGreen': 'kanto',
Red: 'kanto',
Blue: 'kanto',
Yellow: 'kanto',
FireRed: 'kanto',
LeafGreen: 'kanto',
'LG: Pikachu': 'kanto',
'LG: Eevee': 'kanto',
// Johto region
'Gold': 'johto',
'Silver': 'johto',
'Crystal': 'johto',
'HeartGold': 'johto',
'SoulSilver': 'johto',
Gold: 'johto',
Silver: 'johto',
Crystal: 'johto',
HeartGold: 'johto',
SoulSilver: 'johto',
// Hoenn region
'Ruby': 'hoenn',
'Sapphire': 'hoenn',
'Emerald': 'hoenn',
'OmegaRuby': 'hoenn',
'AlphaSapphire': 'hoenn',
Ruby: 'hoenn',
Sapphire: 'hoenn',
Emerald: 'hoenn',
OmegaRuby: 'hoenn',
AlphaSapphire: 'hoenn',
// Sinnoh region
'Diamond': 'sinnoh',
'Pearl': 'sinnoh',
'Platinum': 'sinnoh',
'BrilliantDiamond': 'sinnoh',
'ShiningPearl': 'sinnoh',
Diamond: 'sinnoh',
Pearl: 'sinnoh',
Platinum: 'sinnoh',
BrilliantDiamond: 'sinnoh',
ShiningPearl: 'sinnoh',
// Unova region
'Black': 'unova_bw',
'White': 'unova_bw',
'Black2': 'unova_b2w2',
'White2': 'unova_b2w2',
Black: 'unova_bw',
White: 'unova_bw',
Black2: 'unova_b2w2',
White2: 'unova_b2w2',
// Kalos region - Note: All XY use all three sub-dexes
// We default to Central for simplicity
'X': 'kalos_central',
'Y': 'kalos_central',
X: 'kalos_central',
Y: 'kalos_central',
// Alola region
'Sun': 'alola_sm',
'Moon': 'alola_sm',
'UltraSun': 'alola_usum',
'UltraMoon': 'alola_usum',
Sun: 'alola_sm',
Moon: 'alola_sm',
UltraSun: 'alola_usum',
UltraMoon: 'alola_usum',
// Galar region
'Sword': 'galar',
'Shield': 'galar',
Sword: 'galar',
Shield: 'galar',
// Hisui region
'LegendsArceus': 'hisui',
LegendsArceus: 'hisui',
// Paldea region
'Scarlet': 'paldea',
'Violet': 'paldea'
Scarlet: 'paldea',
Violet: 'paldea'
};
/**
@@ -119,22 +119,22 @@ export function getRegionalDexFieldName(gameName: string): string | undefined {
// Map regional key to actual PokedexEntry field name (camelCase)
const fieldMap: Record<string, string> = {
'kanto': 'kantoDexNumber',
'johto': 'johtoDexNumber',
'hoenn': 'hoennDexNumber',
'sinnoh': 'sinnohDexNumber',
'unova_bw': 'unovaBwDexNumber',
'unova_b2w2': 'unovaB2w2DexNumber',
'kalos_central': 'kalosCentralDexNumber',
'kalos_coastal': 'kalosCoastalDexNumber',
'kalos_mountain': 'kalosMountainDexNumber',
'alola_sm': 'alolaSmDexNumber',
'alola_usum': 'alolaUsumDexNumber',
'galar': 'galarDexNumber',
'galar_isle_of_armor': 'galarIsleOfArmorDexNumber',
'galar_crown_tundra': 'galarCrownTundraDexNumber',
'hisui': 'hisuiDexNumber',
'paldea': 'paldeaDexNumber'
kanto: 'kantoDexNumber',
johto: 'johtoDexNumber',
hoenn: 'hoennDexNumber',
sinnoh: 'sinnohDexNumber',
unova_bw: 'unovaBwDexNumber',
unova_b2w2: 'unovaB2w2DexNumber',
kalos_central: 'kalosCentralDexNumber',
kalos_coastal: 'kalosCoastalDexNumber',
kalos_mountain: 'kalosMountainDexNumber',
alola_sm: 'alolaSmDexNumber',
alola_usum: 'alolaUsumDexNumber',
galar: 'galarDexNumber',
galar_isle_of_armor: 'galarIsleOfArmorDexNumber',
galar_crown_tundra: 'galarCrownTundraDexNumber',
hisui: 'hisuiDexNumber',
paldea: 'paldeaDexNumber'
};
return fieldMap[regionalKey];
+59
View File
@@ -0,0 +1,59 @@
/** Folder holding every sprite; paths in static/sprites-small/manifest.json are relative to it. */
export function spriteRoot(useLocalSprites: boolean): string {
return useLocalSprites
? '/sprites-small/home'
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
}
export function resolveSpriteUrl(
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
shiny: boolean,
useLocalSprites: boolean
): string {
const form = entry.form?.trim() ?? '';
const strippedNumber = String(entry.pokedexNumber).replace(/^0+/, '') || '0';
let key = entry.spriteKey?.trim();
if (!key) {
let formKey = form
.replace(/^female[-\s]*/i, '')
.replace(/\s*\(.*?\)/g, '')
.replace(/\s*\[.*?\]/g, '')
.trim();
if (!formKey || formKey.toLowerCase() === 'male') {
key = strippedNumber;
} else {
formKey = formKey
.toLowerCase()
.replace(/%/g, '')
.replace(/\balolan\b/g, 'alola')
.replace(/\bgalarian\b/g, 'galar')
.replace(/\bhisuian\b/g, 'hisui')
.replace(/\bpaldean\b/g, 'paldea')
.replace(/\bform(e)?$/, '')
.replace(/\bability$/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.replace(/2/g, 'two')
.replace(/3/g, 'three')
.replace(/4/g, 'four');
key = formKey ? `${strippedNumber}-${formKey}` : strippedNumber;
}
}
let root = spriteRoot(useLocalSprites);
if (shiny) root += '/shiny';
if (/^female\b/i.test(form)) root += '/female';
return `${root}/${key}.webp`;
}
/** Grid assets use a separate immutable URL space; detail resolution is unchanged. */
export function resolveGridSpriteUrl(
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
shiny: boolean
): string {
return resolveSpriteUrl(entry, shiny, true).replace(
'/sprites-small/home/',
'/sprites-grid/v1/home/'
);
}
+5 -35
View File
@@ -2,26 +2,18 @@
/// <reference types="vite/client" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
import {
cleanupOutdatedCaches,
// createHandlerBoundToURL,
precacheAndRoute,
precache
} from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
declare let self: ServiceWorkerGlobalScope;
// Kept as a static script so the same snapshot, artwork and navigation behavior can be imported
// by Workbox's generateSW output too.
self.importScripts('/offline-worker.js');
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
});
// Add root route to precache manifest
precache([{ url: '/', revision: null }]);
// self.__WB_MANIFEST is default injection point
// Handle the case where __WB_MANIFEST might be undefined in development
const manifest = self.__WB_MANIFEST || [];
@@ -29,26 +21,4 @@ if (Array.isArray(manifest)) {
precacheAndRoute(manifest);
}
// clean old assets
cleanupOutdatedCaches();
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 3000,
maxAgeSeconds: 60 * 60 * 24 * 30,
purgeOnQuotaError: true
})
]
})
);
// let allowlist: undefined | RegExp[];
// if (import.meta.env.DEV) allowlist = [/^\/$/];
// // to allow work offline
// registerRoute(new NavigationRoute(createHandlerBoundToURL('/'), { allowlist }));
+4 -2
View File
@@ -1,10 +1,12 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => {
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
const { session, user } = await safeGetSession();
return {
session,
user
user,
// The universal layout load rebuilds a server-side client from these during SSR.
cookies: cookies.getAll()
};
};
+134 -35
View File
@@ -1,11 +1,22 @@
<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 { afterCriticalPageWork } from '$lib/utils/criticalPageWork';
import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js';
import SignIn from '$lib/components/SignIn.svelte';
import SignOut from '$lib/components/SignOut.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import { page } from '$app/stores';
import { claimOfflineData, requestOfflineSync, startOfflineSync } from '$lib/stores/offlineSync';
import {
PROVIDER_LABELS,
backupsNeedingReconnect,
clearBackupStatus,
refreshBackupStatus
} from '$lib/stores/backupStatus';
import { pwaInfo } from 'virtual:pwa-info';
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
@@ -17,55 +28,95 @@
let { supabase } = data;
$: ({ supabase } = data);
let localUser = null as User | null;
let localUser: User | null = data.user ?? null;
let userStoreReady = false;
const unsubscribe = user.subscribe((value) => {
localUser = value;
if (userStoreReady) localUser = value;
});
onDestroy(unsubscribe);
let authSubscription: { unsubscribe: () => void } | null = null;
let cancelBackupStartup: (() => void) | null = null;
let stopOfflineSync: (() => void) | null = null;
let isOnline = true;
let signOutError = '';
onMount(() => {
void getUser();
userStoreReady = true;
isOnline = navigator.onLine;
const updateOnlineState = () => {
isOnline = navigator.onLine;
document.documentElement.classList.toggle('offline-readonly', !isOnline);
};
const blockOfflineMutation = (event: Event) => {
if (navigator.onLine) return;
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest('button, input, textarea, select, form')) return;
if (target.closest('[data-offline-action]')) return;
event.preventDefault();
event.stopImmediatePropagation();
};
window.addEventListener('online', updateOnlineState);
window.addEventListener('offline', updateOnlineState);
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
window.addEventListener(name, blockOfflineMutation, true);
}
updateOnlineState();
void getUser()
.then(async () => {
if (localUser) {
cancelBackupStartup = afterCriticalPageWork(() => {
if (localUser) void refreshBackupStatus();
});
await claimOfflineData(localUser.id);
}
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
})
.catch((error) => console.error('Unable to claim offline data', error));
// Listen for auth state changes to keep the user store in sync
const {
data: { subscription }
} = supabase.auth.onAuthStateChange((event, session) => {
const previousUserId = localUser?.id ?? null;
if (session) {
localUser = session.user;
// SIGNED_IN also fires when a tab regains focus, so only a change of account fetches a new
// offline copy. Page loads and token refreshes reuse the saved one while it is fresh.
if (event === 'SIGNED_IN' && session.user.id !== previousUserId) {
cancelBackupStartup?.();
clearBackupStatus();
void claimOfflineData(session.user.id)
.then(requestOfflineSync)
.catch((error) => console.error('Unable to claim offline data', error));
void refreshBackupStatus();
}
} else {
// Offline data is only cleared by the Sign Out button (or another account claiming it).
// An expired or rejected session must not throw away artwork that would then have to be
// downloaded again after signing back in.
localUser = null;
clearBackupStatus();
}
user.set(localUser);
});
authSubscription = subscription;
if (pwaInfo) {
void (async () => {
const { registerSW } = await import('virtual:pwa-register');
registerSW({
immediate: true,
onRegistered(r) {
// uncomment following code if you want check for updates
// r && setInterval(() => {
// console.log('Checking for sw update')
// r.update()
// }, 20000 /* 20s for testing purposes */)
console.log(`SW Registered: ${r}`);
},
onRegisterError(error) {
console.log('SW registration error', error);
}
});
})();
}
return () => {
authSubscription?.unsubscribe();
stopOfflineSync?.();
cancelBackupStartup?.();
window.removeEventListener('online', updateOnlineState);
window.removeEventListener('offline', updateOnlineState);
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
window.removeEventListener(name, blockOfflineMutation, true);
}
document.documentElement.classList.remove('offline-readonly');
};
});
$: reconnectLabels = $backupsNeedingReconnect.map((provider) => PROVIDER_LABELS[provider]);
async function getUser() {
const {
data: { session }
@@ -99,13 +150,6 @@
name="description"
content="A free and open source web app to track completion of a living Pokédex, which works offline."
/> -->
<meta property="og:title" content="Living Dex Tracker - A free Pokédex completion tool" />
<meta property="og:url" content="https://pokedex.jcreek.co.uk" />
<meta
property="og:description"
content="A free and open source web app to track completion of a living Pokédex, which works offline."
/>
<link rel="canonical" href="https://pokedex.jcreek.co.uk" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- <link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" /> -->
</svelte:head>
@@ -148,7 +192,7 @@
{#if localUser}
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<img alt="usericon" src="/OIG5.jpg" />
<img alt="Account menu" src="/avatar.webp" width="40" height="40" />
</div>
</div>
<ul
@@ -162,7 +206,19 @@
<li>
<a href="/backup-settings"> Backup Settings </a>
</li>
<li><SignOut {supabase} on:signedOut={getUser} /></li>
<li>
<a href="/offline-guide"> Using Offline </a>
</li>
<li>
<SignOut
{supabase}
on:signedOut={() => {
signOutError = '';
void getUser();
}}
on:signOutFailed={(event) => (signOutError = event.detail.message)}
/>
</li>
{:else}
<li><SignIn {supabase} on:signedIn={getUser} /></li>
{/if}
@@ -174,6 +230,28 @@
</div>
</div>
</header>
{#if signOutError}
<div class="alert alert-error rounded-none" role="alert">
<span>{signOutError}</span>
</div>
{/if}
{#if !isOnline}
<div class="alert rounded-none" role="status">
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
</div>
{/if}
{#if localUser && reconnectLabels.length > 0 && $page.url.pathname !== '/backup-settings'}
<div
class="alert alert-warning rounded-none"
role="alert"
data-testid="backup-reconnect-banner"
>
<span>
Your {reconnectLabels.join(' and ')} backup has stopped because access expired or was revoked.
</span>
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
</div>
{/if}
<main class="flex-grow">
<slot />
@@ -181,8 +259,14 @@
<footer class="footer items-center p-4 bg-neutral text-neutral-content bottom-0">
<aside class="items-center grid-flow-col">
<a href="https://github.com/jcreek/LivingDexTracker" target="_blank">
<a
href="https://github.com/jcreek/LivingDexTracker"
target="_blank"
rel="noopener noreferrer"
aria-label="Living Dex Tracker on GitHub"
>
<svg
aria-hidden="true"
width="36"
height="36"
fill-rule="evenodd"
@@ -203,8 +287,13 @@
</p>
</aside>
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
<a href="https://discord.gg/SQcJkaXDye" target="_blank"
<a
href="https://discord.gg/SQcJkaXDye"
target="_blank"
rel="noopener noreferrer"
aria-label="Living Dex Tracker Discord community"
><svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -223,3 +312,13 @@
<ReloadPrompt />
{/await}
</div>
<style>
:global(.offline-readonly button:not([data-offline-action])),
:global(.offline-readonly input:not([data-offline-action])),
:global(.offline-readonly textarea:not([data-offline-action])),
:global(.offline-readonly select:not([data-offline-action])) {
pointer-events: none;
opacity: 0.65;
}
</style>
+48 -16
View File
@@ -1,25 +1,52 @@
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import type { LayoutLoad } from './$types';
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr';
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr';
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('supabase:auth');
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch
},
cookies: {
get(key) {
if (!isBrowser()) {
return JSON.stringify(data.session);
}
const cookie = parse(document.cookie);
return cookie[key];
let recoveryExchangeSucceeded = false;
let hashRecoveryCallback = false;
let codeRecoveryCallback = false;
if (isBrowser() && window.location.pathname === '/reset-password') {
const hash = new URLSearchParams(window.location.hash.slice(1));
hashRecoveryCallback =
hash.get('type') === 'recovery' && hash.has('access_token') && hash.has('refresh_token');
codeRecoveryCallback = new URL(window.location.href).searchParams.has('code');
}
const authFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (codeRecoveryCallback && response.ok) {
const requestUrl = new URL(
typeof input === 'string' || input instanceof URL ? input : input.url,
window.location.origin
);
if (
requestUrl.pathname.endsWith('/auth/v1/token') &&
requestUrl.searchParams.get('grant_type') === 'pkce'
) {
recoveryExchangeSucceeded = true;
}
}
});
return response;
};
// The browser client manages document.cookie itself, including removing stale session chunks.
const supabase = isBrowser()
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch: authFetch
}
})
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch
},
cookies: {
getAll() {
return data.cookies;
}
}
});
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
@@ -30,5 +57,10 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
data: { session }
} = await supabase.auth.getSession();
return { supabase, session };
return {
supabase,
session,
user: data.user,
recoveryIntent: !!session && (hashRecoveryCallback || recoveryExchangeSucceeded)
};
};
+20 -9
View File
@@ -1,17 +1,28 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export type PublicStats = {
pokemonCaught: number;
users: number;
livingDexesCompleted: number;
};
/**
* Server-side load function for the homepage
*
* Fetches public statistics from the database and passes them to the page component.
* Stats are cached for 24 hours to improve performance.
* Signed-in users go straight to their Pokédexes. For everyone else the page renders at once and
* the public statistics stream in afterwards, so a slow stats query never delays the first paint.
*/
export const load: PageServerLoad = async ({ fetch }) => {
// Fetch stats from database
const statsResponse = await fetch('/api/stats');
const statsData = await statsResponse.json();
export const load: PageServerLoad = async ({ fetch, locals }) => {
const { user } = await locals.safeGetSession();
if (user) {
throw redirect(303, '/my-pokedexes');
}
return {
stats: statsData.error ? null : statsData
};
const stats: Promise<PublicStats | null> = fetch('/api/stats')
.then((response) => response.json())
.then((statsData) => (statsData.error ? null : statsData))
.catch(() => null);
return { stats };
};
+310 -345
View File
@@ -1,42 +1,12 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js';
import SignUp from '$lib/components/SignUp.svelte';
import { goto } from '$app/navigation';
// Signed-in visitors are redirected by the server load, so the page renders straight away.
export let data;
let { supabase, stats } = data;
$: ({ supabase, stats } = data);
let localUser: User | null;
const unsubscribe = user.subscribe((value) => {
localUser = value;
});
onDestroy(unsubscribe);
let isCheckingSession = true;
onMount(() => {
checkSessionAndRedirect();
});
async function checkSessionAndRedirect() {
try {
const {
data: { session }
} = await supabase.auth.getSession();
if (session) {
await goto('/my-pokedexes');
}
} catch (error) {
console.error('Error checking session:', error);
} finally {
isCheckingSession = false;
}
}
async function handleSignedUp() {
await goto('/welcome');
}
@@ -51,16 +21,6 @@
}
return num.toString();
}
// Get formatted stats or fallback to 0
let pokemonCaught = '0';
let users = '0';
let livingDexesCompleted = '0';
$: {
pokemonCaught = formatNumber(stats?.pokemonCaught ?? 0);
users = formatNumber(stats?.users ?? 0);
livingDexesCompleted = formatNumber(stats?.livingDexesCompleted ?? 0);
}
</script>
<svelte:head>
@@ -69,324 +29,329 @@
name="description"
content="A free, open source tool to track your Living Pokédex progress. Join thousands of trainers worldwide in completing their collection."
/>
<link rel="canonical" href="https://pokedex.jcreek.co.uk" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Living Dex Tracker - A free Pokédex completion tool" />
<meta property="og:url" content="https://pokedex.jcreek.co.uk" />
<meta
property="og:description"
content="A free and open source web app to track completion of a living Pokédex, which works offline."
/>
</svelte:head>
{#if isCheckingSession}
<!-- Loading placeholder while checking session -->
<div class="hero bg-base-100 my-36">
<div class="hero-content flex-col">
<span class="loading loading-spinner loading-lg"></span>
<!-- Hero Section -->
<div class="hero bg-base-100 my-36">
<div class="hero-content flex-col lg:flex-row-reverse">
<div class="text-center lg:text-left">
<div class="flex flex-wrap gap-2 mb-4">
<div class="badge badge-primary badge-lg gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
Free
</div>
<div class="badge badge-secondary badge-lg gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
Open Source
</div>
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clip-rule="evenodd"
/>
</svg>
Offline-friendly
</a>
</div>
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
<p class="text-xl mb-6 text-base-content/80">
Track your progress towards a complete Living Pokédex — one of every Pokémon, actively
maintained across your boxes. Join thousands of trainers worldwide.
</p>
<div class="flex flex-wrap gap-3 justify-center lg:justify-start">
<a
href="https://discord.gg/2ytj4pkUPY"
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary btn-lg"
aria-label="Join the Living Dex Tracker Discord community"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
Join Discord
</a>
<a
href="https://github.com/jcreek/LivingDexTracker"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-lg"
aria-label="View the Living Dex Tracker project on GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
Contribute on GitHub
</a>
</div>
</div>
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Get Started Free</h2>
<SignUp {supabase} on:signedUp={handleSignedUp} />
</div>
</div>
</div>
{:else}
<!-- Hero Section -->
<div class="hero bg-base-100 my-36">
<div class="hero-content flex-col lg:flex-row-reverse">
<div class="text-center lg:text-left">
<div class="flex flex-wrap gap-2 mb-4">
<div class="badge badge-primary badge-lg gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
Free
</div>
<div class="badge badge-secondary badge-lg gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
Open Source
</div>
<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>
<!-- Stats Section -->
<div class="bg-base-200 py-16">
<div class="container mx-auto px-4">
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
<div class="stat">
<div class="stat-title">Pokémon caught</div>
<div class="stat-value text-primary">
{#await stats}{:then value}{formatNumber(value?.pokemonCaught ?? 0)}{/await}
</div>
</div>
<div class="stat">
<div class="stat-title">Users</div>
<div class="stat-value text-primary">
{#await stats}{:then value}{formatNumber(value?.users ?? 0)}{/await}
</div>
</div>
<div class="stat">
<div class="stat-title">Living Dexes Completed</div>
<div class="stat-value text-primary">
{#await stats}{:then value}{formatNumber(value?.livingDexesCompleted ?? 0)}{/await}
</div>
</div>
</div>
</div>
</div>
<!-- What is a Living Dex Section -->
<div class="py-16 bg-base-100">
<div class="container mx-auto px-4 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-3xl mb-4">What is a Living Dex?</h2>
<p class="text-lg text-base-content/80">
A Living Dex is a complete Pokédex where you keep one of every Pokémon in your boxes
(often including forms/variants). Living Dex Tracker helps you build and maintain that
collection with filters, notes, and progress tracking.
</p>
</div>
</div>
</div>
</div>
<!-- Features Section -->
<div class="py-16 bg-base-200">
<div class="container mx-auto px-4 max-w-6xl">
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
<div class="grid md:grid-cols-2 gap-6">
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Social & Shareable</h2>
<p class="text-base-content/80">
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go
it alone, that's okay too!
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
<p class="text-base-content/80">
Completely open source and free to use, enabling the community to contribute updates
as soon as new Pokémon are released.
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Advanced Filtering</h2>
<p class="text-base-content/80">
Track simple progress or tackle harder variants like a Living Origin Form Dex with
our powerful filtering options for targeted catching sessions.
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">100% free</h2>
<p class="text-base-content/80">
Did we mention it's completely free to use? Oh, we did? Good. Because it is.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CTA Section -->
<div class="py-16 bg-base-100">
<div class="container mx-auto px-4 text-center">
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
Get started today with tracking your Living Pokédex progress. It's free, open source, and
built with love for the Pokémon community.
</p>
<div class="flex flex-wrap gap-4 justify-center">
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Get Started Free</h2>
<h3 class="card-title text-xl mb-4">Sign Up Now</h3>
<SignUp {supabase} on:signedUp={handleSignedUp} />
</div>
</div>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="bg-base-200 py-16">
<div class="container mx-auto px-4">
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
<div class="stat">
<div class="stat-title">Pokémon caught</div>
<div class="stat-value text-primary">{pokemonCaught}</div>
</div>
<div class="stat">
<div class="stat-title">Users</div>
<div class="stat-value text-primary">{users}</div>
</div>
<div class="stat">
<div class="stat-title">Living Dexes Completed</div>
<div class="stat-value text-primary">{livingDexesCompleted}</div>
</div>
<!-- Legal Section -->
<div class="py-8 bg-base-200">
<div class="container mx-auto px-4 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-lg mb-2">Legal Disclaimer</h2>
<p class="text-sm text-base-content/70">
Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
characters, images, or other content featured on this website. This project is not
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak, or
The Pokémon Company.
</p>
</div>
</div>
</div>
<!-- What is a Living Dex Section -->
<div class="py-16 bg-base-100">
<div class="container mx-auto px-4 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-3xl mb-4">What is a Living Dex?</h2>
<p class="text-lg text-base-content/80">
A Living Dex is a complete Pokédex where you keep one of every Pokémon in your boxes
(often including forms/variants). Living Dex Tracker helps you build and maintain that
collection with filters, notes, and progress tracking.
</p>
</div>
</div>
</div>
</div>
<!-- Features Section -->
<div class="py-16 bg-base-200">
<div class="container mx-auto px-4 max-w-6xl">
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
<div class="grid md:grid-cols-2 gap-6">
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Social & Shareable</h2>
<p class="text-base-content/80">
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go
it alone, that's okay too!
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
<p class="text-base-content/80">
Completely open source and free to use, enabling the community to contribute
updates as soon as new Pokémon are released.
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">Advanced Filtering</h2>
<p class="text-base-content/80">
Track simple progress or tackle harder variants like a Living Origin Form Dex with
our powerful filtering options for targeted catching sessions.
</p>
</div>
</div>
</div>
</div>
<div class="card bg-neutral shadow-xl">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</div>
<div>
<h2 class="card-title text-xl mb-2">100% free</h2>
<p class="text-base-content/80">
Did we mention it's completely free to use? Oh, we did? Good. Because it is.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CTA Section -->
<div class="py-16 bg-base-100">
<div class="container mx-auto px-4 text-center">
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
Get started today with tracking your Living Pokédex progress. It's free, open source, and
built with love for the Pokémon community.
</p>
<div class="flex flex-wrap gap-4 justify-center">
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
<div class="card-body">
<h3 class="card-title text-xl mb-4">Sign Up Now</h3>
<SignUp {supabase} on:signedUp={handleSignedUp} />
</div>
</div>
</div>
</div>
</div>
<!-- Legal Section -->
<div class="py-8 bg-base-200">
<div class="container mx-auto px-4 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-lg mb-2">Legal Disclaimer</h2>
<p class="text-sm text-base-content/70">
Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
characters, images, or other content featured on this website. This project is not
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak,
or The Pokémon Company.
</p>
</div>
</div>
</div>
</div>
{/if}
</div>
@@ -7,11 +7,6 @@ export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
const integrations = await repo.listAll();
@@ -47,7 +47,9 @@ export const PUT = async (event: RequestEvent) => {
.eq('userId', userId)
.is('pokedexId', null)
.eq('provider', provider)
.select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError')
.select(
'id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError'
)
.maybeSingle();
if (error) {
@@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI
import { requireAuth } from '$lib/utils/auth';
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
export const GET = async (event: RequestEvent) => {
try {
@@ -56,7 +57,7 @@ export const GET = async (event: RequestEvent) => {
grant_type: 'authorization_code'
});
const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', {
const tokenResponse = await fetch(getProviderEndpoints().dropbox.token, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString()
@@ -97,7 +98,9 @@ export const GET = async (event: RequestEvent) => {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
accessTokenExpiresAt: expiresAt,
metadata: tokenData.scope ? { scope: tokenData.scope } : null
metadata: tokenData.scope ? { scope: tokenData.scope } : null,
// A reconnect replaces the tokens, so any error from the old ones no longer applies.
lastError: null
});
} catch (saveError) {
console.error('Dropbox integration save failed:', saveError);
@@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
export const GET = async (event: RequestEvent) => {
const userId = await requireAuth(event);
@@ -60,5 +61,5 @@ export const GET = async (event: RequestEvent) => {
scope: 'files.content.write'
});
throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`);
throw redirect(302, `${getProviderEndpoints().dropbox.authorize}?${params.toString()}`);
};
@@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI
import { requireAuth } from '$lib/utils/auth';
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
export const GET = async (event: RequestEvent) => {
try {
@@ -57,7 +58,7 @@ export const GET = async (event: RequestEvent) => {
grant_type: 'authorization_code'
});
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
const tokenResponse = await fetch(getProviderEndpoints().google.token, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString()
@@ -98,7 +99,9 @@ export const GET = async (event: RequestEvent) => {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
accessTokenExpiresAt: expiresAt,
metadata: tokenData.scope ? { scope: tokenData.scope } : null
metadata: tokenData.scope ? { scope: tokenData.scope } : null,
// A reconnect replaces the tokens, so any error from the old ones no longer applies.
lastError: null
});
} catch (saveError) {
console.error('Google Drive integration save failed:', saveError);
@@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
export const GET = async (event: RequestEvent) => {
const userId = await requireAuth(event);
@@ -65,5 +66,5 @@ export const GET = async (event: RequestEvent) => {
state
});
throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
throw redirect(302, `${getProviderEndpoints().google.authorize}?${params.toString()}`);
};
@@ -0,0 +1,42 @@
import { json, type RequestEvent } from '@sveltejs/kit';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import { OFFLINE_SNAPSHOT_VERSION, type OfflineSnapshot } from '$lib/models/OfflineSnapshot';
import { requireAuth } from '$lib/utils/auth';
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const pokedexes = await new PokedexRepository(event.locals.supabase, userId).findAll();
const snapshots = await Promise.all(
pokedexes.map(async (pokedex) => {
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
const entries = await new CombinedDataRepository(
event.locals.supabase,
userId,
pokedex._id
).findAllCombinedData(userId, pokedex.isFormDex, '', pokedex.gameScope ?? '', dexScopes);
return { pokedex: { ...pokedex, dexScopes }, entries };
})
);
const snapshot: OfflineSnapshot = {
version: OFFLINE_SNAPSHOT_VERSION,
generatedAt: new Date().toISOString(),
userId,
pokedexes: snapshots
};
return json(snapshot, {
headers: {
'Cache-Control': 'private, no-store',
Vary: 'Cookie'
}
});
} catch (error) {
console.error('Unable to build offline snapshot:', error);
if (error && typeof error === 'object' && 'status' in error) throw error;
return json({ error: 'Unable to build offline snapshot' }, { status: 500 });
}
};
@@ -31,9 +31,7 @@ export const GET = async (event: RequestEvent) => {
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
const catchData = await repo.findAll();
const sortedData = catchData.sort(
(a, b) => Number(a.pokemonId) - Number(b.pokemonId)
);
const sortedData = catchData.sort((a, b) => Number(a.pokemonId) - Number(b.pokemonId));
return json(sortedData);
} catch (err) {
console.error(err);
@@ -159,10 +157,7 @@ export const POST = async (event: RequestEvent) => {
try {
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
} catch (exportError) {
console.error(
'Failed to export pokedex after per-record catch updates:',
exportError
);
console.error('Failed to export pokedex after per-record catch updates:', exportError);
}
return json(insertedRecords);
}
@@ -1,9 +1,8 @@
import { json } from '@sveltejs/kit';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { getOptionalUserId } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
// GET: Get combined data (pokédex entries + catch records) for specific pokédex
export const GET = async (event: RequestEvent) => {
@@ -23,48 +22,29 @@ export const GET = async (event: RequestEvent) => {
const region = url.searchParams.get('region') || '';
const game = url.searchParams.get('game') || '';
// If authenticated, verify user owns this pokédex and get its gameScope
let pokedex;
if (userId) {
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
return json({ error: 'Pokedex not found' }, { status: 404 });
}
} else {
if (!userId) {
// Anonymous users cannot view pokédexes
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Use pokédex's gameScope as default filter if no manual game filter is set
const effectiveGame = game || pokedex.gameScope || '';
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
// Verify the user owns this pokédex and get its gameScope
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
const repo = new CombinedDataRepository(event.locals.supabase, userId, pokedexId);
if (!pokedex) {
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
return json({ error: 'Pokedex not found' }, { status: 404 });
}
// Get paginated combined data
const combinedData = await repo.findCombinedData(
userId!,
page,
limit,
enableForms,
region,
effectiveGame,
dexScopes
return json(
await loadCombinedDataPage(event.locals.supabase, userId, pokedex, {
page,
limit,
enableForms,
region,
game
})
);
// Get total count for pagination
const totalCount = await repo.countCombinedData(enableForms, region, effectiveGame, dexScopes);
const totalPages = Math.ceil(totalCount / limit);
return json({
combinedData,
totalPages,
currentPage: page,
totalCount
});
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
@@ -0,0 +1,18 @@
import { error, json } from '@sveltejs/kit';
import { requireAuth } from '$lib/utils/auth';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { loadPokedexEntryDetail } from '$lib/services/PokedexGridService';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async (event) => {
const userId = await requireAuth(event);
const entryId = Number(event.params.entryId);
if (!Number.isSafeInteger(entryId) || entryId < 1) throw error(400, 'Invalid entry');
const pokedex = await new PokedexRepository(event.locals.supabase, userId).findById(
event.params.id
);
if (!pokedex) throw error(404, 'Pokédex not found');
const detail = await loadPokedexEntryDetail(event.locals.supabase, userId, pokedex, entryId);
if (!detail) throw error(404, 'Entry not found');
return json(detail, { headers: { 'cache-control': 'private, no-store' } });
};
@@ -0,0 +1,29 @@
import { packGrid } from '$lib/models/PokedexGridRow';
import { error, json } from '@sveltejs/kit';
import { requireAuth } from '$lib/utils/auth';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { loadPokedexGrid } from '$lib/services/PokedexGridService';
import { PokedexPerformance } from '$lib/server/pokedexPerformance';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async (event) => {
const timings = new PokedexPerformance();
const userId = await timings.measure('auth', () => requireAuth(event));
const pokedex = await timings.measure('ownership', () =>
new PokedexRepository(event.locals.supabase, userId).findById(event.params.id)
);
if (!pokedex) throw error(404, 'Pokédex not found');
const grid = await loadPokedexGrid(event.locals.supabase, userId, pokedex, timings);
const packed = timings.prepare(() => packGrid(grid));
timings.recordAuth(event.locals.pokedexAuthMs);
const timing = timings.finish();
return json(
{ grid: packed },
{
headers: {
'cache-control': 'private, no-store',
...(timing ? { 'server-timing': timing } : {})
}
}
);
};
+4
View File
@@ -27,6 +27,10 @@ export const GET = async (event: RequestEvent) => {
}
const stats = data[0];
// The figures refresh at most daily, so let browsers and the CDN reuse them.
event.setHeaders({
'cache-control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400'
});
return json({
pokemonCaught: stats.pokemon_caught,
users: stats.total_users,
+23 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { setBackupStatus } from '$lib/stores/backupStatus';
type ExportIntegrationSummary = {
id: string;
@@ -40,6 +41,8 @@
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
// Keeps the sitewide banner in step, e.g. clearing it after the OAuth flow returns here.
setBackupStatus(exportIntegrations);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
exportError = message || 'Failed to load export settings';
@@ -48,6 +51,16 @@
}
}
function statusBadge(integration: ExportIntegrationSummary | undefined) {
if (!integration) return { label: 'Not Connected', className: 'badge-ghost' };
// Exports switch an integration off when the provider revokes its access.
if (!integration.enabled) return { label: 'Reconnect needed', className: 'badge-warning' };
return { label: 'Connected', className: 'badge-success' };
}
$: googleBadge = statusBadge(googleIntegration);
$: dropboxBadge = statusBadge(dropboxIntegration);
function getGoogleFolderUrl(folderId: string): string {
return `https://drive.google.com/drive/folders/${folderId}`;
}
@@ -106,13 +119,14 @@
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Google Drive</h2>
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
{googleIntegration ? 'Connected' : 'Not Connected'}
</span>
<span class={`badge ${googleBadge.className}`}>{googleBadge.label}</span>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" on:click={connectGoogleDrive}>
<button
class={`btn btn-sm ${googleIntegration?.enabled === false ? 'btn-primary' : 'btn-outline'}`}
on:click={connectGoogleDrive}
>
{googleIntegration ? 'Reconnect' : 'Connect'}
</button>
</div>
@@ -142,13 +156,14 @@
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Dropbox</h2>
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
{dropboxIntegration ? 'Connected' : 'Not Connected'}
</span>
<span class={`badge ${dropboxBadge.className}`}>{dropboxBadge.label}</span>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" on:click={connectDropbox}>
<button
class={`btn btn-sm ${dropboxIntegration?.enabled === false ? 'btn-primary' : 'btn-outline'}`}
on:click={connectDropbox}
>
{dropboxIntegration ? 'Reconnect' : 'Connect'}
</button>
</div>
+5 -5
View File
@@ -3,6 +3,7 @@
import type { Pokedex } from '$lib/models/Pokedex';
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
import { requestOfflineSync } from '$lib/stores/offlineSync';
export let data;
let { pokedexes } = data;
@@ -93,6 +94,7 @@
closeModal();
await loadPokedexes();
requestOfflineSync();
return;
} else {
// Create new pokédex
@@ -114,6 +116,7 @@
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
closeModal();
await loadPokedexes();
requestOfflineSync();
// If the user's total pokédex count is now 1, this newly created one is their first.
if (pokedexes.length === 1) {
@@ -149,6 +152,7 @@
}
await loadPokedexes();
requestOfflineSync();
} catch (error) {
console.error('Error deleting pokédex:', error);
alert('An error occurred');
@@ -204,11 +208,7 @@
</h3>
<PokedexForm bind:pokedex={formData} {mode} onSubmit={handleSubmit} onCancel={closeModal} />
</div>
<button
type="button"
class="modal-backdrop"
aria-label="Close modal"
on:click={closeModal}
<button type="button" class="modal-backdrop" aria-label="Close modal" on:click={closeModal}
></button>
</div>
{/if}
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { user } from '$lib/stores/user.js';
import {
artworkDownloadStatus,
downloadAllArtwork,
offlineSyncStatus,
requestOfflineSync
} from '$lib/stores/offlineSync';
function formatMegabytes(bytes: number) {
const megabytes = bytes / 1048576;
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
}
</script>
<svelte:head>
<title>Using Offline - Living Dex Tracker</title>
<meta
name="description"
content="How Living Dex Tracker keeps your Pokédexes and artwork available offline, and how to save everything before you lose signal."
/>
</svelte:head>
<div class="container mx-auto p-4 max-w-screen-lg">
<h1 class="text-3xl font-bold mb-6">Using Living Dex Tracker offline</h1>
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Your offline copy</h2>
{#if !$user}
<p>
<a href="/signin" class="link link-primary">Sign in</a> to keep a copy of your pokédexes on
this device.
</p>
{:else}
<div data-testid="offline-copy-status" role="status">
{#if $offlineSyncStatus.state === 'syncing'}
<p>Updating your offline copy…</p>
{:else if $offlineSyncStatus.state === 'error'}
<div class="alert alert-warning">
<span>Your offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
</div>
{:else if $offlineSyncStatus.generatedAt}
<p>
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
</p>
{:else}
<p>
No offline copy is saved on this device yet. It saves automatically while you are
online.
</p>
{/if}
</div>
<h3 class="font-semibold mt-4">Artwork</h3>
{#if $artworkDownloadStatus.state === 'downloading'}
<p role="status">Saving all artwork for offline…</p>
{:else if $artworkDownloadStatus.state === 'error'}
<div class="alert alert-warning" role="status">
<span>Some artwork could not be saved: {$artworkDownloadStatus.message}.</span>
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
</div>
{:else if $artworkDownloadStatus.state === 'done'}
<p>All artwork is saved on this device.</p>
{:else}
<p>
Artwork is saved as you view it. To browse every Pokémon offline, including every form,
shiny and female variant, save it all now.
</p>
{#if $artworkDownloadStatus.state === 'missing'}
<div>
<button class="btn btn-primary btn-sm" on:click={downloadAllArtwork}>
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
$artworkDownloadStatus.missingBytes
)}){/if}
</button>
</div>
{/if}
{/if}
{/if}
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">How it works</h2>
<ul class="list-disc pl-5 space-y-2">
<li>
<strong>Install the app.</strong> Use your browser's "Install app" or "Add to Home Screen"
option so Living Dex Tracker opens without a connection.
</li>
<li>
<strong>Stay signed in.</strong> Your offline copy updates automatically whenever you are online,
including after you make changes.
</li>
<li>
<strong>Offline is read-only.</strong> You can browse your pokédexes, but catches and edits
are disabled until you are back online.
</li>
<li>
<strong>Artwork.</strong> Sprites are saved as you view them. Use "Save all artwork for offline"
above to download the rest in one go.
</li>
<li>
<strong>Signing out</strong> removes the offline copy from this device.
</li>
</ul>
</div>
</div>
</div>
+27 -22
View File
@@ -1,28 +1,33 @@
import { packGrid } from '$lib/models/PokedexGridRow';
import { error, redirect } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { loadPokedexGrid } from '$lib/services/PokedexGridService';
import { PokedexPerformance } from '$lib/server/pokedexPerformance';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
const { safeGetSession, supabase } = locals;
const { session, user } = await safeGetSession();
// Require authentication
if (!session || !user) {
throw redirect(303, '/signin');
export const load: PageServerLoad = async ({ locals, params, setHeaders, cookies }) => {
const timings = new PokedexPerformance();
const { session, user } = await timings.measure('auth', () => locals.safeGetSession());
if (!session || !user) throw redirect(303, '/signin');
const pokedex = await timings.measure('ownership', () =>
new PokedexRepository(locals.supabase, user.id).findById(params.id)
);
if (!pokedex) throw error(404, 'Pokédex not found');
let grid = null;
try {
grid = await loadPokedexGrid(locals.supabase, user.id, pokedex, timings);
} catch {
console.error('Unable to load Pokédex grid');
}
const { id } = params;
// Fetch pokédex to verify ownership (RLS will also block, but we want a proper 404)
const repo = new PokedexRepository(supabase, user.id);
const pokedex = await repo.findById(id);
if (!pokedex) {
// Either doesn't exist or user doesn't own it
throw error(404, 'Pokédex not found');
}
return {
pokedex
};
const packed = timings.prepare(() => (grid ? packGrid(grid) : null));
timings.recordAuth(locals.pokedexAuthMs);
const timing = timings.finish();
setHeaders({
'cache-control': 'private, no-store',
...(timing ? { 'server-timing': timing } : {})
});
const layout = cookies.get('boxViewLayout');
const boxViewLayout: 'comfortable' | 'compact' | 'ultra' =
layout === 'compact' || layout === 'ultra' ? layout : 'comfortable';
return { pokedex, grid: packed, boxViewLayout };
};
+421 -123
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js';
import { type CombinedData } from '$lib/models/CombinedData';
@@ -16,7 +16,21 @@
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
import type { Pokedex } from '$lib/models/Pokedex';
import type { PageData } from './$types';
import { readOfflineEntry, 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 {
unpackGrid,
type PokedexGridRow,
type CatchRecordPatch
} from '$lib/models/PokedexGridRow';
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
export let data: PageData;
@@ -33,19 +47,25 @@
}
}
let combinedData = null as CombinedData[] | null;
let currentPage = 1 as number;
// Box view requires the full dataset for correct box numbering/placement.
// If/when a paginated list view is introduced, this can be lowered and paired with UI controls.
let itemsPerPage = 9999 as number;
let totalPages = 0 as number;
let combinedData: PokedexGridRow[] | null = null;
type CatchUpdateEvent = CustomEvent<{
catchRecord: CatchRecord;
source: 'toggle' | 'notes' | 'notes-blur';
changes?: Partial<CatchRecord>;
}>;
let creatingRecords = false;
let totalRecordsCreated = 0;
let failedToLoad = false;
let localUser: User | null;
let localUser: User | null = data.user ?? null;
let userStoreReady = false;
let boxNumbers: number[] = [];
let showModal = false;
let selectedPokemon: CombinedData | null = null;
let showShareModal = false;
let shareUrl = '';
let shareFeedback = '';
let nativeShareSupported = false;
let online = true;
let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null;
let catchWriteQueueKey: string | null = null;
@@ -57,6 +77,9 @@
lastFlushAttemptAt: null,
lastSuccessfulFlushAt: null
};
let lastOfflineSyncFlush: number | null = null;
// Backup providers that just refused this page's export because their access was revoked.
let reconnectToastLabels: string[] = [];
let exportAfterFlush = false;
let exportInFlight = false;
let exportTimer: ReturnType<typeof setTimeout> | null = null;
@@ -101,6 +124,24 @@
console.error('Auto-export failed:', response.status, body);
return;
}
const result = (await response.json().catch(() => null)) as {
failed?: Array<{ provider: ExportProvider; reconnectRequired?: boolean }>;
} | null;
const revoked = (result?.failed ?? []).filter((failure) => failure.reconnectRequired);
const alreadyPaused = new Set(get(backupsNeedingReconnect));
if (revoked.length > 0) {
markReconnectNeeded(revoked.map((failure) => failure.provider));
} else {
// Saving a catch record also exports on the server, and that export may already have
// paused a provider, leaving this export nothing to report. Re-read the status to catch it.
await refreshBackupStatus();
}
const newlyPaused = get(backupsNeedingReconnect).filter(
(provider) => !alreadyPaused.has(provider)
);
if (newlyPaused.length > 0) {
reconnectToastLabels = newlyPaused.map((provider) => PROVIDER_LABELS[provider]);
}
if (exportGeneration === exportInFlightGeneration) {
exportAfterFlush = false;
}
@@ -128,15 +169,18 @@
}, 250);
}
// Derive from pokedex config
$: showOrigins = !!pokedex?.isOriginDex;
$: showShiny = !!pokedex?.isShinyDex;
const unsubscribe = user.subscribe((value) => {
localUser = value;
if (userStoreReady) localUser = value;
});
onDestroy(unsubscribe);
onDestroy(() => {
detailRequest++;
detailAbort?.abort();
});
onDestroy(() => {
catchWriteQueueUnsubscribe?.();
catchWriteQueueUnsubscribe = null;
@@ -145,21 +189,131 @@
resetExportState();
});
function openPokemonModal(pokemon: CombinedData) {
selectedPokemon = pokemon;
let selectedSummary: PokedexGridRow | null = null;
let detailError = '';
let detailRequest = 0;
let detailAbort: AbortController | null = null;
let returnFocus: HTMLElement | null = null;
const detailCache = new Map<string, CombinedData>();
let detailOwner = '';
$: if (localUser?.id !== detailOwner) {
detailOwner = localUser?.id ?? '';
detailCache.clear();
closePokemonModal();
}
async function openPokemonModal(pokemon: PokedexGridRow) {
if (!showModal) returnFocus = document.activeElement as HTMLElement;
selectedSummary = pokemon;
selectedPokemon = null;
detailError = '';
showModal = true;
const request = ++detailRequest;
detailAbort?.abort();
detailAbort = new AbortController();
const id = pokedexId;
const owner = localUser?.id || '';
const entryId = pokemon.pokedexEntry._id;
const key = `${owner}:${id}:${entryId}`;
try {
let detail = detailCache.get(key);
if (!detail) {
if (!navigator.onLine) detail = (await readOfflineEntry(owner, id, entryId)) ?? undefined;
else {
const response = await fetch(`/api/pokedexes/${id}/entries/${entryId}`, {
signal: detailAbort.signal
});
if (!response.ok) throw new Error('Unable to load details. Please retry.');
detail = await response.json();
}
}
if (request !== detailRequest || id !== pokedexId || owner !== localUser?.id) return;
if (!detail) throw new Error('These details are not saved for offline use.');
detailCache.set(key, detail);
// A detail response must not undo status changes made while it was in flight.
const current = combinedData?.find((row) => row.pokedexEntry._id === entryId)?.catchRecord;
const pending = catchWriteQueue?.getPendingPatch(entryId);
selectedPokemon = {
...detail,
catchRecord:
detail.catchRecord || current || pending
? {
_id: '',
userId: owner,
pokedexId: id,
pokemonId: entryId,
caught: false,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false,
personalNotes: '',
...detail.catchRecord,
...current,
...pending
}
: null
};
} catch (error) {
if (request !== detailRequest || id !== pokedexId || owner !== localUser?.id) return;
detailError = error instanceof Error ? error.message : 'Unable to load details.';
}
}
function openShareModal() {
if (!pokedex?.shareToken || !browser) return;
shareUrl = `${window.location.origin}/shared/${pokedex.shareToken}`;
shareFeedback = '';
showShareModal = true;
}
function closeShareModal() {
showShareModal = false;
shareFeedback = '';
}
async function copyShareLink() {
try {
await navigator.clipboard.writeText(shareUrl);
shareFeedback = 'Link copied';
} catch {
shareFeedback = 'Copy failed — select the link above to copy it manually.';
}
}
async function sharePokedex() {
if (!nativeShareSupported || !pokedex) return;
try {
await navigator.share({
title: pokedex.name,
text: `See my ${pokedex.name} progress on Living Dex Tracker.`,
url: shareUrl
});
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
shareFeedback = 'Sharing failed. You can copy the link instead.';
}
}
}
function closePokemonModal() {
detailRequest++;
detailAbort?.abort();
showModal = false;
selectedPokemon = null;
selectedSummary = null;
if (browser && returnFocus) {
const target = returnFocus;
void tick().then(() => target.isConnected && target.focus());
}
returnFocus = null;
}
function ensureCatchWriteQueue() {
if (!browser) return;
if (!pokedexId) return;
if (!localUser?.id) return;
const desiredKey = `${localUser.id}:${pokedexId}`;
const ownerId = localUser.id;
const desiredKey = `${ownerId}:${pokedexId}`;
if (catchWriteQueue && catchWriteQueueKey === desiredKey) return;
resetExportState();
@@ -172,12 +326,22 @@
endpointUrl: `/api/pokedexes/${pokedexId}/catch-records`,
fetchFn: fetch,
batchSize: 200,
concurrency: 1
concurrency: 1,
isCurrentUser: () => get(user)?.id === ownerId
});
catchWriteQueueKey = desiredKey;
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
catchWriteStatus = s;
if (
s.lastSuccessfulFlushAt &&
s.lastSuccessfulFlushAt !== lastOfflineSyncFlush &&
s.pending === 0 &&
s.inFlight === 0
) {
lastOfflineSyncFlush = s.lastSuccessfulFlushAt;
requestOfflineSync();
}
if (s.pending > 0 || s.inFlight > 0) {
if (exportTimer) {
clearTimeout(exportTimer);
@@ -190,73 +354,93 @@
});
}
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
let editGeneration = 0;
function applyOptimisticCatchRecordUpdate(next: CatchRecordPatch) {
if (!combinedData) return;
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
if (idx === -1) return;
// Replace the catchRecord entry with the updated version.
const current = combinedData[idx];
const patched: CombinedData = {
...current,
catchRecord: {
...(current.catchRecord ?? next),
...next
}
};
combinedData = [...combinedData.slice(0, idx), patched, ...combinedData.slice(idx + 1)];
if (selectedPokemon?.pokedexEntry._id === next.pokemonId) {
selectedPokemon = patched;
}
editGeneration++;
combinedData = combinedData.map((row) =>
row.pokedexEntry._id === next.pokemonId
? {
...row,
catchRecord: {
_id: '',
caught: false,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false,
...row.catchRecord,
...next
}
}
: row
);
const key = `${next.userId}:${next.pokedexId}:${next.pokemonId}`;
const cached = detailCache.get(key);
if (cached)
detailCache.set(key, {
...cached,
catchRecord: {
_id: '',
caught: false,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false,
personalNotes: '',
...cached.catchRecord,
...next
}
});
if (selectedPokemon?.pokedexEntry._id === next.pokemonId)
selectedPokemon = {
...selectedPokemon,
catchRecord: {
_id: '',
caught: false,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false,
personalNotes: '',
...selectedPokemon.catchRecord,
...next
}
};
}
async function handleModalCatchUpdate(event: any) {
async function handleModalCatchUpdate(event: CatchUpdateEvent) {
await updateACatch(event);
}
type GetDataOptions = {
page?: number;
perPage?: number;
setCombinedDataToNull?: boolean;
};
async function getData({
page = currentPage,
perPage = itemsPerPage,
setCombinedDataToNull = true
}: GetDataOptions = {}) {
if (!pokedex || !pokedexId) return;
if (setCombinedDataToNull) {
combinedData = null;
}
const effectivePage = Math.max(1, page);
const effectivePerPage = Math.max(1, perPage);
// Use new pokédex-scoped endpoint
const endpoint = `/api/pokedexes/${pokedexId}/combined-data?page=${effectivePage}&limit=${effectivePerPage}&enableForms=${pokedex.isFormDex}`;
const response = await fetch(endpoint);
const fetchedData = await response.json();
if (fetchedData.error) {
failedToLoad = true;
return;
}
combinedData = fetchedData.combinedData;
totalPages = fetchedData.totalPages || 0;
// Always extract box numbers for box view
if (combinedData) {
boxNumbers = calculateBoxNumbers(combinedData.length);
let gridRequest = 0;
async function getData({ setCombinedDataToNull = true } = {}) {
const id = pokedexId;
const owner = localUser?.id;
const request = ++gridRequest;
const generation = editGeneration;
if (setCombinedDataToNull) combinedData = null;
failedToLoad = false;
try {
const response = await fetch(`/api/pokedexes/${id}/grid`);
if (!response.ok) throw new Error('Unable to load grid');
const result = await response.json();
if (
request !== gridRequest ||
id !== pokedexId ||
owner !== localUser?.id ||
generation !== editGeneration
)
return;
combinedData = unpackGrid(result.grid);
detailCache.clear();
} catch {
if (request === gridRequest && id === pokedexId && owner === localUser?.id)
failedToLoad = true;
}
}
async function updateACatch(event: any) {
async function updateACatch(event: CatchUpdateEvent) {
if (!pokedexId) return;
ensureCatchWriteQueue();
const { catchRecord, source } = event.detail as {
catchRecord: CatchRecord;
source: 'toggle' | 'notes' | 'notes-blur';
};
const { catchRecord, source, changes } = event.detail;
// Enforce mutual exclusivity (should be impossible to have both true).
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
if (sanitizedCatchRecord.caught) {
@@ -271,11 +455,25 @@
}
// Optimistic UI: update local state immediately.
applyOptimisticCatchRecordUpdate(sanitizedCatchRecord);
const patch: CatchRecordPatch = {
userId: localUser.id,
pokedexId,
pokemonId: sanitizedCatchRecord.pokemonId,
...(changes ??
(source === 'toggle'
? {
caught: sanitizedCatchRecord.caught,
haveToEvolve: sanitizedCatchRecord.haveToEvolve,
inHome: sanitizedCatchRecord.inHome,
hasGigantamaxed: sanitizedCatchRecord.hasGigantamaxed
}
: { personalNotes: sanitizedCatchRecord.personalNotes }))
};
applyOptimisticCatchRecordUpdate(patch);
// Queue a background write with coalescing.
const debounceMs = source === 'notes' ? 650 : 0;
catchWriteQueue?.enqueue(sanitizedCatchRecord, {
catchWriteQueue?.enqueue(patch, {
debounceMs,
flushSoon: true
});
@@ -300,37 +498,14 @@
if (!pokedexId) return;
ensureCatchWriteQueue();
const catchRecordsToUpdate: CatchRecord[] = combinedData
const catchRecordsToUpdate: CatchRecordPatch[] = combinedData
.filter((_, index) => calculateBoxPlacement(index).box === boxNumber)
.map(({ pokedexEntry, catchRecord }) => {
// Create default record if null
const baseRecord: CatchRecord = catchRecord ?? {
_id: '',
userId: localUser?.id || '',
pokemonId: pokedexEntry._id,
pokedexId: pokedexId,
haveToEvolve: false,
caught: false,
inHome: false,
hasGigantamaxed: false,
personalNotes: ''
};
let updatedRecord: CatchRecord = { ...baseRecord };
if (inHome !== null) {
updatedRecord = {
...updatedRecord,
inHome
};
} else {
updatedRecord = {
...updatedRecord,
caught,
haveToEvolve: needsToEvolve
};
}
return updatedRecord;
});
.map(({ pokedexEntry }) => ({
userId: localUser?.id || '',
pokedexId,
pokemonId: pokedexEntry._id,
...(inHome !== null ? { inHome } : { caught, haveToEvolve: needsToEvolve })
}));
// Optimistic patch: apply locally first.
for (const record of catchRecordsToUpdate) {
@@ -423,15 +598,26 @@
creatingRecords = false;
failedToLoad = false;
await getData({ page: currentPage, perPage: itemsPerPage });
await getData();
});
}
// Fetch data whenever pagination controls change (client-side only)
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
let shownData: PageData | undefined;
$: if (data !== shownData) {
shownData = data;
localUser = data.user ?? null;
gridRequest++;
closePokemonModal();
detailCache.clear();
combinedData = data.grid ? unpackGrid(data.grid) : null;
failedToLoad = data.grid === null;
}
$: boxNumbers = calculateBoxNumbers(combinedData?.length ?? 0);
onMount(() => {
if (!browser) return;
userStoreReady = true;
nativeShareSupported = typeof navigator.share === 'function';
const flushKeepalive = () => {
if (!catchWriteQueue) return;
@@ -443,7 +629,15 @@
if (document.visibilityState === 'hidden') flushKeepalive();
};
const onOnline = () => void catchWriteQueue?.flushNow();
online = navigator.onLine;
const onOffline = () => {
online = false;
};
const onOnline = () => {
online = true;
void catchWriteQueue?.flushNow();
};
window.addEventListener('offline', onOffline);
window.addEventListener('pagehide', flushKeepalive);
document.addEventListener('visibilitychange', onVisibilityChange);
@@ -454,19 +648,37 @@
if (!pokedexId) return;
if (creatingRecords) return;
if (catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0) return;
void getData({ page: currentPage, perPage: itemsPerPage, setCombinedDataToNull: false });
void getData({ setCombinedDataToNull: false });
}, 60_000);
return () => {
window.removeEventListener('pagehide', flushKeepalive);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
window.clearInterval(reconcileInterval);
};
});
</script>
{#if reconnectToastLabels.length > 0}
<!-- Above DaisyUI's modal (z-index 999) so the alert stays usable over an open Pokémon dialog. -->
<div class="toast toast-end z-[1000]">
<div class="alert alert-warning" role="alert" data-testid="backup-reconnect-toast">
<span>
Backups to {reconnectToastLabels.join(' and ')} have stopped because access expired or was revoked.
</span>
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
<button
type="button"
class="btn btn-sm btn-ghost"
aria-label="Dismiss"
on:click={() => (reconnectToastLabels = [])}>✕</button
>
</div>
</div>
{/if}
<svelte:head>
<title>{pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'}</title>
</svelte:head>
@@ -599,7 +811,6 @@
{#if pokedex.description}
<p class="text-sm text-base-content/70 mt-3">{pokedex.description}</p>
{/if}
</div>
<!-- Right side: Actions -->
@@ -613,6 +824,20 @@
Save failed (will retry)
</div>
{/if}
<button type="button" class="btn btn-primary btn-sm" on:click={openShareModal}>
<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="M15 8a3 3 0 1 0-2.83-4L7.91 6.13a3 3 0 0 0 0 1.74L12.17 10A3 3 0 1 0 13 8.59L8.83 6.5 13 4.41A3 3 0 0 0 15 8Zm0 10a3 3 0 1 0-2.83-4L7.91 11.87a3 3 0 1 0 0 1.74L12.17 15.7A3 3 0 0 0 15 18Z"
/>
</svg>
Share
</button>
<a href="/my-pokedexes" class="btn btn-outline btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -634,9 +859,10 @@
<!-- Box View -->
<PokedexViewBoxes
{showShiny}
bind:combinedData
{combinedData}
bind:boxNumbers
bind:creatingRecords
{totalRecordsCreated}
bind:failedToLoad
{markBoxAsNotCaught}
{markBoxAsCaught}
@@ -644,22 +870,94 @@
{markBoxAsInHome}
{markBoxAsNotInHome}
{createCatchRecords}
onPokemonClick={openPokemonModal}
retryLoad={() => getData()}
virtualize={true}
gridKey={pokedexId}
initialLayout={data.boxViewLayout}
onPokemonClick={(row) => {
const own = combinedData?.find((entry) => entry.pokedexEntry._id === row.pokedexEntry._id);
if (own) void openPokemonModal(own);
}}
/>
</div>
{#if showModal && selectedPokemon}
{#if showModal && selectedSummary}
<PokedexModal isOpen={showModal} onClose={closePokemonModal}>
<PokedexEntryCatchRecord
pokedexEntry={selectedPokemon.pokedexEntry}
bind:catchRecord={selectedPokemon.catchRecord}
{showOrigins}
showForms={pokedex.isFormDex}
{showShiny}
userId={localUser?.id}
{pokedexId}
on:updateCatch={handleModalCatchUpdate}
/>
{#if selectedPokemon}
<PokedexEntryCatchRecord
pokedexEntry={selectedPokemon.pokedexEntry}
bind:catchRecord={selectedPokemon.catchRecord}
{showOrigins}
showForms={pokedex.isFormDex}
{showShiny}
userId={localUser?.id}
{pokedexId}
on:updateCatch={handleModalCatchUpdate}
readOnly={!online}
sharedCatchStatus={selectedPokemon.catchRecord}
/>
{#if !online && selectedPokemon.catchRecord?.personalNotes}<p class="p-6">
Notes: {selectedPokemon.catchRecord.personalNotes}
</p>{/if}
{:else}
<div class="p-6" aria-busy={!detailError}>
<h2 class="text-xl font-bold">{selectedSummary.pokedexEntry.pokemon}</h2>
<div class="w-64 h-64">
<PokemonSprite
pokemonName={selectedSummary.pokedexEntry.pokemon}
pokedexNumber={selectedSummary.pokedexEntry.pokedexNumber}
form={selectedSummary.pokedexEntry.form}
spriteKey={selectedSummary.pokedexEntry.spriteKey}
shiny={showShiny}
loadingStrategy="eager"
/>
</div>
{#if detailError}
<p role="alert">{detailError}</p>
<button
class="btn"
data-offline-action
on:click={() => selectedSummary && openPokemonModal(selectedSummary)}
>Retry details</button
>
{:else}<p role="status">Loading details…</p>{/if}
</div>
{/if}
</PokedexModal>
{/if}
{#if showShareModal}
<div class="modal modal-open" role="dialog" aria-modal="true" aria-labelledby="share-title">
<div class="modal-box">
<h2 id="share-title" class="font-bold text-xl">Share {pokedex.name}</h2>
<p class="py-3 text-sm text-base-content/70">
Anyone with this link can view live progress. Personal notes are never shared.
</p>
<label class="label" for="share-url"><span class="label-text">Read-only link</span></label>
<input
id="share-url"
class="input input-bordered w-full"
value={shareUrl}
readonly
on:focus={(event) => event.currentTarget.select()}
/>
{#if shareFeedback}
<p class="text-sm mt-2" role="status">{shareFeedback}</p>
{/if}
<div class="modal-action">
<button type="button" class="btn btn-ghost" on:click={closeShareModal}>Close</button>
<button type="button" class="btn btn-outline" on:click={copyShareLink}>Copy link</button>
{#if nativeShareSupported}
<button type="button" class="btn btn-primary" on:click={sharePokedex}>Share…</button>
{/if}
</div>
</div>
<button
class="modal-backdrop"
type="button"
aria-label="Close share dialog"
on:click={closeShareModal}
></button>
</div>
{/if}
{/if}

Some files were not shown because too many files have changed in this diff Show More