diff --git a/config/default.json b/config/default.json index 21068c9ba..fdbd7af04 100644 --- a/config/default.json +++ b/config/default.json @@ -28,6 +28,7 @@ "polling": { "devices": 10, "gyms": 10, + "hyperlocal": 20, "nests": 300, "pokemon": 20, "pokestops": 300, @@ -159,6 +160,7 @@ "pokestops", "stations", "pokemon", + "hyperlocal", "routes", "wayfarer", "s2cells", @@ -489,6 +491,9 @@ "gymBadges": false, "baseGymSlotAmounts": [1, 2, 3, 4, 5, 6] }, + "hyperlocal": { + "enabled": true + }, "nests": { "enabled": false, "polygons": false, @@ -764,6 +769,11 @@ "trialPeriodEligible": false, "roles": [] }, + "hyperlocal": { + "enabled": true, + "trialPeriodEligible": false, + "roles": [] + }, "raids": { "enabled": true, "trialPeriodEligible": false, diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 66f71734a..e47262d4a 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -820,5 +820,7 @@ "filters": "Filters", "active": "Active", "inactive": "Inactive", - "bread_time_window": "You can take on Max Battles between 6AM and 9PM." + "bread_time_window": "You can take on Max Battles between 6AM and 9PM.", + "hyperlocal": "Bonus Regions", + "radius": "Radius" } diff --git a/server/src/filters/builder/base.js b/server/src/filters/builder/base.js index a800ba163..29e805c4f 100644 --- a/server/src/filters/builder/base.js +++ b/server/src/filters/builder/base.js @@ -167,6 +167,16 @@ function buildDefaultFilters(perms) { }, } : undefined, + hyperlocal: + perms.hyperlocal && state.db.models.Hyperlocal + ? { + enabled: defaultFilters.hyperlocal.enabled, + standard: new BaseFilter(), + filter: { + global: new BaseFilter(), + }, + } + : undefined, portals: perms.portals && state.db.models.Portal ? { diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index f9333a3c3..640851405 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -293,6 +293,12 @@ const resolvers = { } return [] }, + hyperlocal: (_, args, { perms, Db }) => { + if (perms?.hyperlocal) { + return Db.query('Hyperlocal', 'getAll', perms, args) + } + return [] + }, s2cells: (_, args, { perms }) => { if (perms?.s2cells) { const { onlyCells } = args.filters diff --git a/server/src/graphql/typeDefs/index.graphql b/server/src/graphql/typeDefs/index.graphql index eff77b33b..96dc71576 100644 --- a/server/src/graphql/typeDefs/index.graphql +++ b/server/src/graphql/typeDefs/index.graphql @@ -148,6 +148,13 @@ type Query { maxLon: Float filters: JSON ): [Route] + hyperlocal( + minLat: Float + maxLat: Float + minLon: Float + maxLon: Float + filters: JSON + ): [Hyperlocal] webhook(category: String, status: String): Poracle webhookAreas: [WebhookAreaGroups] webhookGeojson: JSON diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index 3210e55ce..b9b6ab511 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -267,6 +267,18 @@ type Route { waypoints: [Waypoint] } +type Hyperlocal { + id: String + experiment_id: Int + start_ms: Float + end_ms: Float + lat: Float + lon: Float + radius_m: Float + challenge_bonus_key: String + updated_ms: Float +} + type Station { id: ID name: String diff --git a/server/src/models/Hyperlocal.js b/server/src/models/Hyperlocal.js new file mode 100644 index 000000000..41189e06b --- /dev/null +++ b/server/src/models/Hyperlocal.js @@ -0,0 +1,53 @@ +// @ts-check +const { Model } = require('objection') + +class Hyperlocal extends Model { + static get tableName() { + return 'hyperlocal' + } + + static get idColumn() { + return ['experiment_id', 'lat', 'lon'] + } + + /** + * Returns all hyperlocal records within bounds + * @param {import('@rm/types').Permissions} perms + * @param {object} args + * @returns {Promise} + */ + static async getAll(perms, { minLat, maxLat, minLon, maxLon }) { + const query = this.query() + .whereBetween('lat', [minLat, maxLat]) + .whereBetween('lon', [minLon, maxLon]) + + // Only show active bonus regions (not expired) + const now = Date.now() + query.where('end_ms', '>', now) + + const results = await query + + // Add unique id for React keys by combining the composite primary key + return results.map((hyperlocal) => ({ + ...hyperlocal, + id: `${hyperlocal.experiment_id}_${hyperlocal.lat}_${hyperlocal.lon}`, + })) + } + + /** + * Returns a single hyperlocal record + * @param {number} experimentId + * @param {number} lat + * @param {number} lon + * @returns {Promise} + */ + static async getOne(experimentId, lat, lon) { + return this.query() + .where('experiment_id', experimentId) + .where('lat', lat) + .where('lon', lon) + .first() + } +} + +module.exports = { Hyperlocal } diff --git a/server/src/models/index.js b/server/src/models/index.js index 3dd967fae..1c99d51ff 100644 --- a/server/src/models/index.js +++ b/server/src/models/index.js @@ -3,6 +3,7 @@ const { Backup } = require('./Backup') const { Badge } = require('./Badge') const { Device } = require('./Device') const { Gym } = require('./Gym') +const { Hyperlocal } = require('./Hyperlocal') const { Nest } = require('./Nest') const { NestSubmission } = require('./NestSubmission') const { Pokestop } = require('./Pokestop') @@ -28,6 +29,7 @@ const rmModels = { const scannerModels = { Device, Gym, + Hyperlocal, Nest, Pokestop, Pokemon, diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 539cf936e..6dc5147d8 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -16,6 +16,7 @@ class DbManager extends Logger { static validModels = /** @type {const} */ ([ 'Device', 'Gym', + 'Hyperlocal', 'Nest', 'Pokestop', 'Pokemon', diff --git a/server/src/ui/drawer.js b/server/src/ui/drawer.js index 031773a78..d93cefa3a 100644 --- a/server/src/ui/drawer.js +++ b/server/src/ui/drawer.js @@ -152,6 +152,10 @@ function drawer(req, perms) { }, } : BLOCKED, + hyperlocal: + perms.hyperlocal && state.db.models.Hyperlocal + ? { enabled: true } + : BLOCKED, routes: perms.routes && state.db.models.Route ? { enabled: true } : BLOCKED, wayfarer: perms.portals || perms.submissionCells diff --git a/src/features/drawer/Extras.jsx b/src/features/drawer/Extras.jsx index 9ada71fcc..6f18172c1 100644 --- a/src/features/drawer/Extras.jsx +++ b/src/features/drawer/Extras.jsx @@ -5,6 +5,7 @@ import { PokestopDrawer } from './pokestops' import { GymDrawer } from './gyms' import { NestsDrawer } from './nests' import { RoutesDrawer } from './Routes' +import { HyperlocalDrawer } from './Hyperlocal' import { WayfarerDrawer } from './Wayfarer' import { S2CellsDrawer } from './S2Cells' import { AdminDrawer } from './Admin' @@ -24,6 +25,8 @@ function ExtrasComponent({ category, subItem }) { return case 'routes': return + case 'hyperlocal': + return case 'admin': return case 'stations': diff --git a/src/features/drawer/Hyperlocal.jsx b/src/features/drawer/Hyperlocal.jsx new file mode 100644 index 000000000..0480a6296 --- /dev/null +++ b/src/features/drawer/Hyperlocal.jsx @@ -0,0 +1,13 @@ +// @ts-check +import * as React from 'react' + +import { CollapsibleItem } from './components/CollapsibleItem' + +function BaseHyperlocalDrawer({ subItem }) { + return subItem === 'enabled' ? : null +} + +export const HyperlocalDrawer = React.memo( + BaseHyperlocalDrawer, + (prev, next) => prev.subItem === next.subItem, +) diff --git a/src/features/hyperlocal/HyperlocalPopup.jsx b/src/features/hyperlocal/HyperlocalPopup.jsx new file mode 100644 index 000000000..491770903 --- /dev/null +++ b/src/features/hyperlocal/HyperlocalPopup.jsx @@ -0,0 +1,79 @@ +// @ts-check +import * as React from 'react' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'react-i18next' + +import { Timer } from '@components/popups/Timer' +import { formatInterval } from '@utils/formatInterval' + +/** + * @param {{ hyperlocal: import('@rm/types').Hyperlocal, ts?: number }} props + */ +export function HyperlocalPopup({ + hyperlocal, + ts = Math.floor(Date.now() / 1000), +}) { + const { t, i18n } = useTranslation() + + // Format times in h:m:s AM/PM format + const formatTime = React.useCallback( + (timestamp) => { + if (!timestamp) return null + const formatter = new Intl.DateTimeFormat(i18n.language, { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }) + return formatter.format(timestamp * 1000) + }, + [i18n.language], + ) + + // Calculate time ago for last seen + const timeAgo = React.useMemo(() => { + if (!hyperlocal.updated_ms) return null + const updatedSeconds = Math.floor(hyperlocal.updated_ms / 1000) + const diff = ts - updatedSeconds + const { str } = formatInterval(diff * 1000) + return str + }, [hyperlocal.updated_ms, ts]) + + const startTime = hyperlocal.start_ms + ? Math.floor(hyperlocal.start_ms / 1000) + : null + const endTime = hyperlocal.end_ms + ? Math.floor(hyperlocal.end_ms / 1000) + : null + const lastSeenTime = hyperlocal.updated_ms + ? Math.floor(hyperlocal.updated_ms / 1000) + : null + + return ( +
+ + {`${t(hyperlocal.challenge_bonus_key)} (${hyperlocal.experiment_id})`} + + + + {t('starts')}: {formatTime(startTime)} + + + + {t('ends')}: {formatTime(endTime)} + + + + + + + + Radius: {hyperlocal.radius_m}m + + + + {t('last_seen')}: {formatTime(lastSeenTime)}{' '} + {timeAgo && `(${timeAgo} ago)`} + +
+ ) +} diff --git a/src/features/hyperlocal/HyperlocalTile.jsx b/src/features/hyperlocal/HyperlocalTile.jsx new file mode 100644 index 000000000..6ef4c9f03 --- /dev/null +++ b/src/features/hyperlocal/HyperlocalTile.jsx @@ -0,0 +1,31 @@ +// @ts-check +import * as React from 'react' +import { Circle, Popup } from 'react-leaflet' + +import { HyperlocalPopup } from './HyperlocalPopup' +import { hyperlocalMarker } from './hyperlocalMarker' + +/** + * @param {import('@rm/types').Hyperlocal & { lat: number, lon: number }} props + */ +const BaseHyperlocalTile = (props) => { + const markerProps = hyperlocalMarker(props) + + return ( + + + + + + ) +} + +export const HyperlocalTile = React.memo( + BaseHyperlocalTile, + (prev, next) => prev.updated_ms === next.updated_ms, +) diff --git a/src/features/hyperlocal/hyperlocalMarker.js b/src/features/hyperlocal/hyperlocalMarker.js new file mode 100644 index 000000000..810d1d13c --- /dev/null +++ b/src/features/hyperlocal/hyperlocalMarker.js @@ -0,0 +1,23 @@ +// @ts-check + +/** + * Creates a circle marker for hyperlocal bonus regions + * @param {object} hyperlocal - The hyperlocal data object + * @returns {object} Circle marker configuration + */ +export function hyperlocalMarker(hyperlocal) { + const { lat, lon, radius_m } = hyperlocal + + return { + center: [lat, lon], + radius: radius_m, + pathOptions: { + color: '#FFD700', // Gold color for bonus regions + fillColor: '#FFD700', + fillOpacity: 0.3, + weight: 2, + opacity: 0.7, + }, + className: 'hyperlocal-circle', + } +} diff --git a/src/features/hyperlocal/index.js b/src/features/hyperlocal/index.js new file mode 100644 index 000000000..da772ade0 --- /dev/null +++ b/src/features/hyperlocal/index.js @@ -0,0 +1,5 @@ +// @ts-check + +export * from './hyperlocalMarker' +export * from './HyperlocalPopup' +export * from './HyperlocalTile' diff --git a/src/pages/map/components/QueryData.jsx b/src/pages/map/components/QueryData.jsx index 3ecf01d17..515bda50c 100644 --- a/src/pages/map/components/QueryData.jsx +++ b/src/pages/map/components/QueryData.jsx @@ -169,7 +169,18 @@ function QueryData({ category, timeout }) { ? [] : (data || previousData || { [category]: [] })[category] - if (!returnData) { + // Add unique IDs to hyperlocal data for React keys + const processedData = React.useMemo(() => { + if (category === 'hyperlocal' && returnData) { + return returnData.map((item) => ({ + ...item, + id: `${item.experiment_id}_${item.lat}_${item.lon}`, + })) + } + return returnData + }, [returnData, category]) + + if (!processedData) { return error && process.env.NODE_ENV === 'development' ? ( - {returnData.map((each) => { + {processedData.map((each) => { if (!hideList.has(each.id)) { return } diff --git a/src/pages/map/tileObject.js b/src/pages/map/tileObject.js index 71ccdcc5e..6afb9728a 100644 --- a/src/pages/map/tileObject.js +++ b/src/pages/map/tileObject.js @@ -7,6 +7,7 @@ import { DeviceTile as devices } from '@features/device' import { NestTile as nests } from '@features/nest' import { PortalTile as portals } from '@features/portal' import { RouteTile as routes } from '@features/route' +import { HyperlocalTile as hyperlocal } from '@features/hyperlocal' import { WeatherTile as weather } from '@features/weather' import { SpawnpointTile as spawnpoints } from '@features/spawnpoint' import { ScanCellTile as scanCells } from '@features/scanCell' @@ -29,5 +30,6 @@ export const TILES = { weather, s2cells, routes, + hyperlocal, stations, } diff --git a/src/services/queries/hyperlocal.js b/src/services/queries/hyperlocal.js new file mode 100644 index 000000000..e18e8a995 --- /dev/null +++ b/src/services/queries/hyperlocal.js @@ -0,0 +1,29 @@ +// @ts-check +import { gql } from '@apollo/client' + +export const GET_HYPERLOCAL = gql` + query Hyperlocal( + $minLat: Float! + $maxLat: Float! + $minLon: Float! + $maxLon: Float! + $filters: JSON! + ) { + hyperlocal( + minLat: $minLat + maxLat: $maxLat + minLon: $minLon + maxLon: $maxLon + filters: $filters + ) { + experiment_id + start_ms + end_ms + lat + lon + radius_m + challenge_bonus_key + updated_ms + } + } +` diff --git a/src/services/queries/index.js b/src/services/queries/index.js index c0bd1eaa9..9034aace4 100644 --- a/src/services/queries/index.js +++ b/src/services/queries/index.js @@ -18,6 +18,7 @@ import { GET_ONE_NEST, GET_ALL_NESTS, NEST_SUBMISSION } from './nest' import { GET_ALL_SCAN_AREAS, GET_SCAN_AREAS_MENU } from './scanAreas' import { S2_CELLS } from './s2cell' import { GET_ROUTE, GET_ROUTES } from './route' +import { GET_HYPERLOCAL } from './hyperlocal' export class Query { /** @@ -189,4 +190,8 @@ export class Query { if (method === 'getOne') return GET_ROUTE return GET_ROUTES } + + static hyperlocal() { + return GET_HYPERLOCAL + } }