diff --git a/src/features/webhooks/Manage.jsx b/src/features/webhooks/Manage.jsx index 811320896..5e3a7ba66 100644 --- a/src/features/webhooks/Manage.jsx +++ b/src/features/webhooks/Manage.jsx @@ -91,18 +91,27 @@ export function Manage() { React.useEffect(() => { if (!addNew.open && addNew.save && category !== 'human') { - const { tempFilters } = useWebhookStore.getState() - const values = Poracle.processor( + const { + tempFilters, + context: { ui }, + } = useWebhookStore.getState() + const { defaults } = ui[category] + const enabledFilters = Object.values(tempFilters || {}).filter( + (x) => x && x.enabled, + ) + const payload = Poracle.toApiPayload(category, enabledFilters, defaults) + const values = Poracle.toTrackedState( category, - Object.values(tempFilters || {}).filter((x) => x && x.enabled), - useWebhookStore.getState().context.ui[category].defaults, + enabledFilters, + defaults, + payload, ) apolloClient.mutate({ // @ts-ignore mutation: Query.webhook(category.toUpperCase()), variables: { category, - data: values, + data: payload, status: 'POST', }, refetchQueries: [ALL_PROFILES], diff --git a/src/features/webhooks/WebhookAdv.jsx b/src/features/webhooks/WebhookAdv.jsx index 381d65ed4..d647685be 100644 --- a/src/features/webhooks/WebhookAdv.jsx +++ b/src/features/webhooks/WebhookAdv.jsx @@ -40,6 +40,7 @@ const skipFields = new Set([ 'allForms', 'pvpEntry', 'noIv', + 'omittedFields', 'byDistance', 'distance', 'xs', @@ -169,12 +170,18 @@ export function WebhookAdvanced() { const handleSlider = React.useCallback( (low, high) => (name, values) => { + const isPvp = name.startsWith('pvp') setFilterValues((prev) => ({ ...prev, [name]: values })) setPoracleValues((prev) => ({ ...prev, [low]: values[0], [high]: values[1], - pvpEntry: name.startsWith('pvp'), + pvpEntry: isPvp + ? !!prev.pvp_ranking_league + : name === 'size' + ? prev.pvpEntry && !!prev.pvp_ranking_league + : false, + noIv: isPvp && prev.pvp_ranking_league ? false : prev.noIv, })) }, [], @@ -241,11 +248,18 @@ export function WebhookAdvanced() { const handleSelect = (event) => { const { name, value } = event.target const newObj = { [name]: value } + const hasPvpLeague = + name === 'pvp_ranking_league' + ? !!value + : !!poracleValues.pvp_ranking_league if (name === 'pvp_ranking_league') { newObj.pvp_ranking_min_cp = pvp === 'ohbem' ? 0 : value - 50 } if (name.startsWith('pvp')) { - newObj.pvpEntry = true + newObj.pvpEntry = hasPvpLeague + if (hasPvpLeague) { + newObj.noIv = false + } } if (name === 'move' && value !== 9000) { newObj.allMoves = false diff --git a/src/features/webhooks/services/Poracle.js b/src/features/webhooks/services/Poracle.js index bb963909e..76048111b 100644 --- a/src/features/webhooks/services/Poracle.js +++ b/src/features/webhooks/services/Poracle.js @@ -3,6 +3,54 @@ import { t } from 'i18next' import { useWebhookStore } from '@store/useWebhookStore' +const POKEMON_PVP_FIELDS = [ + 'pvp_ranking_league', + 'pvp_ranking_best', + 'pvp_ranking_worst', + 'pvp_ranking_min_cp', + 'pvp_ranking_cap', +] + +const POKEMON_CREATE_REQUIRED_FIELDS = [ + 'pokemon_id', + 'form', + 'profile_no', + 'template', +] + +const POKEMON_UPDATE_REQUIRED_FIELDS = [ + 'uid', + ...POKEMON_CREATE_REQUIRED_FIELDS, +] + +const POKEMON_SCALAR_FIELDS = ['clean', 'distance', 'gender', 'min_time'] + +const POKEMON_RANGE_GROUPS = [ + ['min_cp', 'max_cp'], + ['min_level', 'max_level'], + ['atk', 'max_atk'], + ['def', 'max_def'], + ['sta', 'max_sta'], + ['rarity', 'max_rarity'], + ['size', 'max_size'], + ['min_weight', 'max_weight'], +] + +const POKEMON_PVP_RANGE_GROUPS = [ + ['rarity', 'max_rarity'], + ['size', 'max_size'], +] + +const POKEMON_OMITTABLE_FIELDS = [ + ...new Set([ + ...POKEMON_SCALAR_FIELDS, + 'min_iv', + 'max_iv', + ...POKEMON_RANGE_GROUPS.flat(), + ...POKEMON_PVP_FIELDS, + ]), +] + export class Poracle { static getMapCategory(poracleCategory) { switch (poracleCategory) { @@ -177,23 +225,136 @@ export class Poracle { return reactMapFriendly } - static processor(category, entries, defaults) { - const pvpFields = [ - 'pvp_ranking_league', - 'pvp_ranking_best', - 'pvp_ranking_worst', - 'pvp_ranking_min_cp', - 'pvp_ranking_cap', - ] + static getPokemonOmittedFields(pokemon) { + if (!pokemon) return {} + + const omittedFields = { ...(pokemon?.omittedFields || {}) } + + POKEMON_OMITTABLE_FIELDS.forEach((field) => { + if (pokemon?.[field] == null) { + omittedFields[field] = true + } + }) + + return omittedFields + } + + static getPokemonFieldValue(pokemon, defaults, field) { + return pokemon[field] == null ? defaults[field] : pokemon[field] + } + + static shouldOmitPokemonField(pokemon, defaults, omittedFields, field) { + return ( + omittedFields[field] && + Poracle.getPokemonFieldValue(pokemon, defaults, field) === defaults[field] + ) + } + + static toTrackedState(category, entries, defaults, payload = []) { + if (category !== 'pokemon') { + return Poracle.toLocalState(category, entries, defaults) + } + + return Poracle.toLocalState(category, entries, defaults).map( + (pokemon, index) => ({ + ...pokemon, + omittedFields: Poracle.getPokemonOmittedFields(payload[index]), + }), + ) + } + + static processPokemon(entries, defaults, includeUiState = false) { const ignoredFields = [ 'noIv', 'byDistance', + 'omittedFields', 'xs', 'xl', 'allForms', 'pvpEntry', ] const dupes = {} + + return entries + .map((pokemon) => { + const normalized = pokemon.allForms + ? { ...pokemon, form: defaults.form } + : pokemon + const omittedFields = Poracle.getPokemonOmittedFields(normalized) + const fields = [ + 'uid', + 'pokemon_id', + 'form', + 'clean', + 'distance', + 'min_time', + 'template', + 'profile_no', + 'ping', + 'gender', + 'rarity', + 'max_rarity', + 'size', + 'max_size', + ] + const newPokemon = {} + + if (pokemon.allForms) { + if (dupes[pokemon.pokemon_id]) { + return null + } + dupes[pokemon.pokemon_id] = true + } + + if (pokemon.pvpEntry) { + fields.push(...POKEMON_PVP_FIELDS) + if (includeUiState) { + fields.push( + ...Object.keys(defaults).filter( + (key) => + !POKEMON_PVP_FIELDS.includes(key) && + !ignoredFields.includes(key), + ), + ) + } + } else { + fields.push( + ...Object.keys(normalized).filter( + (key) => + !POKEMON_PVP_FIELDS.includes(key) && + !ignoredFields.includes(key), + ), + ) + if (includeUiState) { + fields.push(...POKEMON_PVP_FIELDS) + } + } + + new Set(fields).forEach((field) => { + newPokemon[field] = Poracle.getPokemonFieldValue( + normalized, + defaults, + field, + ) + }) + + if (includeUiState) { + newPokemon.allForms = !!pokemon.allForms + newPokemon.byDistance = !!pokemon.byDistance + newPokemon.noIv = !!pokemon.noIv + newPokemon.omittedFields = omittedFields + newPokemon.pvpEntry = !!pokemon.pvpEntry + newPokemon.xs = !!pokemon.xs + newPokemon.xl = !!pokemon.xl + } + + return newPokemon + }) + .filter((pokemon) => pokemon) + } + + static processor(category, entries, defaults) { + const dupes = {} switch (category) { case 'egg': return entries.map((egg) => ({ @@ -255,51 +416,188 @@ export class Poracle { }) .filter((quest) => quest) default: - return entries - .map((pkmn) => { - const fields = [ - 'pokemon_id', - 'form', - 'clean', - 'distance', - 'min_time', - 'template', - 'profile_no', - 'gender', - 'rarity', - 'max_rarity', - 'size', - 'max_size', - ] - const newPokemon = {} - if (pkmn.allForms) { - pkmn.form = 0 - if (dupes[pkmn.pokemon_id]) { - return null - } - dupes[pkmn.pokemon_id] = true - } - if (pkmn.pvpEntry) { - fields.push(...pvpFields) - } else { - fields.push( - ...Object.keys(pkmn).filter( - (key) => - !pvpFields.includes(key) && !ignoredFields.includes(key), - ), - ) - } - new Set(fields).forEach( - (field) => - (newPokemon[field] = - pkmn[field] === undefined ? defaults[field] : pkmn[field]), - ) - return newPokemon - }) - .filter((pokemon) => pokemon) + return Poracle.processPokemon(entries, defaults) } } + static toLocalState(category, entries, defaults) { + if (category !== 'pokemon') { + return Poracle.processor(category, entries, defaults) + } + + return Poracle.processPokemon(entries, defaults, true) + } + + static toUpdatePayload(category, entries, defaults) { + const processed = Poracle.toLocalState(category, entries, defaults) + if (category !== 'pokemon') { + return processed + } + + return processed.map((pokemon) => { + const payload = {} + const omittedFields = { ...(pokemon.omittedFields || {}) } + const hasPvpEntry = pokemon.pvpEntry && pokemon.pvp_ranking_league + const includeNoIv = pokemon.noIv && !hasPvpEntry + const rangeGroups = hasPvpEntry + ? POKEMON_PVP_RANGE_GROUPS + : POKEMON_RANGE_GROUPS + const setPokemonField = (field) => { + if ( + !Poracle.shouldOmitPokemonField( + pokemon, + defaults, + omittedFields, + field, + ) + ) { + payload[field] = Poracle.getPokemonFieldValue( + pokemon, + defaults, + field, + ) + } + } + + POKEMON_UPDATE_REQUIRED_FIELDS.forEach((field) => { + if (pokemon[field] !== undefined) { + payload[field] = pokemon[field] + } + }) + + if (pokemon.ping !== undefined) { + payload.ping = pokemon.ping + } + + POKEMON_SCALAR_FIELDS.forEach(setPokemonField) + + if (hasPvpEntry) { + // PvP alerts intentionally ignore regular IV/stat ranges. + } else if (includeNoIv) { + payload.min_iv = Poracle.getPokemonFieldValue( + pokemon, + defaults, + 'min_iv', + ) + payload.max_iv = Poracle.getPokemonFieldValue( + pokemon, + defaults, + 'max_iv', + ) + } else { + ;['min_iv', 'max_iv'].forEach(setPokemonField) + } + + rangeGroups.forEach(([low, high]) => { + setPokemonField(low) + setPokemonField(high) + }) + + if (hasPvpEntry) { + POKEMON_PVP_FIELDS.forEach(setPokemonField) + } else if ( + !POKEMON_PVP_FIELDS.every((field) => + Poracle.shouldOmitPokemonField( + pokemon, + defaults, + omittedFields, + field, + ), + ) + ) { + POKEMON_PVP_FIELDS.forEach((field) => { + payload[field] = defaults[field] + }) + } + + return payload + }) + } + + static toApiPayload(category, entries, defaults) { + const processed = Poracle.toLocalState(category, entries, defaults) + if (category !== 'pokemon') { + return processed + } + + return processed.map((pokemon) => { + const payload = {} + const hasPvpEntry = pokemon.pvpEntry && pokemon.pvp_ranking_league + const includeNoIv = pokemon.noIv && !hasPvpEntry + const rangeGroups = hasPvpEntry + ? POKEMON_PVP_RANGE_GROUPS + : POKEMON_RANGE_GROUPS + + POKEMON_CREATE_REQUIRED_FIELDS.forEach((field) => { + if (pokemon[field] !== undefined) { + payload[field] = pokemon[field] + } + }) + + if (pokemon.ping !== undefined) { + payload.ping = pokemon.ping + } + + POKEMON_SCALAR_FIELDS.forEach((field) => { + if ( + pokemon[field] !== undefined && + pokemon[field] !== defaults[field] + ) { + payload[field] = pokemon[field] + } + }) + + if (hasPvpEntry) { + // PvP alerts intentionally ignore regular IV/stat ranges. + } else if (includeNoIv) { + payload.min_iv = + pokemon.min_iv === undefined ? defaults.min_iv : pokemon.min_iv + payload.max_iv = + pokemon.max_iv === undefined ? defaults.max_iv : pokemon.max_iv + } else if ( + pokemon.min_iv !== undefined && + pokemon.max_iv !== undefined && + (pokemon.min_iv !== defaults.min_iv || + pokemon.max_iv !== defaults.max_iv) + ) { + payload.min_iv = pokemon.min_iv + payload.max_iv = pokemon.max_iv + } + + rangeGroups.forEach(([low, high]) => { + if ( + pokemon[low] !== undefined && + pokemon[high] !== undefined && + (pokemon[low] !== defaults[low] || pokemon[high] !== defaults[high]) + ) { + payload[low] = pokemon[low] + payload[high] = pokemon[high] + } + }) + + if (payload.min_weight === undefined) { + payload.min_weight = defaults.min_weight + } + if (payload.max_weight === undefined) { + payload.max_weight = defaults.max_weight + } + + if (includeNoIv) { + return payload + } + + if (hasPvpEntry) { + POKEMON_PVP_FIELDS.forEach((field) => { + if (pokemon[field] !== undefined) { + payload[field] = pokemon[field] + } + }) + } + + return payload + }) + } + /** * * @param {object} item diff --git a/src/features/webhooks/tiles/TrackedTile.jsx b/src/features/webhooks/tiles/TrackedTile.jsx index 6ade3b2b4..6e49a3ecf 100644 --- a/src/features/webhooks/tiles/TrackedTile.jsx +++ b/src/features/webhooks/tiles/TrackedTile.jsx @@ -29,14 +29,30 @@ export function TrackedTile({ index }) { React.useEffect(() => { if (advOpen.open && advOpen.id === id && advOpen.uid === item.uid) { + const localItem = + category === 'pokemon' + ? Poracle.toLocalState( + category, + [ + { + ...item, + omittedFields: Poracle.getPokemonOmittedFields(item), + }, + ], + defaults, + )[0] + : { ...defaults, ...item } useWebhookStore.setState((prev) => ({ tempFilters: { ...prev.tempFilters, - [id]: { ...item, byDistance: !!item.distance }, + [id]: { + ...localItem, + byDistance: !!item.distance, + }, }, })) } - }, [advOpen, id, item]) + }, [advOpen, defaults, id, item]) const onClose = React.useCallback( (newFilter, save) => { @@ -44,7 +60,7 @@ export function TrackedTile({ index }) { apolloClient.mutate({ mutation: webhookNodes[category.toUpperCase()], variables: { - data: Poracle.processor(category, [newFilter], defaults), + data: Poracle.toUpdatePayload(category, [newFilter], defaults), status: 'POST', category, },