mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-18 19:42:04 +00:00
Merge pull request #100 from jcreek/fix/pokedex-detail-card-latency
fix(pokedex): open detail cards without a per-card round trip
This commit is contained in:
@@ -7,7 +7,8 @@ Status: application changes implemented; hosting comparison remains gated. Measu
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- `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.
|
- Details for the whole dex are read once per account/dex in the background, at the same interactive/idle boundary as the other deferred startup work, and fill the modal's cache. Opening a card therefore renders from memory with no request of its own: a per-card round trip cost seconds on the deployed cross-region setup, which local timings did not show. That read reuses `/api/pokedexes/[id]/combined-data`, which now skips its count query when a caller asks for every row at once.
|
||||||
|
- The per-entry detail endpoint remains as the fallback for a card opened before the background read lands, and for a failed read. It verifies ownership and membership before returning one full `CombinedData` row, checking membership for the single entry rather than materialising the dex. The modal opens immediately with identity and full artwork, then fills in editable details. Its account/dex/entry cache, abort/sequence checks and pending-patch merge protect rapid selection changes and optimistic edits; catch status always comes from the live grid row, so a cached detail can never show stale progress.
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -26,7 +27,7 @@ Fixtures: national (1,025 entries), Scarlet/Paldea forms (439 entries), mixed ca
|
|||||||
|
|
||||||
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 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.
|
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; the dex is read for details exactly once, and opening a card afterwards must make no request at all. The offline-isolation checks block that background read so the snapshot path, not the primed cache, answers those clicks.
|
||||||
|
|
||||||
Warm results from the corrected harness (30 samples per row; [sanitized summary](pokedex-local-results.json)):
|
Warm results from the corrected harness (30 samples per row; [sanitized summary](pokedex-local-results.json)):
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ try {
|
|||||||
`Scroll anchor moved: ${anchorTop} to ${resizedTop}`
|
`Scroll anchor moved: ${anchorTop} to ${resizedTop}`
|
||||||
);
|
);
|
||||||
await page.setViewportSize({ width: 1350, height: 940 });
|
await page.setViewportSize({ width: 1350, height: 940 });
|
||||||
|
// Let the resize settle first: restoring the box anchor scrolls the page, and a late restore
|
||||||
|
// would undo the scroll below.
|
||||||
|
await page.waitForTimeout(200);
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
await page.waitForSelector('[data-entry-index="1024"]');
|
await page.waitForSelector('[data-entry-index="1024"]');
|
||||||
assert.ok((await page.locator('[data-entry-index]').count()) < 300);
|
assert.ok((await page.locator('[data-entry-index]').count()) < 300);
|
||||||
@@ -117,6 +120,9 @@ try {
|
|||||||
await page.waitForSelector('[data-grid-interactive]');
|
await page.waitForSelector('[data-grid-interactive]');
|
||||||
assert.ok((await page.locator('body').innerText()).includes('Showing 439 of 439'));
|
assert.ok((await page.locator('body').innerText()).includes('Showing 439 of 439'));
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
|
// Block the background detail read so the cache stays empty: these checks are about the offline
|
||||||
|
// snapshot answering a click, not about details the browser already holds.
|
||||||
|
await page.route(`**/api/pokedexes/${national.id}/combined-data*`, (route) => route.abort());
|
||||||
await page
|
await page
|
||||||
.locator('.card')
|
.locator('.card')
|
||||||
.filter({ hasText: national.name })
|
.filter({ hasText: national.name })
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ const errors = [];
|
|||||||
page.on('pageerror', (error) => errors.push(error.message));
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
const requests = [];
|
const requests = [];
|
||||||
page.on('request', (request) => requests.push(new URL(request.url()).pathname));
|
page.on('request', (request) => requests.push(new URL(request.url()).pathname));
|
||||||
|
const primedDetails = () =>
|
||||||
|
requests.filter((path) => /\/api\/pokedexes\/[^/]+\/combined-data$/.test(path)).length;
|
||||||
|
// Details are read once per dex at the idle boundary, so allow for a slow idle callback.
|
||||||
|
async function waitForDetailPrime() {
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
while (primedDetails() === 0 && Date.now() < deadline) await page.waitForTimeout(100);
|
||||||
|
assert.equal(primedDetails(), 1, 'Details must be read exactly once for the dex');
|
||||||
|
}
|
||||||
const results = [];
|
const results = [];
|
||||||
try {
|
try {
|
||||||
for (const dex of fixture.dexes) {
|
for (const dex of fixture.dexes) {
|
||||||
@@ -61,18 +69,25 @@ try {
|
|||||||
assert.ok(stats.cells <= 180, `Mounted cells: ${stats.cells}`);
|
assert.ok(stats.cells <= 180, `Mounted cells: ${stats.cells}`);
|
||||||
assert.ok(stats.dom < 2500, `DOM elements: ${stats.dom}`);
|
assert.ok(stats.dom < 2500, `DOM elements: ${stats.dom}`);
|
||||||
assert.ok(stats.cls <= 0.1, `CLS: ${stats.cls}`);
|
assert.ok(stats.cls <= 0.1, `CLS: ${stats.cls}`);
|
||||||
assert.equal(
|
// The grid ships with the page, so re-fetching it would be redundant. The details read is
|
||||||
requests.filter((path) => /\/api\/pokedexes\/[^/]+\/(grid|combined-data)$/.test(path)).length,
|
// deliberate: it is what lets a card open without a round trip of its own.
|
||||||
0
|
assert.equal(requests.filter((path) => /\/api\/pokedexes\/[^/]+\/grid$/.test(path)).length, 0);
|
||||||
);
|
await waitForDetailPrime();
|
||||||
assert.equal(await page.locator('button button').count(), 0);
|
assert.equal(await page.locator('button button').count(), 0);
|
||||||
await page.screenshot({ path: `${directory}/${dex.gameScope ? 'scoped' : 'national'}.png` });
|
await page.screenshot({ path: `${directory}/${dex.gameScope ? 'scoped' : 'national'}.png` });
|
||||||
const first = page.locator('[data-entry-index="0"]');
|
const first = page.locator('[data-entry-index="0"]');
|
||||||
const entryId = await first.getAttribute('data-entry-id');
|
const entryId = await first.getAttribute('data-entry-id');
|
||||||
const notes = await readJson(`/api/pokedexes/${dex.id}/entries/${entryId}`);
|
const notes = await readJson(`/api/pokedexes/${dex.id}/entries/${entryId}`);
|
||||||
|
const openedWith = requests.filter((path) => path.endsWith(`/entries/${entryId}`)).length;
|
||||||
await first.click();
|
await first.click();
|
||||||
const modal = page.getByRole('dialog', { name: 'Pokémon details' });
|
const modal = page.getByRole('dialog', { name: 'Pokémon details' });
|
||||||
await modal.getByLabel('Notes:', { exact: true }).waitFor();
|
await modal.getByLabel('Notes:', { exact: true }).waitFor();
|
||||||
|
// Primed details mean opening a card costs no request of its own.
|
||||||
|
assert.equal(
|
||||||
|
requests.filter((path) => path.endsWith(`/entries/${entryId}`)).length,
|
||||||
|
openedWith,
|
||||||
|
'Opening a card must not fetch its details separately'
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await modal.getByLabel('Notes:', { exact: true }).inputValue(),
|
await modal.getByLabel('Notes:', { exact: true }).inputValue(),
|
||||||
notes.catchRecord.personalNotes
|
notes.catchRecord.personalNotes
|
||||||
|
|||||||
@@ -397,6 +397,51 @@ class CombinedDataRepository {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorises one entry without materialising the dex. Mirrors `findGridEntries`, which reads the
|
||||||
|
* scoped dex tables and — for game-scoped form dexes — supplements named forms from
|
||||||
|
* `pokedex_entries`. Region is not a grid filter, so it is not applied here either.
|
||||||
|
*/
|
||||||
|
async isEntryInDex(
|
||||||
|
entryId: number,
|
||||||
|
enableForms: boolean,
|
||||||
|
game: string,
|
||||||
|
dexScopes: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (dexScopes.length) {
|
||||||
|
let scoped = this.supabase
|
||||||
|
.from('game_pokedex_entry_details')
|
||||||
|
.select('id')
|
||||||
|
.in('dexId', dexScopes)
|
||||||
|
.eq('id', entryId);
|
||||||
|
if (!enableForms) scoped = scoped.eq('isDefaultForm', true);
|
||||||
|
|
||||||
|
const { data, error } = await scoped.limit(1);
|
||||||
|
if (error) throw new Error('Unable to load dex entries');
|
||||||
|
if (data && data.length > 0) return true;
|
||||||
|
if (!enableForms || !game) return false;
|
||||||
|
|
||||||
|
// Named forms are absent from the game dex tables and are supplemented from pokedex_entries.
|
||||||
|
const { data: forms, error: formsError } = await this.supabase
|
||||||
|
.from('pokedex_entries')
|
||||||
|
.select('id')
|
||||||
|
.eq('id', entryId)
|
||||||
|
.not('form', 'is', null)
|
||||||
|
.contains('gamesToCatchIn', [game])
|
||||||
|
.limit(1);
|
||||||
|
if (formsError) throw new Error('Unable to load form entries');
|
||||||
|
return !!forms && forms.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = this.supabase.from('pokedex_entries').select('id').eq('id', entryId);
|
||||||
|
if (!enableForms) query = query.eq('isDefaultForm', true);
|
||||||
|
if (game) query = query.contains('gamesToCatchIn', [game]);
|
||||||
|
|
||||||
|
const { data, error } = await query.limit(1);
|
||||||
|
if (error) throw new Error('Unable to load dex entries');
|
||||||
|
return !!data && data.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
async joinGridCatches(entries: PokedexEntryDB[]): Promise<PokedexGridRow[]> {
|
async joinGridCatches(entries: PokedexEntryDB[]): Promise<PokedexGridRow[]> {
|
||||||
const catches = new Map(
|
const catches = new Map(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type CombinedDataQuery = {
|
|||||||
enableForms: boolean;
|
enableForms: boolean;
|
||||||
region?: string;
|
region?: string;
|
||||||
game?: string;
|
game?: string;
|
||||||
|
/** Counting repeats the whole scoped read; callers fetching every row in one page can skip it. */
|
||||||
|
includeCount?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,16 +22,37 @@ export async function loadCombinedDataPage(
|
|||||||
supabase: SupabaseClient,
|
supabase: SupabaseClient,
|
||||||
userId: string,
|
userId: string,
|
||||||
pokedex: Pokedex,
|
pokedex: Pokedex,
|
||||||
{ page, limit, enableForms, region = '', game = '' }: CombinedDataQuery
|
{ page, limit, enableForms, region = '', game = '', includeCount = true }: CombinedDataQuery
|
||||||
) {
|
) {
|
||||||
// Use the pokédex's gameScope as the default filter if no manual game filter is set.
|
// Use the pokédex's gameScope as the default filter if no manual game filter is set.
|
||||||
const effectiveGame = game || pokedex.gameScope || '';
|
const effectiveGame = game || pokedex.gameScope || '';
|
||||||
const dexScopes = await resolveDexScopes(supabase, pokedex);
|
const dexScopes = await resolveDexScopes(supabase, pokedex);
|
||||||
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
|
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
|
||||||
|
|
||||||
|
const rows = repo.findCombinedData(
|
||||||
|
userId,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
enableForms,
|
||||||
|
region,
|
||||||
|
effectiveGame,
|
||||||
|
dexScopes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!includeCount) {
|
||||||
|
// Single-page callers already hold every row, so counting would repeat the same scoped read.
|
||||||
|
const combinedData = await rows;
|
||||||
|
return {
|
||||||
|
combinedData,
|
||||||
|
totalPages: 1,
|
||||||
|
currentPage: page,
|
||||||
|
totalCount: combinedData.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// The rows and the count are independent queries, so run them together.
|
// The rows and the count are independent queries, so run them together.
|
||||||
const [combinedData, totalCount] = await Promise.all([
|
const [combinedData, totalCount] = await Promise.all([
|
||||||
repo.findCombinedData(userId, page, limit, enableForms, region, effectiveGame, dexScopes),
|
rows,
|
||||||
repo.countCombinedData(enableForms, region, effectiveGame, dexScopes)
|
repo.countCombinedData(enableForms, region, effectiveGame, dexScopes)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ export async function loadPokedexEntryDetail(
|
|||||||
entryId: number
|
entryId: number
|
||||||
) {
|
) {
|
||||||
const scopes = await resolveDexScopes(supabase, pokedex);
|
const scopes = await resolveDexScopes(supabase, pokedex);
|
||||||
const membership = new CombinedDataRepository(supabase, userId, pokedex._id, true);
|
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
|
||||||
const entries = await membership.findGridEntries(
|
const member = await repo.isEntryInDex(
|
||||||
|
entryId,
|
||||||
pokedex.isFormDex,
|
pokedex.isFormDex,
|
||||||
pokedex.gameScope || '',
|
pokedex.gameScope || '',
|
||||||
scopes
|
scopes
|
||||||
);
|
);
|
||||||
if (!entries.some((entry) => entry.id === entryId)) return null;
|
if (!member) return null;
|
||||||
return new CombinedDataRepository(supabase, userId, pokedex._id).findEntryDetail(entryId);
|
return repo.findEntryDetail(entryId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { CatchRecord } from '$lib/models/CatchRecord';
|
||||||
|
import type { CombinedData } from '$lib/models/CombinedData';
|
||||||
|
import type { PokedexGridRow } from '$lib/models/PokedexGridRow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the Pokémon shown in the detail modal. The detail row supplies catalog text and personal
|
||||||
|
* notes; the live grid row and any queued write supply catch status, so a detail response can never
|
||||||
|
* undo a status change made while it was being loaded.
|
||||||
|
*/
|
||||||
|
export function mergeEntryDetail(
|
||||||
|
detail: CombinedData,
|
||||||
|
gridRecord: PokedexGridRow['catchRecord'] | null | undefined,
|
||||||
|
pending: Partial<CatchRecord> | null | undefined,
|
||||||
|
owner: string,
|
||||||
|
pokedexId: string,
|
||||||
|
entryId: string
|
||||||
|
): CombinedData {
|
||||||
|
if (!detail.catchRecord && !gridRecord && !pending) return { ...detail, catchRecord: null };
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
catchRecord: {
|
||||||
|
_id: '',
|
||||||
|
userId: owner,
|
||||||
|
pokedexId,
|
||||||
|
pokemonId: entryId,
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: false,
|
||||||
|
inHome: false,
|
||||||
|
hasGigantamaxed: false,
|
||||||
|
personalNotes: '',
|
||||||
|
...detail.catchRecord,
|
||||||
|
...gridRecord,
|
||||||
|
...pending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
const enableForms = url.searchParams.get('enableForms') === 'true';
|
const enableForms = url.searchParams.get('enableForms') === 'true';
|
||||||
const region = url.searchParams.get('region') || '';
|
const region = url.searchParams.get('region') || '';
|
||||||
const game = url.searchParams.get('game') || '';
|
const game = url.searchParams.get('game') || '';
|
||||||
|
// Callers that fetch the whole dex in one page opt out of the duplicate count query.
|
||||||
|
const includeCount = url.searchParams.get('includeCount') !== 'false';
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Anonymous users cannot view pokédexes
|
// Anonymous users cannot view pokédexes
|
||||||
@@ -42,7 +44,8 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
limit,
|
limit,
|
||||||
enableForms,
|
enableForms,
|
||||||
region,
|
region,
|
||||||
game
|
game,
|
||||||
|
includeCount
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
type CatchRecordPatch
|
type CatchRecordPatch
|
||||||
} from '$lib/models/PokedexGridRow';
|
} from '$lib/models/PokedexGridRow';
|
||||||
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
|
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
|
||||||
|
import { afterCriticalPageWork } from '$lib/utils/criticalPageWork';
|
||||||
|
import { mergeEntryDetail } from '$lib/utils/pokemonDetail';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@@ -180,6 +182,10 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
detailRequest++;
|
detailRequest++;
|
||||||
detailAbort?.abort();
|
detailAbort?.abort();
|
||||||
|
detailPrimeRequest++;
|
||||||
|
detailPrimeAbort?.abort();
|
||||||
|
cancelDetailPrime?.();
|
||||||
|
cancelDetailPrime = null;
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
catchWriteQueueUnsubscribe?.();
|
catchWriteQueueUnsubscribe?.();
|
||||||
@@ -196,12 +202,62 @@
|
|||||||
let returnFocus: HTMLElement | null = null;
|
let returnFocus: HTMLElement | null = null;
|
||||||
const detailCache = new Map<string, CombinedData>();
|
const detailCache = new Map<string, CombinedData>();
|
||||||
let detailOwner = '';
|
let detailOwner = '';
|
||||||
|
// Details are catalog text plus personal notes, so one background read serves every card in the
|
||||||
|
// dex. Without it each card open costs an authenticated round trip before anything can render.
|
||||||
|
let detailPrimeKey = '';
|
||||||
|
let detailPrimeRequest = 0;
|
||||||
|
let detailPrimeAbort: AbortController | null = null;
|
||||||
|
let cancelDetailPrime: (() => void) | null = null;
|
||||||
|
|
||||||
$: if (localUser?.id !== detailOwner) {
|
$: if (localUser?.id !== detailOwner) {
|
||||||
detailOwner = localUser?.id ?? '';
|
detailOwner = localUser?.id ?? '';
|
||||||
detailCache.clear();
|
detailCache.clear();
|
||||||
|
detailPrimeKey = '';
|
||||||
closePokemonModal();
|
closePokemonModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: if (browser && pokedexId && localUser?.id) scheduleDetailPrime();
|
||||||
|
|
||||||
|
function scheduleDetailPrime(force = false) {
|
||||||
|
if (!browser) return;
|
||||||
|
const owner = localUser?.id ?? '';
|
||||||
|
const id = pokedexId;
|
||||||
|
if (!owner || !id) return;
|
||||||
|
const key = `${owner}:${id}`;
|
||||||
|
if (!force && key === detailPrimeKey) return;
|
||||||
|
detailPrimeKey = key;
|
||||||
|
cancelDetailPrime?.();
|
||||||
|
cancelDetailPrime = afterCriticalPageWork(() => {
|
||||||
|
cancelDetailPrime = null;
|
||||||
|
void primeDetailCache(owner, id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function primeDetailCache(owner: string, id: string) {
|
||||||
|
if (!browser || !navigator.onLine) return;
|
||||||
|
const request = ++detailPrimeRequest;
|
||||||
|
detailPrimeAbort?.abort();
|
||||||
|
detailPrimeAbort = new AbortController();
|
||||||
|
try {
|
||||||
|
const enableForms = pokedex?.isFormDex ?? false;
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/pokedexes/${id}/combined-data?page=1&limit=9999&enableForms=${enableForms}&includeCount=false`,
|
||||||
|
{ signal: detailPrimeAbort.signal }
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error('Unable to load details');
|
||||||
|
const result: { combinedData: CombinedData[] } = await response.json();
|
||||||
|
if (request !== detailPrimeRequest || id !== pokedexId || owner !== localUser?.id) return;
|
||||||
|
for (const row of result.combinedData) {
|
||||||
|
detailCache.set(`${owner}:${id}:${row.pokedexEntry._id}`, row);
|
||||||
|
}
|
||||||
|
// A card opened before this landed is still waiting on its own request; serve it now.
|
||||||
|
if (showModal && !selectedPokemon && selectedSummary) void openPokemonModal(selectedSummary);
|
||||||
|
} catch {
|
||||||
|
// Cards fall back to fetching their own details; allow a later attempt to prime again.
|
||||||
|
if (request === detailPrimeRequest) detailPrimeKey = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openPokemonModal(pokemon: PokedexGridRow) {
|
async function openPokemonModal(pokemon: PokedexGridRow) {
|
||||||
if (!showModal) returnFocus = document.activeElement as HTMLElement;
|
if (!showModal) returnFocus = document.activeElement as HTMLElement;
|
||||||
selectedSummary = pokemon;
|
selectedSummary = pokemon;
|
||||||
@@ -230,29 +286,14 @@
|
|||||||
if (request !== detailRequest || id !== pokedexId || owner !== localUser?.id) return;
|
if (request !== detailRequest || id !== pokedexId || owner !== localUser?.id) return;
|
||||||
if (!detail) throw new Error('These details are not saved for offline use.');
|
if (!detail) throw new Error('These details are not saved for offline use.');
|
||||||
detailCache.set(key, detail);
|
detailCache.set(key, detail);
|
||||||
// A detail response must not undo status changes made while it was in flight.
|
selectedPokemon = mergeEntryDetail(
|
||||||
const current = combinedData?.find((row) => row.pokedexEntry._id === entryId)?.catchRecord;
|
detail,
|
||||||
const pending = catchWriteQueue?.getPendingPatch(entryId);
|
combinedData?.find((row) => row.pokedexEntry._id === entryId)?.catchRecord,
|
||||||
selectedPokemon = {
|
catchWriteQueue?.getPendingPatch(entryId),
|
||||||
...detail,
|
owner,
|
||||||
catchRecord:
|
id,
|
||||||
detail.catchRecord || current || pending
|
entryId
|
||||||
? {
|
);
|
||||||
_id: '',
|
|
||||||
userId: owner,
|
|
||||||
pokedexId: id,
|
|
||||||
pokemonId: entryId,
|
|
||||||
caught: false,
|
|
||||||
haveToEvolve: false,
|
|
||||||
inHome: false,
|
|
||||||
hasGigantamaxed: false,
|
|
||||||
personalNotes: '',
|
|
||||||
...detail.catchRecord,
|
|
||||||
...current,
|
|
||||||
...pending
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (request !== detailRequest || id !== pokedexId || owner !== localUser?.id) return;
|
if (request !== detailRequest || id !== pokedexId || owner !== localUser?.id) return;
|
||||||
detailError = error instanceof Error ? error.message : 'Unable to load details.';
|
detailError = error instanceof Error ? error.message : 'Unable to load details.';
|
||||||
@@ -430,7 +471,7 @@
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
combinedData = unpackGrid(result.grid);
|
combinedData = unpackGrid(result.grid);
|
||||||
detailCache.clear();
|
// Cached details keep their catalog text; catch status is taken from the grid on open.
|
||||||
} catch {
|
} catch {
|
||||||
if (request === gridRequest && id === pokedexId && owner === localUser?.id)
|
if (request === gridRequest && id === pokedexId && owner === localUser?.id)
|
||||||
failedToLoad = true;
|
failedToLoad = true;
|
||||||
@@ -609,6 +650,7 @@
|
|||||||
gridRequest++;
|
gridRequest++;
|
||||||
closePokemonModal();
|
closePokemonModal();
|
||||||
detailCache.clear();
|
detailCache.clear();
|
||||||
|
detailPrimeKey = '';
|
||||||
combinedData = data.grid ? unpackGrid(data.grid) : null;
|
combinedData = data.grid ? unpackGrid(data.grid) : null;
|
||||||
failedToLoad = data.grid === null;
|
failedToLoad = data.grid === null;
|
||||||
}
|
}
|
||||||
@@ -636,6 +678,8 @@
|
|||||||
const onOnline = () => {
|
const onOnline = () => {
|
||||||
online = true;
|
online = true;
|
||||||
void catchWriteQueue?.flushNow();
|
void catchWriteQueue?.flushNow();
|
||||||
|
// Priming is skipped while offline, so ask for it again now the network is back.
|
||||||
|
scheduleDetailPrime(true);
|
||||||
};
|
};
|
||||||
window.addEventListener('offline', onOffline);
|
window.addEventListener('offline', onOffline);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ Feature: Signed-in page speed
|
|||||||
Then its entries appear within 5 seconds
|
Then its entries appear within 5 seconds
|
||||||
And the browser did not request the grid separately
|
And the browser did not request the grid separately
|
||||||
|
|
||||||
|
Scenario: A Pokémon's card opens from data the browser already has
|
||||||
|
When I load the Pokédex page directly
|
||||||
|
And the Pokédex has finished loading its details in the background
|
||||||
|
And I open the first Pokémon
|
||||||
|
Then its details were already in the browser
|
||||||
|
|
||||||
Scenario: Moving between my Pokédex list and a Pokédex is quick
|
Scenario: Moving between my Pokédex list and a Pokédex is quick
|
||||||
When I switch between my Pokédex list and the Pokédex
|
When I switch between my Pokédex list and the Pokédex
|
||||||
Then each switch finishes within 3 seconds
|
Then each switch finishes within 3 seconds
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { test, expect } from '../fixtures';
|
|||||||
const { When, Then } = createBdd(test);
|
const { When, Then } = createBdd(test);
|
||||||
|
|
||||||
// Per-page scratch values; scenarios run one at a time (workers: 1).
|
// Per-page scratch values; scenarios run one at a time (workers: 1).
|
||||||
const timings = new WeakMap<
|
type Timing = {
|
||||||
Page,
|
entriesMs?: number;
|
||||||
{ entriesMs?: number; switchMs: number[]; entryRequests: number }
|
switchMs: number[];
|
||||||
>();
|
gridRequests: number;
|
||||||
|
detailRequests: number;
|
||||||
|
detailsPrimed: boolean;
|
||||||
|
};
|
||||||
|
const timings = new WeakMap<Page, Timing>();
|
||||||
|
|
||||||
function entriesVisible(page: Page) {
|
function entriesVisible(page: Page) {
|
||||||
return expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible({
|
return expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible({
|
||||||
@@ -18,21 +22,61 @@ function entriesVisible(page: Page) {
|
|||||||
|
|
||||||
When('I load the Pokédex page directly', async ({ page, state }) => {
|
When('I load the Pokédex page directly', async ({ page, state }) => {
|
||||||
if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be opened');
|
if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be opened');
|
||||||
const record = { switchMs: [], entryRequests: 0 };
|
const record: Timing = {
|
||||||
|
switchMs: [],
|
||||||
|
gridRequests: 0,
|
||||||
|
detailRequests: 0,
|
||||||
|
detailsPrimed: false
|
||||||
|
};
|
||||||
timings.set(page, record);
|
timings.set(page, record);
|
||||||
// Only requests made while the page first loads matter; the page's 60s reconciliation refetch
|
// Only requests made while the page first loads matter; the page's 60s reconciliation refetch
|
||||||
// can't fire within this window.
|
// can't fire within this window.
|
||||||
const countEntryRequests = (request: { url(): string }) => {
|
const countGridRequests = (request: { url(): string }) => {
|
||||||
if (/\/api\/pokedexes\/[^/]+\/(?:grid|combined-data)/.test(request.url()))
|
if (/\/api\/pokedexes\/[^/]+\/grid/.test(request.url())) record.gridRequests++;
|
||||||
record.entryRequests++;
|
|
||||||
};
|
};
|
||||||
page.on('request', countEntryRequests);
|
// The background detail read is deliberately allowed: it is what keeps cards from fetching
|
||||||
|
// themselves one at a time. Remember when it lands so a later step can open a card.
|
||||||
|
const notePrimedDetails = (response: { url(): string; status(): number }) => {
|
||||||
|
if (/\/api\/pokedexes\/[^/]+\/combined-data/.test(response.url()) && response.status() === 200)
|
||||||
|
record.detailsPrimed = true;
|
||||||
|
};
|
||||||
|
page.on('request', countGridRequests);
|
||||||
|
page.on('response', notePrimedDetails);
|
||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
await page.goto(`/pokedex/${state.pokedexId}`);
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
await entriesVisible(page);
|
await entriesVisible(page);
|
||||||
(record as { entriesMs?: number }).entriesMs = Date.now() - started;
|
record.entriesMs = Date.now() - started;
|
||||||
page.off('request', countEntryRequests);
|
page.off('request', countGridRequests);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('the Pokédex has finished loading its details in the background', async ({ page }) => {
|
||||||
|
const record = timings.get(page);
|
||||||
|
expect(record, 'the Pokédex page was never loaded').toBeDefined();
|
||||||
|
await expect.poll(() => record!.detailsPrimed, { timeout: 30_000 }).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I open the first Pokémon', async ({ page }) => {
|
||||||
|
const record = timings.get(page);
|
||||||
|
expect(record, 'the Pokédex page was never loaded').toBeDefined();
|
||||||
|
const countDetailRequests = (request: { url(): string }) => {
|
||||||
|
if (/\/api\/pokedexes\/[^/]+\/entries\//.test(request.url())) record!.detailRequests++;
|
||||||
|
};
|
||||||
|
page.on('request', countDetailRequests);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /^View details for / })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
// "Where to catch" comes from the detail row, so it only renders once details are present.
|
||||||
|
await expect(page.getByText('Where to catch:')).toBeVisible({ timeout: 10_000 });
|
||||||
|
// Give any stray per-entry request time to be made before asserting none was.
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
page.off('request', countDetailRequests);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('its details were already in the browser', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('status')).toHaveCount(0);
|
||||||
|
expect(timings.get(page)?.detailRequests).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
Then('its entries appear within {int} seconds', async ({ page }, seconds: number) => {
|
Then('its entries appear within {int} seconds', async ({ page }, seconds: number) => {
|
||||||
@@ -44,12 +88,17 @@ Then('its entries appear within {int} seconds', async ({ page }, seconds: number
|
|||||||
Then('the browser did not request the grid separately', async ({ page }) => {
|
Then('the browser did not request the grid separately', async ({ page }) => {
|
||||||
// The server load includes the compact grid with the HTML, so the page must not make
|
// The server load includes the compact grid with the HTML, so the page must not make
|
||||||
// the old hydrate-then-fetch round trip.
|
// the old hydrate-then-fetch round trip.
|
||||||
expect(timings.get(page)?.entryRequests).toBe(0);
|
expect(timings.get(page)?.gridRequests).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
When('I switch between my Pokédex list and the Pokédex', async ({ page, state }) => {
|
When('I switch between my Pokédex list and the Pokédex', async ({ page, state }) => {
|
||||||
if (!state.pokedexName) throw new Error('A Pokédex must exist before switching to it');
|
if (!state.pokedexName) throw new Error('A Pokédex must exist before switching to it');
|
||||||
const record = { switchMs: [] as number[], entryRequests: 0 };
|
const record: Timing = {
|
||||||
|
switchMs: [],
|
||||||
|
gridRequests: 0,
|
||||||
|
detailRequests: 0,
|
||||||
|
detailsPrimed: false
|
||||||
|
};
|
||||||
timings.set(page, record);
|
timings.set(page, record);
|
||||||
await page.goto('/my-pokedexes');
|
await page.goto('/my-pokedexes');
|
||||||
const card = page.locator('.card').filter({ hasText: state.pokedexName }).first();
|
const card = page.locator('.card').filter({ hasText: state.pokedexName }).first();
|
||||||
|
|||||||
@@ -234,3 +234,54 @@ describe('compact grid reads', () => {
|
|||||||
expect(selection?.args).toHaveLength(1);
|
expect(selection?.args).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CombinedDataRepository.isEntryInDex', () => {
|
||||||
|
it('accepts an entry the scoped dex lists, without reading the whole dex', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub((table) =>
|
||||||
|
table === 'game_pokedex_entry_details'
|
||||||
|
? { data: [{ id: 25 }], error: null }
|
||||||
|
: { data: [], error: null }
|
||||||
|
);
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
|
||||||
|
|
||||||
|
await expect(repo.isEntryInDex(25, false, 'Black', ['black-unova'])).resolves.toBe(true);
|
||||||
|
|
||||||
|
const [dexEntries] = queryFor(queries, 'game_pokedex_entry_details');
|
||||||
|
expect(hasCall(dexEntries, 'eq', ['id', 25])).toBe(true);
|
||||||
|
expect(hasCall(dexEntries, 'eq', ['isDefaultForm', true])).toBe(true);
|
||||||
|
expect(hasCall(dexEntries, 'limit', [1])).toBe(true);
|
||||||
|
// A membership check must not page the dex the way the grid read does.
|
||||||
|
expect(dexEntries.calls.some((c) => c.method === 'range')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a named form a game-scoped form dex supplements from pokedex_entries', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub((table) =>
|
||||||
|
table === 'pokedex_entries' ? { data: [{ id: 99 }], error: null } : { data: [], error: null }
|
||||||
|
);
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
|
||||||
|
|
||||||
|
await expect(repo.isEntryInDex(99, true, 'Scarlet', ['scarlet-paldea'])).resolves.toBe(true);
|
||||||
|
|
||||||
|
const [forms] = queryFor(queries, 'pokedex_entries');
|
||||||
|
expect(hasCall(forms, 'not', ['form', 'is', null])).toBe(true);
|
||||||
|
expect(hasCall(forms, 'contains', ['gamesToCatchIn', ['Scarlet']])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an entry that is in neither the scoped dex nor its form supplement', async () => {
|
||||||
|
const { supabase } = createSupabaseStub();
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
|
||||||
|
|
||||||
|
await expect(repo.isEntryInDex(1, true, 'Scarlet', ['scarlet-paldea'])).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks an unscoped dex against the game filter alone', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub(() => ({ data: [{ id: 25 }], error: null }));
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
|
||||||
|
|
||||||
|
await expect(repo.isEntryInDex(25, true, 'Red', [])).resolves.toBe(true);
|
||||||
|
|
||||||
|
const [entries] = queryFor(queries, 'pokedex_entries');
|
||||||
|
expect(hasCall(entries, 'contains', ['gamesToCatchIn', ['Red']])).toBe(true);
|
||||||
|
expect(mentionsIsDefaultForm(entries)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -74,4 +74,23 @@ describe('loadCombinedDataPage', () => {
|
|||||||
releaseRows([]);
|
releaseRows([]);
|
||||||
await expect(pending).resolves.toMatchObject({ totalCount: 45, totalPages: 5 });
|
await expect(pending).resolves.toMatchObject({ totalCount: 45, totalPages: 5 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips the count query when the caller asks for every row at once', async () => {
|
||||||
|
findCombinedData.mockResolvedValue([{ id: 'a' }, { id: 'b' }]);
|
||||||
|
|
||||||
|
const result = await loadCombinedDataPage(supabase, 'user-1', pokedex, {
|
||||||
|
page: 1,
|
||||||
|
limit: 9999,
|
||||||
|
enableForms: true,
|
||||||
|
includeCount: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(countCombinedData).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({
|
||||||
|
combinedData: [{ id: 'a' }, { id: 'b' }],
|
||||||
|
totalPages: 1,
|
||||||
|
currentPage: 1,
|
||||||
|
totalCount: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mergeEntryDetail } from '$lib/utils/pokemonDetail';
|
||||||
|
import type { CombinedData } from '$lib/models/CombinedData';
|
||||||
|
|
||||||
|
const detail = {
|
||||||
|
pokedexEntry: {
|
||||||
|
_id: '25',
|
||||||
|
pokedexNumber: 25,
|
||||||
|
pokemon: 'Pikachu',
|
||||||
|
form: '',
|
||||||
|
spriteKey: '25',
|
||||||
|
canGigantamax: true,
|
||||||
|
regionToCatchIn: 'Kanto',
|
||||||
|
gamesToCatchIn: ['Red'],
|
||||||
|
regionToEvolveIn: '',
|
||||||
|
evolutionInformation: 'Use a Thunder Stone',
|
||||||
|
catchInformation: ['Viridian Forest'],
|
||||||
|
notes: 'Dex note'
|
||||||
|
},
|
||||||
|
catchRecord: {
|
||||||
|
_id: 'catch-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
pokedexId: 'dex-1',
|
||||||
|
pokemonId: '25',
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: false,
|
||||||
|
inHome: false,
|
||||||
|
hasGigantamaxed: false,
|
||||||
|
personalNotes: 'My note'
|
||||||
|
}
|
||||||
|
} satisfies CombinedData;
|
||||||
|
|
||||||
|
describe('mergeEntryDetail', () => {
|
||||||
|
it('keeps catalog text and personal notes while taking status from the grid row', () => {
|
||||||
|
const merged = mergeEntryDetail(
|
||||||
|
detail,
|
||||||
|
{ _id: 'catch-1', caught: true, haveToEvolve: false, inHome: true, hasGigantamaxed: false },
|
||||||
|
undefined,
|
||||||
|
'user-1',
|
||||||
|
'dex-1',
|
||||||
|
'25'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.pokedexEntry.evolutionInformation).toBe('Use a Thunder Stone');
|
||||||
|
expect(merged.catchRecord).toMatchObject({
|
||||||
|
caught: true,
|
||||||
|
inHome: true,
|
||||||
|
personalNotes: 'My note'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a queued write win over both the detail row and the grid row', () => {
|
||||||
|
const merged = mergeEntryDetail(
|
||||||
|
detail,
|
||||||
|
{ _id: 'catch-1', caught: false, haveToEvolve: false, inHome: false, hasGigantamaxed: false },
|
||||||
|
{ userId: 'user-1', pokedexId: 'dex-1', pokemonId: '25', caught: true },
|
||||||
|
'user-1',
|
||||||
|
'dex-1',
|
||||||
|
'25'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.catchRecord?.caught).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a record from a queued write when nothing has been saved yet', () => {
|
||||||
|
const merged = mergeEntryDetail(
|
||||||
|
{ ...detail, catchRecord: null },
|
||||||
|
null,
|
||||||
|
{ userId: 'user-1', pokedexId: 'dex-1', pokemonId: '25', haveToEvolve: true },
|
||||||
|
'user-1',
|
||||||
|
'dex-1',
|
||||||
|
'25'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.catchRecord).toEqual({
|
||||||
|
_id: '',
|
||||||
|
userId: 'user-1',
|
||||||
|
pokedexId: 'dex-1',
|
||||||
|
pokemonId: '25',
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: true,
|
||||||
|
inHome: false,
|
||||||
|
hasGigantamaxed: false,
|
||||||
|
personalNotes: ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an uncaught Pokémon without a catch record', () => {
|
||||||
|
const merged = mergeEntryDetail({ ...detail, catchRecord: null }, null, null, 'u', 'd', '25');
|
||||||
|
|
||||||
|
expect(merged.catchRecord).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user