feat(pokemon): add opposite relationships and redesign detail view

Add description and opposite relationships to environments and favorite things
Move pokedex reference data (stats, dimensions, types) to a separate tab
Highlight core mechanics (skills, habitat, favorite things) in detail view
Update related pokemon scoring to account for opposite relationships
This commit is contained in:
2026-05-07 15:57:38 +08:00
parent a781bc559b
commit 953b90eba1
8 changed files with 489 additions and 96 deletions

View File

@@ -109,11 +109,14 @@ CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx
CREATE TABLE IF NOT EXISTS environments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
description text NOT NULL DEFAULT '',
opposite_environment_id integer REFERENCES environments(id) ON DELETE SET NULL,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (opposite_environment_id IS NULL OR opposite_environment_id <> id)
);
CREATE TABLE IF NOT EXISTS roles (
@@ -1071,11 +1074,13 @@ CREATE TABLE IF NOT EXISTS skills (
CREATE TABLE IF NOT EXISTS favorite_things (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
opposite_favorite_thing_id integer REFERENCES favorite_things(id) ON DELETE SET NULL,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (opposite_favorite_thing_id IS NULL OR opposite_favorite_thing_id <> id)
);
CREATE TABLE IF NOT EXISTS pokemon_types (
@@ -1526,6 +1531,50 @@ ALTER TABLE skills
ALTER TABLE items
ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3));
ALTER TABLE environments
ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT '';
ALTER TABLE environments
ADD COLUMN IF NOT EXISTS opposite_environment_id integer;
ALTER TABLE favorite_things
ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'environments_opposite_environment_id_fkey'
) THEN
ALTER TABLE environments
ADD CONSTRAINT environments_opposite_environment_id_fkey
FOREIGN KEY (opposite_environment_id) REFERENCES environments(id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'environments_opposite_environment_id_check'
) THEN
ALTER TABLE environments
ADD CONSTRAINT environments_opposite_environment_id_check
CHECK (opposite_environment_id IS NULL OR opposite_environment_id <> id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'favorite_things_opposite_favorite_thing_id_fkey'
) THEN
ALTER TABLE favorite_things
ADD CONSTRAINT favorite_things_opposite_favorite_thing_id_fkey
FOREIGN KEY (opposite_favorite_thing_id) REFERENCES favorite_things(id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'favorite_things_opposite_favorite_thing_id_check'
) THEN
ALTER TABLE favorite_things
ADD CONSTRAINT favorite_things_opposite_favorite_thing_id_check
CHECK (opposite_favorite_thing_id IS NULL OR opposite_favorite_thing_id <> id);
END IF;
END $$;
UPDATE items
SET dyeability = CASE
WHEN dual_dyeable THEN 2

View File

