diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..463d55544 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- React client code lives in `src/`, with shared UI in `src/components`, feature bundles in `src/features`, and hooks/services under `src/hooks`, `src/services`, and `src/utils`. +- The Node.js API, GraphQL schema, and auth logic are in `server/src`; configs and migrations sit under `server/src/configs` and `server/src/db`. +- Workspace packages in `packages/` expose shared config (`@rm/config`), localization, logging, masterfile data, and build plugins; treat them as the source of truth for cross-app utilities. +- Static assets are sourced from `public/`, while built bundles land in `dist/`; avoid committing build artefacts or anything listed in `.gitignore`. + +## Build, Test, and Development Commands + +- `yarn install` – install workspace dependencies; rerun after pulling lockfile changes. +- `yarn dev` – start the full dev stack (Nodemon backend + Vite) using local config. +- `yarn watch` – Vite-only hot reload for rapid UI work when the API is proxied elsewhere. +- `yarn build` – create a production bundle in `dist/`; ensure it succeeds before release PRs. +- `yarn lint` / `yarn lint:fix` – run ESLint with the Airbnb ruleset; lint must pass pre-commit. +- `yarn prettier` / `yarn prettier:fix` – enforce formatting for JS/JSX, CSS, HTML, and YAML. +- `yarn config:env` and `yarn locales:generate` – regenerate env files and derived locales after editing base config or strings. + +## Coding Style & Naming Conventions + +- Prettier governs formatting (2-space indent, single quotes in JS, semicolons off); never hand-format conflicting styles. +- Prefer functional React components, PascalCase for components, camelCase for helpers, and `use` prefixes for hooks. + +## Testing Guidelines + +- No dedicated Jest suite today; rely on `yarn lint`, type checks from editor tooling, and manual verification in a local dev session. +- When adding backend features, exercise relevant GraphQL/REST paths via the dev server and document sanity checks in the PR description. + +## Commit & Pull Request Guidelines + +- Use Conventional Commits (`type(scope): summary`), matching existing history (e.g. `feat(map): add weather overlays`). +- Each PR should describe scope, link related issues, list testing steps, and include screenshots or GIFs for UI changes. +- Re-run `yarn lint`, `yarn build`, and integration steps touched by the change before requesting review. + +## Localization Notes + +- Update English copy only in `packages/locales/lib/human/en.json`; run `yarn locales:generate` to refresh derived languages. +- When adding a new translation key (for example when calling `t('some_key')`), create the English entry in `packages/locales/lib/human/en.json` in the same change. NEVER use fallback strings. +- Never edit generated locale files directly—the automation pipeline syncs translations downstream. diff --git a/CHANGELOG.md b/CHANGELOG.md index 29970c1f8..359c00536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,67 @@ +# [1.39.0-develop.8](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.7...v1.39.0-develop.8) (2025-09-24) + + +### Bug Fixes + +* ditto filtering ([2fe3af3](https://github.com/WatWowMap/ReactMap/commit/2fe3af358bd848810e8d63b3b7e442f70ad78da7)) + +# [1.39.0-develop.7](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.6...v1.39.0-develop.7) (2025-09-24) + + +### Features + +* estimated shiny probability for pokemon ([#1133](https://github.com/WatWowMap/ReactMap/issues/1133)) ([d44a3df](https://github.com/WatWowMap/ReactMap/commit/d44a3df6834e3752a129a2fa5706526076cd9144)) + +# [1.39.0-develop.6](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.5...v1.39.0-develop.6) (2025-09-23) + + +### Bug Fixes + +* translation ([5a02a6b](https://github.com/WatWowMap/ReactMap/commit/5a02a6b81ca3c559dd2fea41a0b4c5a382560ed1)) + +# [1.39.0-develop.5](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.4...v1.39.0-develop.5) (2025-09-23) + + +### Bug Fixes + +* localization ([f02e8cd](https://github.com/WatWowMap/ReactMap/commit/f02e8cd927d76c9191e7fe6ea909f6eec9a4a729)) +* refinements to interaction between gyms/stops and routes ([8c0ba37](https://github.com/WatWowMap/ReactMap/commit/8c0ba372db29e53f2b74215494a8b170feab192a)) + +# [1.39.0-develop.4](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.3...v1.39.0-develop.4) (2025-09-23) + + +### Features + +* Compact Route View ([#1131](https://github.com/WatWowMap/ReactMap/issues/1131)) ([795976e](https://github.com/WatWowMap/ReactMap/commit/795976e2ea9f68ba54ad5495bbf81af2716b4920)) + +# [1.39.0-develop.3](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.2...v1.39.0-develop.3) (2025-09-22) + + +### Features + +* display actual battle bonus via stationed pokemon ([ba51f47](https://github.com/WatWowMap/ReactMap/commit/ba51f47d6c90dc659427148d4cc025ed6bc89950)) + +# [1.39.0-develop.2](https://github.com/WatWowMap/ReactMap/compare/v1.39.0-develop.1...v1.39.0-develop.2) (2025-09-16) + + +### Bug Fixes + +* skip updated filtering if shortcode is present ([b0cfe13](https://github.com/WatWowMap/ReactMap/commit/b0cfe13876b9d81e70ce44c997f4accb48943609)) + +# [1.39.0-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.38.1-develop.1...v1.39.0-develop.1) (2025-09-16) + + +### Features + +* route share code ([4ac76d0](https://github.com/WatWowMap/ReactMap/commit/4ac76d00d53eb06966a94a525541302340b638de)) + +## [1.38.1-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.38.0...v1.38.1-develop.1) (2025-09-15) + + +### Bug Fixes + +* relative timer not updating ([72b0a8c](https://github.com/WatWowMap/ReactMap/commit/72b0a8cfb3fab2ce897b02e8517bb9c324d0cc80)) + # [1.38.0](https://github.com/WatWowMap/ReactMap/compare/v1.37.0...v1.38.0) (2025-09-10) diff --git a/package.json b/package.json index ff50fcdea..4017ac4a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.38.0", + "version": "1.39.0-develop.8", "private": true, "description": "React based frontend map.", "license": "MIT", @@ -149,6 +149,7 @@ "i18next-http-backend": "2.5.2", "knex": "3.1.0", "leaflet": "1.9.4", + "leaflet-arrowheads": "^1.4.0", "leaflet.locatecontrol": "0.81.0", "lodash": "^4.17.21", "moment-timezone": "^0.5.43", diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 50693178a..c2e936cda 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -377,7 +377,8 @@ "with_ar": "With AR", "both": "Both", "without_ar": "Without AR", - "shiny_probability": "Shiny probability: <0/>", + "shiny_probability": "Shiny rate: <0/>", + "shiny_sample": "{{percentage}}%: {{shiny}} shiny/{{checks}} checks since {{date}}", "exclude_quest_multi": "Exclude {{reward}}", "cluster_limit_0": "{{variable_0}} limit ({{variable_1}}) has been hit", "cluster_limit_1": "Please zoom in or narrow your filters", @@ -660,7 +661,13 @@ "version": "Version", "route_tags": "Route Tags", "routes": "Routes", + "compact_route_view": "Compact Route View", "route_type": "Route Type", + "route_short_code": "Route Share Code: ", + "route_anchor_count_one": "{{count}} Route", + "route_anchor_count_other": "{{count}} Routes", + "shortcode_copied_to_clipboard": "Code copied to clipboard!", + "copy_failed": "Copy failed", "routes_subtitle": "View in game routes and relevant information about them on the map", "description": "Description", "additional_info": "Additional Info", diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index e6db2b943..685a9343c 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -41,6 +41,12 @@ export interface PokemonDisplay { location_card: number } +export interface PokemonShinyStats { + shiny_seen: number + encounters_seen: number + since_date?: string +} + export interface Defender extends PokemonDisplay { pokemon_id: number deployed_ms: number @@ -223,7 +229,6 @@ export interface Pokemon { costume: number gender: Gender display_pokemon_id: number - ditto_form: number weight: number height: number size: number diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 76659e940..e4211fdd9 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -46,6 +46,8 @@ export interface DbContext { hasShowcaseForm: boolean hasShowcaseType: boolean hasStationedGmax: boolean + hasPokemonShinyStats?: boolean + connection?: number } export interface ExpressUser extends User { diff --git a/packages/vite-plugins/package.json b/packages/vite-plugins/package.json index dd3f39b13..5be7a51cd 100644 --- a/packages/vite-plugins/package.json +++ b/packages/vite-plugins/package.json @@ -16,6 +16,6 @@ "@rm/logger": "*" }, "devDependencies": { - "vite": "^6.2.6" + "vite": "^6.3.6" } } diff --git a/server/src/filters/pokemon/Backend.js b/server/src/filters/pokemon/Backend.js index c305ce660..eb8d1e7c9 100644 --- a/server/src/filters/pokemon/Backend.js +++ b/server/src/filters/pokemon/Backend.js @@ -430,16 +430,9 @@ class PkmnBackend { expire_timestamp_verified: !!pokemon.expire_timestamp_verified, updated: pokemon.updated, display_pokemon_id: pokemon.display_pokemon_id, - ditto_form: pokemon.ditto_form, seen_type: pokemon.seen_type, changed: !!pokemon.changed, } - if (result.pokemon_id === 132 && !result.ditto_form) { - result.ditto_form = result.form - result.form = - state.event.masterfile.pokemon[result.display_pokemon_id] - ?.defaultFormId || 0 - } if (!result.seen_type) { if (result.spawn_id === null) { result.seen_type = result.pokestop_id ? 'nearby_stop' : 'nearby_cell' diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index f9333a3c3..a0b4705bf 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -24,6 +24,14 @@ const resolvers = { JSON: GraphQLJSON, Query: { available: (_, _args, { Event, Db, perms }) => { + const supportsShinyStats = Array.isArray(Db.models?.Pokemon) + ? Db.models.Pokemon.some(({ SubModel, ...ctx }) => + typeof SubModel.supportsShinyStats === 'function' + ? SubModel.supportsShinyStats(ctx) + : false, + ) + : false + const data = { questConditions: perms.quests ? Db.questConditions : {}, masterfile: { ...Event.masterfile, invasions: Event.invasions }, @@ -36,6 +44,7 @@ const resolvers = { ...config.getSafe('icons'), styles: Event.uicons, }, + supportsShinyStats, } return data }, @@ -269,6 +278,23 @@ const resolvers = { } return {} }, + pokemonShinyStats: async (_, args, { perms, Db }) => { + if (!perms?.pokemon) { + return null + } + const sources = Db.models?.Pokemon + if (!Array.isArray(sources)) { + return null + } + const results = await Promise.all( + sources.map(({ SubModel, ...ctx }) => + typeof SubModel.getShinyStats === 'function' + ? SubModel.getShinyStats(perms, args, ctx) + : Promise.resolve(null), + ), + ) + return results.find(Boolean) || null + }, portals: (_, args, { perms, Db }) => { if (perms?.portals) { return Db.query('Portal', 'getAll', perms, args) diff --git a/server/src/graphql/typeDefs/index.graphql b/server/src/graphql/typeDefs/index.graphql index eff77b33b..e2bd1aaa2 100644 --- a/server/src/graphql/typeDefs/index.graphql +++ b/server/src/graphql/typeDefs/index.graphql @@ -50,6 +50,7 @@ type Query { filters: JSON ): [Pokemon] pokemonSingle(id: ID, perm: String): Pokemon + pokemonShinyStats(pokemon_id: Int!, form: Int): PokemonShinyStats portals( minLat: Float maxLat: Float diff --git a/server/src/graphql/typeDefs/map.graphql b/server/src/graphql/typeDefs/map.graphql index 86b3a12b8..4621b1c95 100644 --- a/server/src/graphql/typeDefs/map.graphql +++ b/server/src/graphql/typeDefs/map.graphql @@ -4,6 +4,7 @@ type MapData { questConditions: JSON icons: JSON audio: JSON + supportsShinyStats: Boolean } type Badge { diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index 3210e55ce..b28474aef 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -148,6 +148,12 @@ type Pokestop { hasShowcase: Boolean } +type PokemonShinyStats { + shiny_seen: Int + encounters_seen: Int + since_date: String +} + type Pokemon { id: ID encounter_id: Int @@ -159,7 +165,6 @@ type Pokemon { costume: Int gender: Int display_pokemon_id: Int - ditto_form: Int weight: Float height: Float size: Float @@ -246,6 +251,7 @@ type Waypoint { type Route { id: ID name: String + shortcode: String description: String distance_meters: Int duration_seconds: Int diff --git a/server/src/models/Pokemon.js b/server/src/models/Pokemon.js index e04a12388..55b427bde 100644 --- a/server/src/models/Pokemon.js +++ b/server/src/models/Pokemon.js @@ -23,6 +23,11 @@ const { const { PkmnBackend } = require('../filters/pokemon/Backend') const { state } = require('../services/state') +const DITTO_ID = 132 + +const getPokemonFilterKey = (pokemonId, form) => + pokemonId === DITTO_ID ? `${DITTO_ID}-0` : `${pokemonId}-${form}` + class Pokemon extends Model { static get tableName() { return 'pokemon' @@ -52,7 +57,6 @@ class Pokemon extends Model { 'pokemon.gender', 'pokemon.costume', 'pokemon_display.pokemon AS display_pokemon_id', - 'pokemon_display.form AS ditto_form', 'weather_boosted_condition AS weather', raw('IF(calc_endminsec IS NOT NULL, 1, NULL)').as( 'expire_timestamp_verified', @@ -296,8 +300,7 @@ class Pokemon extends Model { // form checker for (let i = 0; i < results.length; i += 1) { const pkmn = results[i] - const id = - pkmn.pokemon_id === 132 ? '132-0' : `${pkmn.pokemon_id}-${pkmn.form}` + const id = getPokemonFilterKey(pkmn.pokemon_id, pkmn.form) const filter = filterMap[id] || globalFilter let noPvp = true @@ -374,12 +377,14 @@ class Pokemon extends Model { for (let i = 0; i < pvpResults.length; i += 1) { const pkmn = pvpResults[i] const filter = - filterMap[`${pkmn.pokemon_id}-${pkmn.form}`] || globalFilter + filterMap[getPokemonFilterKey(pkmn.pokemon_id, pkmn.form)] || + globalFilter const result = filter.build(pkmn) if (filter.valid(result)) { finalResults.push(result) } } + return finalResults } @@ -436,6 +441,210 @@ class Pokemon extends Model { return results || [] } + /** + * @param {number | null | undefined} preferredConnection + * @returns {import('knex').Knex | null} + */ + static getStatsKnex(preferredConnection = null) { + return this.getStatsHandle(preferredConnection)?.knex ?? null + } + + /** + * @param {number | null | undefined} preferredConnection + * @returns {{ + * knex: import('knex').Knex, + * connection: number, + * spawnSource?: import('@rm/types').DbContext & { connection: number }, + * } | null} + */ + static getStatsHandle(preferredConnection = null) { + const dbManager = state.db + if (!dbManager) return null + const { connections } = dbManager + if (!connections?.length) return null + + const spawnSources = dbManager.models?.Spawnpoint + + const getSpawnByConnection = (connection) => + Array.isArray(spawnSources) + ? spawnSources.find((source) => source.connection === connection) + : undefined + + const hasConnection = (connection) => + typeof connection === 'number' && Boolean(connections?.[connection]) + + if (!Array.isArray(spawnSources) || !spawnSources.length) { + return null + } + + let candidate = null + + if (typeof preferredConnection === 'number') { + candidate = spawnSources.find( + (source) => + source.connection === preferredConnection && + source.hasPokemonShinyStats && + hasConnection(source.connection), + ) + } + + if (!candidate) { + candidate = spawnSources.find( + (source) => + source.hasPokemonShinyStats && hasConnection(source.connection), + ) + } + + if (!candidate) { + return null + } + + const knexInstance = connections?.[candidate.connection] + if (!knexInstance) { + return null + } + + return { + knex: knexInstance, + connection: candidate.connection, + spawnSource: getSpawnByConnection(candidate.connection), + } + } + + /** + * @param {import("@rm/types").DbContext} ctx + * @returns {boolean} + */ + static supportsShinyStats(ctx) { + const statsHandle = this.getStatsHandle(ctx?.connection) + if (!statsHandle?.knex) { + return false + } + const flag = + typeof statsHandle.spawnSource?.hasPokemonShinyStats === 'boolean' + ? statsHandle.spawnSource.hasPokemonShinyStats + : typeof ctx?.hasPokemonShinyStats === 'boolean' + ? ctx.hasPokemonShinyStats + : false + return flag + } + + /** + * @param {string[]} keys + * @param {import('knex').Knex | null | undefined} [statsKnex] + * @param {number | null | undefined} [preferredConnection] + * @returns {Promise>} + */ + static async fetchShinyStats( + keys, + statsKnex = null, + preferredConnection = null, + ) { + if (!keys.length) return new Map() + + let knexInstance = statsKnex || null + if (!knexInstance) { + const statsHandle = this.getStatsHandle(preferredConnection) + knexInstance = statsHandle?.knex ?? null + } + if (!knexInstance) { + try { + knexInstance = this.knex() + } catch (e) { + knexInstance = null + } + } + if (!knexInstance) return new Map() + + const pairs = keys + .map((key) => key.split('-')) + .map(([pokemonId, formId]) => { + const parsedPokemon = Number.parseInt(pokemonId, 10) + if (Number.isNaN(parsedPokemon)) return null + const parsedForm = Number.parseInt(formId, 10) + return [parsedPokemon, Number.isNaN(parsedForm) ? 0 : parsedForm] + }) + .filter(Boolean) + + if (!pairs.length) return new Map() + + const whereClause = pairs + .map(() => '(pokemon_id = ? AND COALESCE(form_id, 0) = ?)') + .join(' OR ') + const bindings = pairs.flatMap(([pokemonId, formId]) => [pokemonId, formId]) + const query = ` + SELECT + pokemon_id, + COALESCE(form_id, 0) AS form_id, + date, + SUM(count) AS shiny, + SUM(total) AS checks + FROM pokemon_shiny_stats + WHERE area = 'world' + AND fence = 'world' + AND (${whereClause}) + AND date >= CURRENT_DATE - INTERVAL 7 DAY + GROUP BY pokemon_id, form_id, date + ORDER BY pokemon_id, form_id, date DESC + ` + + const [rows] = await knexInstance.raw(query, bindings) + + const grouped = new Map() + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i] + const key = `${row.pokemon_id}-${row.form_id ?? 0}` + const entry = grouped.get(key) + const rowDate = + row.date instanceof Date + ? row.date.toISOString().slice(0, 10) + : `${row.date}` + const payload = { + shiny: Number(row.shiny) || 0, + checks: Number(row.checks) || 0, + date: rowDate, + } + if (entry) { + entry.push(payload) + } else { + grouped.set(key, [payload]) + } + } + + const statsMap = new Map() + const today = new Date() + today.setHours(0, 0, 0, 0) + const cutoff = new Date(today) + cutoff.setDate(cutoff.getDate() - 1) + const cutoffStr = cutoff.toISOString().slice(0, 10) + + grouped.forEach((entries, key) => { + let shinySum = 0 + let checkSum = 0 + let sinceDate = null + for (let i = 0; i < entries.length; i += 1) { + const { shiny, checks, date } = entries[i] + const includeRecent = date >= cutoffStr + // 20000 checks would give >99% of distinguishing even 1/512 from 1/256 + if (!includeRecent && checkSum >= 20000) { + break + } + shinySum += shiny + checkSum += checks + if (!sinceDate || date < sinceDate) { + sinceDate = date + } + } + statsMap.set(key, { + shiny_seen: shinySum, + encounters_seen: checkSum, + since_date: sinceDate, + }) + }) + + return statsMap + } + /** * @param {import("@rm/types").Permissions} perms * @param {object} args @@ -512,30 +721,71 @@ class Pokemon extends Model { secret, httpAuth, ) - return results - .filter( - (item) => - !mem || - filterRTree(item, perms.areaRestrictions, args.filters.onlyAreas), - ) + const filtered = results.filter( + (item) => + !mem || + filterRTree(item, perms.areaRestrictions, args.filters.onlyAreas), + ) + + const built = filtered .map((item) => { const filter = - filterMap[ - item.pokemon_id === 132 - ? '132-0' - : `${item.pokemon_id}-${item.form}` - ] || globalFilter + filterMap[getPokemonFilterKey(item.pokemon_id, item.form)] || + globalFilter return filter.build(item) }) .filter((pkmn) => { const filter = - filterMap[ - pkmn.pokemon_id === 132 - ? '132-0' - : `${pkmn.pokemon_id}-${pkmn.form}` - ] || globalFilter + filterMap[getPokemonFilterKey(pkmn.pokemon_id, pkmn.form)] || + globalFilter return filter.valid(pkmn) }) + + return built + } + + /** + * @param {import("@rm/types").Permissions} _perms + * @param {{ pokemon_id: number, form?: number | null }} args + * @param {import("@rm/types").DbContext} ctx + * @returns {Promise} + */ + static async getShinyStats(_perms, args, ctx) { + const statsHandle = this.getStatsHandle(ctx?.connection) + if (!statsHandle?.knex) { + return null + } + const hasStats = + typeof statsHandle.spawnSource?.hasPokemonShinyStats === 'boolean' + ? statsHandle.spawnSource.hasPokemonShinyStats + : typeof ctx?.hasPokemonShinyStats === 'boolean' + ? ctx.hasPokemonShinyStats + : false + if (!hasStats) { + return null + } + const pokemonId = Number.parseInt(`${args.pokemon_id}`, 10) + if (Number.isNaN(pokemonId)) { + return null + } + const formId = Number.parseInt(`${args.form ?? 0}`, 10) + const key = `${pokemonId}-${Number.isNaN(formId) ? 0 : formId}` + try { + const stats = await this.fetchShinyStats( + [key], + statsHandle.knex, + statsHandle.connection, + ) + return stats.get(key) || null + } catch (e) { + log.error(TAGS.pokemon, 'Failed to fetch shiny stats', e) + if (e?.code === 'ER_NO_SUCH_TABLE') { + if (statsHandle.spawnSource) { + statsHandle.spawnSource.hasPokemonShinyStats = false + } + } + return null + } } /** @@ -564,7 +814,7 @@ class Pokemon extends Model { httpAuth, ) available.forEach((pkmn) => { - if (pkmn.id === 132) pkmn.form = 0 + if (pkmn.id === DITTO_ID) pkmn.form = 0 }) return { available: available.map((pkmn) => `${pkmn.id}-${pkmn.form}`), diff --git a/server/src/models/Route.js b/server/src/models/Route.js index b3bc3e1db..e4d696a40 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -38,7 +38,7 @@ class Route extends Model { * @param {import("@rm/types").DbContext} ctx * @returns {Promise} */ - static async getAll(perms, args, { isMad }) { + static async getAll(perms, args, { isMad, hasShortcode }) { const { areaRestrictions } = perms const { onlyAreas, onlyDistance } = args.filters const ts = @@ -56,21 +56,31 @@ class Route extends Model { .whereBetween(startLatitude, [args.minLat, args.maxLat]) .andWhereBetween(startLongitude, [args.minLon, args.maxLon]) .andWhereBetween(distanceMeters, distanceInMeters) - .andWhere( - isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', - '>', - ts, - ) + .andWhere((builder) => { + builder.where( + isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', + '>', + ts, + ) + if (hasShortcode) { + builder.orWhere('shortcode', '<>', '') + } + }) .union((qb) => { qb.select(isMad ? GET_MAD_ALL_SELECT : GET_ALL_SELECT) .whereBetween(endLatitude, [args.minLat, args.maxLat]) .andWhereBetween(endLongitude, [args.minLon, args.maxLon]) .andWhereBetween(distanceMeters, distanceInMeters) - .andWhere( - isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', - '>', - ts, - ) + .andWhere((builder) => { + builder.where( + isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', + '>', + ts, + ) + if (hasShortcode) { + builder.orWhere('shortcode', '<>', '') + } + }) .from('route') getAreaSql(qb, areaRestrictions, onlyAreas, isMad, 'route_end') }) @@ -96,7 +106,7 @@ class Route extends Model { * @param {number} id * @param {import('@rm/types').DbContext} ctx */ - static async getOne(id, { isMad }) { + static async getOne(id, { isMad, hasShortcode }) { /** @type {import('@rm/types').FullRoute} */ const result = isMad ? await this.query() @@ -121,6 +131,7 @@ class Route extends Model { type: 'type', version: 'version', waypoints: 'waypoints', + ...(hasShortcode && { shortcode: 'shortcode' }), }) .select(raw('UNIX_TIMESTAMP(last_updated)').as('updated')) .findOne({ route_id: id }) diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 8e9f4d9be..ee1915074 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -181,6 +181,16 @@ class DbManager extends Logger { const [polygon] = await schema('nests') .columnInfo() .then((columns) => ['polygon' in columns]) + const [hasShortcode] = await schema('route') + .columnInfo() + .then((columns) => ['shortcode' in columns]) + + let hasPokemonShinyStats + try { + hasPokemonShinyStats = await schema.schema.hasTable('pokemon_shiny_stats') + } catch (e) { + hasPokemonShinyStats = false + } return { isMad, @@ -203,6 +213,8 @@ class DbManager extends Logger { hasShowcaseForm, hasShowcaseType, hasStationedGmax, + hasShortcode, + hasPokemonShinyStats, } } @@ -223,6 +235,7 @@ class DbManager extends Logger { // Add support for HTTP authentication httpAuth: this.endpoints[i].httpAuth, pvpV2: true, + hasPokemonShinyStats: false, } Object.entries(this.models).forEach(([category, sources]) => { diff --git a/src/assets/css/main.css b/src/assets/css/main.css index e231a06eb..72c7badcd 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -88,6 +88,31 @@ body { background-color: #ff4b4d; } +.route-count-wrapper { + display: inline-flex; + transform: translate(10px, -12px); +} + +.route-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + padding: 0 4px; + border-radius: 10px; + background: #ff4b4d; + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 16px; + border: 1px solid #ffffff; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.4); +} + +.route-count-badge--destination { + background: #2196f3; +} + .invasion-exists { border: 4px solid rgb(141, 13, 13); } diff --git a/src/features/drawer/Routes.jsx b/src/features/drawer/Routes.jsx index 008238457..19b69afb6 100644 --- a/src/features/drawer/Routes.jsx +++ b/src/features/drawer/Routes.jsx @@ -1,6 +1,9 @@ // @ts-check import * as React from 'react' import ListItem from '@mui/material/ListItem' +import ListItemText from '@mui/material/ListItemText' +import Switch from '@mui/material/Switch' +import { useTranslation } from 'react-i18next' import { useMemory } from '@store/useMemory' import { useStorage, useDeepStore } from '@store/useStorage' @@ -9,8 +12,13 @@ import { SliderTile } from '@components/inputs/SliderTile' import { CollapsibleItem } from './components/CollapsibleItem' const RouteSlider = () => { + const { t } = useTranslation() const enabled = useStorage((s) => !!s.filters?.routes?.enabled) const [filters, setFilters] = useDeepStore('filters.routes.distance') + const [compactView, setCompactView] = useDeepStore( + 'userSettings.routes.compactView', + true, + ) const baseDistance = useMemory.getState().filters?.routes?.distance /** @type {import('@rm/types').RMSlider} */ @@ -31,6 +39,17 @@ const RouteSlider = () => { return ( + setCompactView(checked)} + checked={compactView !== false} + /> + } + > + + { const BaseGymTile = (gym) => { const [markerRef, setMarkerRef] = React.useState(null) const [stateChange, setStateChange] = React.useState(false) + const hasRoutes = useRouteStore( + React.useCallback( + (state) => !!resolveRoutePoiKey(state.poiIndex, gym.id, gym.lat, gym.lon), + [gym.id, gym.lat, gym.lon], + ), + ) + const selectPoi = useRouteStore((s) => s.selectPoi) const [ hasRaid, @@ -179,6 +187,13 @@ const BaseGymTile = (gym) => { raidIconSize, ...gym, })} + eventHandlers={{ + click: () => { + if (hasRoutes) { + selectPoi(gym.id, gym.lat, gym.lon) + } + }, + }} > s.featureFlags.supportsShinyStats) + const shinyKey = React.useMemo( + () => `${pokemon.pokemon_id}-${pokemon.form ?? 0}`, + [pokemon.pokemon_id, pokemon.form], + ) + const [shinyStats, setShinyStats] = React.useState(null) + const pendingShinyKey = React.useRef(null) + const [loadShinyStats] = useLazyQuery(GET_POKEMON_SHINY_STATS) + + React.useEffect(() => { + setShinyStats(null) + pendingShinyKey.current = null + }, [shinyKey]) + + React.useEffect(() => { + if (!supportsShinyStats) { + setShinyStats(null) + pendingShinyKey.current = null + } + }, [supportsShinyStats]) + + React.useEffect(() => { + if (!supportsShinyStats) { + pendingShinyKey.current = null + return + } + if (shinyStats || !pokemon.pokemon_id) { + return + } + if (pendingShinyKey.current === shinyKey) { + return + } + let isActive = true + pendingShinyKey.current = shinyKey + loadShinyStats({ + variables: { + pokemonId: pokemon.pokemon_id, + form: pokemon.form ?? 0, + }, + fetchPolicy: 'cache-first', + }) + .then(({ data }) => { + if (!isActive || pendingShinyKey.current !== shinyKey) { + return + } + if (data?.pokemonShinyStats) { + setShinyStats(data.pokemonShinyStats) + } + }) + .catch(() => { + if (isActive && pendingShinyKey.current === shinyKey) { + pendingShinyKey.current = null + } + }) + return () => { + isActive = false + } + }, [supportsShinyStats, shinyStats, shinyKey, loadShinyStats]) + useAnalytics( 'Popup', `ID: ${pokemon.pokemon_id} IV: ${pokemon.iv}% PVP: #${pokemon.bestPvp}`, @@ -123,6 +185,7 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { timeOfDay={timeOfDay} t={t} /> +