@@ -157,6 +157,8 @@ type ConfigDefinition = {
hasItemDrop?: boolean;
hasTrading?: boolean;
hasChangeLog?: boolean;
hasDescription?: boolean;
oppositeColumn?: string;
};
type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
type SortableContentDefinition = {
@@ -656,6 +658,8 @@ type DailyChecklistChangeSource = {
} & TranslationChangeSource;
type ConfigChangeSource = {
name: string;
description?: string;
opposite?: { name: string } | null;
hasItemDrop?: boolean;
hasTrading?: boolean;
changeLog?: string;
@@ -722,8 +726,8 @@ const ancientArtifactCategoryOptions = [
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
environments: { table: 'environments', entityType: 'environments' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' },
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
@@ -1108,8 +1112,22 @@ function configOrder(): string {
function configSelect(definition: ConfigDefinition, locale: string): string {
const name = localizedName(definition.entityType, 'c', locale);
const oppositeName = localizedName(definition.entityType, 'opposite_config', locale);
const translations = translationsSelect(definition.entityType, 'c.id');
const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`];
if (definition.hasDescription) {
columns.push(`c.description`);
}
if (definition.oppositeColumn) {
columns.push(
`
CASE
WHEN opposite_config.id IS NULL THEN NULL
ELSE json_build_object('id', opposite_config.id, 'name', ${oppositeName})
END AS opposite
`
);
}
if (definition.hasItemDrop) {
columns.push(`c.has_item_drop AS "hasItemDrop"`);
}
@@ -1122,6 +1140,14 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
return columns.join(', ');
}
function configRelationJoins(definition: ConfigDefinition): string {
if (!definition.oppositeColumn) {
return '';
}
return `LEFT JOIN ${definition.table} opposite_config ON opposite_config.id = c.${definition.oppositeColumn}`;
}
function validationError(message: string): ValidationError {
const error = new Error(message) as ValidationError;
error.statusCode = 400;
@@ -2070,7 +2096,7 @@ async function ensurePokemonTypeCatalog(
const changes = configEditChanges(
{ table: 'pokemon_types', entityType: 'pokemon-types' },
existing.rows[0],
{ name, translations, hasItemDrop: false, hasTrading: false, changeLog: '' }
{ name, description: '', translations, oppositeName: '', hasItemDrop: false, hasTrading: false, changeLog: '' }
);
if (changes.length) {
await client.query(
@@ -2636,7 +2662,9 @@ function configEditChanges(
before: ConfigChangeSource,
after: {
name: string;
description: string;
translations: TranslationInput;
oppositeName: string;
hasItemDrop: boolean;
hasTrading: boolean;
changeLog: string;
@@ -2645,6 +2673,12 @@ function configEditChanges(
const changes: EditChange[] = [];
pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
if (definition.hasDescription) {
pushChange(changes, 'Description', before.description, after.description);
}
if (definition.oppositeColumn) {
pushChange(changes, 'Opposite', before.opposite?.name ?? '', after.oppositeName);
}
if (definition.hasItemDrop) {
pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop));
}
@@ -2684,8 +2718,10 @@ function pokemonProjection(locale: string): string {
const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
const typeName = localizedName('pokemon-types', 'pt', locale);
const environmentName = localizedName('environments', 'e', locale);
const environmentOppositeName = localizedName('environments', 'environment_opposite', locale);
const skillName = localizedName('skills', 's', locale);
const favoriteThingName = localizedName('favorite-things', 'ft', locale);
const favoriteThingOppositeName = localizedName('favorite-things', 'opposite_ft', locale);
return `
SELECT
@@ -2715,7 +2751,16 @@ function pokemonProjection(locale: string): string {
) AS stats,
${translationsSelect('pokemon', 'p.id')} AS translations,
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
json_build_object('id', e.id, 'name', ${environmentName}) AS environment,
json_build_object(
'id', e.id,
'name', ${environmentName},
'description', e.description,
'opposite',
CASE
WHEN environment_opposite.id IS NULL THEN NULL
ELSE json_build_object('id', environment_opposite.id, 'name', ${environmentOppositeName})
END
) AS environment,
COALESCE((
SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order)
FROM pokemon_pokemon_types ppt
@@ -2729,13 +2774,26 @@ function pokemonProjection(locale: string): string {
WHERE ps.pokemon_id = p.id
), '[]'::json) AS skills,
COALESCE((
SELECT json_agg(json_build_object('id', ft.id, 'name', ${favoriteThingName}) ORDER BY ${orderByEntity('ft')})
SELECT json_agg(
json_build_object(
'id', ft.id,
'name', ${favoriteThingName},
'opposite',
CASE
WHEN opposite_ft.id IS NULL THEN NULL
ELSE json_build_object('id', opposite_ft.id, 'name', ${favoriteThingOppositeName})
END
)
ORDER BY ${orderByEntity('ft')}
)
FROM pokemon_favorite_things pft
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
LEFT JOIN favorite_things opposite_ft ON opposite_ft.id = ft.opposite_favorite_thing_id
WHERE pft.pokemon_id = p.id
), '[]'::json) AS favorite_things
FROM pokemon p
JOIN environments e ON e.id = p.environment_id
LEFT JOIN environments environment_opposite ON environment_opposite.id = e.opposite_environment_id
${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')}
`;
}
@@ -5363,6 +5421,7 @@ export async function listConfig(type: ConfigType, locale = defaultLocale) {
`
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
FROM ${definition.table} c
${configRelationJoins(definition)}
${auditJoins('c')}
ORDER BY ${configOrder()}
`
@@ -5375,6 +5434,7 @@ async function getConfigById(type: ConfigType, id: number, locale = defaultLocal
`
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
FROM ${definition.table} c
${configRelationJoins(definition)}
${auditJoins('c')}
WHERE c.id = $1
`,
@@ -5386,6 +5446,8 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
@@ -5394,6 +5456,14 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const sortOrder = await nextSortOrder(client, definition.table);
const columns = ['name'];
const values: unknown[] = [name];
if (definition.hasDescription) {
columns.push('description');
values.push(description);
}
if (definition.oppositeColumn) {
columns.push(definition.oppositeColumn);
values.push(oppositeId);
}
if (definition.hasItemDrop) {
columns.push('has_item_drop');
values.push(hasItemDrop);
@@ -5451,14 +5521,28 @@ export async function updateConfig(
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
const before = await getConfigById(type, id, defaultLocale);
if (oppositeId === id) {
throw validationError('server.validation.invalidField');
}
const updated = await withTransaction(async (client) => {
const assignments = ['name = $1'];
const values: unknown[] = [name];
if (definition.hasDescription) {
values.push(description);
assignments.push(`description = $${values.length}`);
}
if (definition.oppositeColumn) {
values.push(oppositeId);
assignments.push(`${definition.oppositeColumn} = $${values.length}`);
}
if (definition.hasItemDrop) {
values.push(hasItemDrop);
assignments.push(`has_item_drop = $${values.length}`);
@@ -5513,8 +5597,17 @@ export async function updateConfig(
}
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
const oppositeNames = definition.oppositeColumn && oppositeId ? await entityNameMap(client, definition.table, [oppositeId]) : new Map<number, string>();
const changes = before
? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, hasTrading, changeLog })
? configEditChanges(definition, before as ConfigChangeSource, {
name,
description,
translations,
oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
hasItemDrop,
hasTrading,
changeLog
})
: [];
await recordEditLog(client, type, id, 'update', userId, changes);
return true;
@@ -5637,6 +5730,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
const relatedSkillName = localizedName('skills', 'related_skill', locale);
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
const relatedFavoriteThingOppositeName = localizedName('favorite-things', 'related_favorite_thing_opposite', locale);
const tradingItemName = localizedName('items', 'trading_item', locale);
const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
@@ -5718,24 +5812,56 @@ export async function getPokemon(id: number, locale = defaultLocale) {
WHERE p.id = $1
),
current_favourites AS (
SELECT pft.favorite_thing_id
SELECT
pft.favorite_thing_id,
ft.opposite_favorite_thing_id
FROM pokemon_favorite_things pft
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
WHERE pft.pokemon_id = $1
),
scored_pokemon AS (
SELECT
related_pokemon.id,
(related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
COUNT(current_favourites.favorite_thing_id)::integer AS "favoriteThingMatchCount"
(
related_pokemon.environment_id = current_environment.opposite_environment_id
OR related_environment.opposite_environment_id = current_pokemon.environment_id
) AS "environmentIsOpposite",
COUNT(current_favourites.favorite_thing_id) FILTER (
WHERE current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
)::integer AS "favoriteThingMatchCount",
COUNT(current_favourites.favorite_thing_id) FILTER (
WHERE current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
)::integer AS "favoriteThingOppositeCount"
FROM current_pokemon
JOIN environments current_environment ON current_environment.id = current_pokemon.environment_id
JOIN pokemon related_pokemon ON related_pokemon.id <> current_pokemon.id
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
LEFT JOIN pokemon_favorite_things related_pokemon_favourite
ON related_pokemon_favourite.pokemon_id = related_pokemon.id
LEFT JOIN favorite_things related_favorite_thing
ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
LEFT JOIN current_favourites
ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
GROUP BY related_pokemon.id, related_pokemon.environment_id, current_pokemon.environment_id
OR current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
GROUP BY
related_pokemon.id,
related_pokemon.environment_id,
related_environment.opposite_environment_id,
current_pokemon.environment_id,
current_environment.opposite_environment_id
HAVING related_pokemon.environment_id = current_pokemon.environment_id
OR COUNT(current_favourites.favorite_thing_id) > 0
OR related_pokemon.environment_id = current_environment.opposite_environment_id
OR related_environment.opposite_environment_id = current_pokemon.environment_id
OR COUNT(current_favourites.favorite_thing_id) FILTER (
WHERE current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
) > 0
OR COUNT(current_favourites.favorite_thing_id) FILTER (
WHERE current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
) > 0
)
SELECT
related_pokemon.id,
@@ -5743,7 +5869,12 @@ export async function getPokemon(id: number, locale = defaultLocale) {
${relatedPokemonName} AS name,
related_pokemon.is_event_item AS "isEventItem",
${pokemonImageJson('related_pokemon')} AS image,
json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment,
json_build_object(
'id', related_environment.id,
'name', ${relatedEnvironmentName},
'matches', scored_pokemon."environmentMatches",
'isOpposite', scored_pokemon."environmentIsOpposite"
) AS environment,
COALESCE((
SELECT json_agg(
json_build_object(
@@ -5763,22 +5894,40 @@ export async function getPokemon(id: number, locale = defaultLocale) {
json_build_object(
'id', related_favorite_thing.id,
'name', ${relatedFavoriteThingName},
'opposite',
CASE
WHEN related_favorite_thing_opposite.id IS NULL THEN NULL
ELSE json_build_object('id', related_favorite_thing_opposite.id, 'name', ${relatedFavoriteThingOppositeName})
END,
'matches', EXISTS (
SELECT 1
FROM current_favourites
WHERE current_favourites.favorite_thing_id = related_favorite_thing.id
),
'isOpposite', EXISTS (
SELECT 1
FROM current_favourites
WHERE current_favourites.opposite_favorite_thing_id = related_favorite_thing.id
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
)
)
ORDER BY ${orderByEntity('related_favorite_thing')}
)
FROM pokemon_favorite_things related_pokemon_favourite
JOIN favorite_things related_favorite_thing ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
LEFT JOIN favorite_things related_favorite_thing_opposite
ON related_favorite_thing_opposite.id = related_favorite_thing.opposite_favorite_thing_id
WHERE related_pokemon_favourite.pokemon_id = related_pokemon.id
), '[]'::json) AS favorite_things
FROM scored_pokemon
JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
ORDER BY scored_pokemon."environmentMatches" DESC, scored_pokemon."favoriteThingMatchCount" DESC, related_pokemon.id
ORDER BY
scored_pokemon."environmentMatches" DESC,
scored_pokemon."favoriteThingMatchCount" DESC,
scored_pokemon."environmentIsOpposite" DESC,
scored_pokemon."favoriteThingOppositeCount" DESC,
related_pokemon.id
`,
[id]
),