diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index 125b8a75f..a8ded4b95 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -40,6 +40,18 @@ export interface PokemonDisplay { location_card: number } +export interface Defender extends PokemonDisplay { + pokemon_id: number + deployed_ms: number + deployed_time: number + battles_won: number + battles_lost: number + times_fed: number + motivation_now: number + cp_when_deployed: number + cp_now: number +} + export interface Gym { id: string lat: number @@ -54,6 +66,7 @@ export interface Gym { updated: number guarding_pokemon_id: number guarding_pokemon_display: PokemonDisplay + defenders: Defender[] available_slots: number team_id: number raid_level: number diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index fbd60c854..d2e0e607a 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -23,6 +23,7 @@ type Gym { updated: Int guarding_pokemon_id: Int guarding_pokemon_display: JSON + defenders: JSON available_slots: Int team_id: Int raid_level: Int diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index ebf0ed8ae..a5314779b 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -27,6 +27,7 @@ const gymFields = [ 'in_battle', 'guarding_pokemon_id', 'guarding_pokemon_display', + 'defenders', 'total_cp', 'power_up_points', 'power_up_level', @@ -309,6 +310,9 @@ class Gym extends Model { gym.guarding_pokemon_display, ) } + if (typeof gym.defenders === 'string' && gym.defenders) { + newGym.defenders = JSON.parse(gym.defenders) + } } if ( onlyRaids && diff --git a/src/features/gym/GymPopup.jsx b/src/features/gym/GymPopup.jsx index 6ecf6f19c..8c11dc92e 100644 --- a/src/features/gym/GymPopup.jsx +++ b/src/features/gym/GymPopup.jsx @@ -5,10 +5,18 @@ import MoreVert from '@mui/icons-material/MoreVert' import Grid from '@mui/material/Unstable_Grid2' import IconButton from '@mui/material/IconButton' import Menu from '@mui/material/Menu' +import Box from '@mui/material/Box' import MenuItem from '@mui/material/MenuItem' import Divider from '@mui/material/Divider' import Collapse from '@mui/material/Collapse' import Typography from '@mui/material/Typography' +import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew' +import ShieldIcon from '@mui/icons-material/Shield' +import FavoriteIcon from '@mui/icons-material/Favorite' +import EmojiEventsIcon from '@mui/icons-material/EmojiEvents' +import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied' +import RestaurantIcon from '@mui/icons-material/Restaurant' +import { useTheme } from '@mui/material/styles' import { useTranslation } from 'react-i18next' import { useSyncData } from '@features/webhooks' @@ -16,20 +24,39 @@ import { useMemory } from '@store/useMemory' import { useLayoutStore } from '@store/useLayoutStore' import { setDeepStore, useStorage } from '@store/useStorage' import { ErrorBoundary } from '@components/ErrorBoundary' -import { Img, TextWithIcon } from '@components/Img' +import { Img } from '@components/Img' import { Title } from '@components/popups/Title' import { PowerUp } from '@components/popups/PowerUp' import { GenderIcon } from '@components/popups/GenderIcon' import { Navigation } from '@components/popups/Navigation' import { Coords } from '@components/popups/Coords' import { TimeStamp } from '@components/popups/TimeStamps' -import { ExtraInfo } from '@components/popups/ExtraInfo' import { useAnalytics } from '@hooks/useAnalytics' import { getTimeUntil } from '@utils/getTimeUntil' import { formatInterval } from '@utils/formatInterval' import { useWebhook } from './useWebhook' +/** + * Format deployed time as either "Xd Xh Xm" or "X:X:X" format + * @param {number} intervalMs - Time interval in milliseconds + * @returns {string} Formatted time string + */ +function formatDeployedTime(intervalMs) { + const totalSeconds = Math.floor(intervalMs / 1000) + const days = Math.floor(totalSeconds / 86400) + const hours = Math.floor((totalSeconds % 86400) / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + + if (days > 0) { + // Format as "Xd Xh Xm" + return `${days}d ${hours}h ${minutes}m` + } + // Format as "X:X:X" (HH:MM:SS) + const seconds = totalSeconds % 60 + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` +} + /** * * @param {{ @@ -44,9 +71,27 @@ export function GymPopup({ hasRaid, hasHatched, raidIconUrl, ...gym }) { const { perms } = useMemory((s) => s.auth) const popups = useStorage((s) => s.popups) const ts = Math.floor(Date.now() / 1000) + const [showDefenders, setShowDefenders] = React.useState(false) useAnalytics('Popup', `Team ID: ${gym.team_id} Has Raid: ${hasRaid}`, 'Gym') + // If defenders modal is toggled, show only that + if (showDefenders) { + return ( + + + setShowDefenders(false)} /> + + + ) + } + return ( )} - + {perms.gyms && ( @@ -116,6 +167,283 @@ export function GymPopup({ hasRaid, hasHatched, raidIconUrl, ...gym }) { ) } +/** + * Compact modal for gym defenders + * @param {{ gym: import('@rm/types').Gym, onClose: () => void }} param0 + */ +function DefendersModal({ gym, onClose }) { + const { t } = useTranslation() + const theme = useTheme() + const Icons = useMemory((s) => s.Icons) + const defenders = gym.defenders || [] + const updatedMs = + defenders.length && + defenders[0].deployed_ms + defenders[0].deployed_time * 1000 + const now = Date.now() + + return ( + + + + { + e.stopPropagation() + onClose() + }} + size="small" + > + + + + + {gym.name} + + + + {defenders.map((def) => { + const fullCP = def.cp_when_deployed + const decayTime = + 72 * + 60 * + 60 * + 1000 * + Math.min(Math.max(Math.log10(3000 / fullCP), 1 / 9), 1) + const predictedMotivation = Math.max( + 0, + def.motivation_now - Math.max(0, now - updatedMs) / decayTime, + ) + const currentCP = Math.round( + fullCP * (0.2 + 0.8 * predictedMotivation), + ) + + return ( +
+
+ {t(`poke_${def.pokemon_id}`)} +
+
+ {/* First line: Pokemon name CP{currentCP}/{fullCP} */} +
+ {t(`poke_${def.pokemon_id}`)} +
+ +
+
+ + {def.battles_won || 0} +
+
+ + {def.battles_lost || 0} +
+
+ + {def.times_fed || 0} +
+
+ +
+ CP{currentCP}/{fullCP}{' '} + {formatDeployedTime(def.deployed_ms + now - updatedMs)} +
+
+
+ {/* Heart outline */} + + {/* Heart background */} + + {/* Heart fill */} + + {/* Heart cracks for rounds */} + + {/* Show cracks based on health: */} + {predictedMotivation <= 2 / 3 && ( + // Always show top crack if predictedMotivation <= 2/3 + + )} + {predictedMotivation <= 1 / 3 && ( + // Show bottom crack only if predictedMotivation <= 1/3 + + )} + +
+
+ ) + })} +
+ + {t('last_updated')}:{' '} + {defenders.length ? new Date(updatedMs).toLocaleString() : t('unknown')} + +
+ ) +} + /** * * @param {{ @@ -459,14 +787,22 @@ const RaidInfo = ({ } return ( - - - + + + {getRaidName(raid_level, raid_pokemon_id)} - - + + + {getRaidForm( raid_pokemon_id, raid_pokemon_form, @@ -474,44 +810,74 @@ const RaidInfo = ({ )} + + {/* Move 1 */} {raid_pokemon_move_1 && raid_pokemon_move_1 !== 1 && ( + container + alignItems="center" + justifyContent="center" + sx={{ maxWidth: '100%', flexWrap: 'nowrap' }} + spacing={1} + > + + + + + + {t(`move_${raid_pokemon_move_1}`)} + + + )} - - - {t(`move_${raid_pokemon_move_1}`)} - - + + {/* Move 2 */} {raid_pokemon_move_2 && raid_pokemon_move_2 !== 2 && ( + container + alignItems="center" + justifyContent="center" + sx={{ maxWidth: '100%', flexWrap: 'nowrap' }} + spacing={1} + > + + + + + + {t(`move_${raid_pokemon_move_2}`)} + + + )} - - - {t(`move_${raid_pokemon_move_2}`)} - - ) } @@ -570,10 +936,12 @@ const Timer = ({ * lat: number * lon: number * hasRaid: boolean + * gym: any + * setShowDefenders: any * }} param0 * @returns */ -const GymFooter = ({ lat, lon, hasRaid }) => { +const GymFooter = ({ lat, lon, hasRaid, gym, setShowDefenders }) => { const darkMode = useStorage((s) => s.darkMode) const popups = useStorage((s) => s.popups) const perms = useMemory((s) => s.auth.perms) @@ -587,38 +955,77 @@ const GymFooter = ({ lat, lon, hasRaid }) => { })) } + const buttons = [] + + if (hasRaid && perms.raids && perms.gyms) { + buttons.push({ + key: 'raids', + element: ( + handleExpandClick('raids')} size="large"> + {popups.raids + + ), + }) + } + + if (gym.defenders?.length > 0) { + buttons.push({ + key: 'defenders', + element: ( + { + e.stopPropagation() + setShowDefenders(true) + }} + size="large" + > + + + ), + }) + } + + buttons.push({ + key: 'nav', + element: , + }) + + if (perms.gyms) { + buttons.push({ + key: 'extras', + element: ( + handleExpandClick('extras')} + size="large" + > + + + ), + }) + } + return ( - <> - {hasRaid && perms.raids && perms.gyms && ( - - handleExpandClick('raids')} size="large"> - {popups.raids - - - )} - - - - {perms.gyms && ( - - handleExpandClick('extras')} - size="large" - > - - - - )} - + + {buttons.map(({ key, element }) => ( + {element} + ))} + ) } @@ -627,55 +1034,13 @@ const GymFooter = ({ lat, lon, hasRaid }) => { * @param {import('@rm/types').Gym} props * @returns */ -const ExtraGymInfo = ({ - last_modified_timestamp, - lat, - lon, - updated, - total_cp, - guarding_pokemon_id, - guarding_pokemon_display, -}) => { - const { t, i18n } = useTranslation() - const Icons = useMemory((s) => s.Icons) - const gymValidDataLimit = useMemory((s) => s.gymValidDataLimit) +const ExtraGymInfo = ({ last_modified_timestamp, lat, lon, updated }) => { const enableGymPopupCoords = useStorage( (s) => s.userSettings.gyms.enableGymPopupCoords, ) - const numFormatter = new Intl.NumberFormat(i18n.language) - /** @type {Partial} */ - const gpd = guarding_pokemon_display || {} - return ( - {!!guarding_pokemon_id && updated > gymValidDataLimit && ( - - - {gpd.badge === 1 && ( - <> - {t('best_buddy')} -   - - )} - {t(`poke_${guarding_pokemon_id}`)} - - - )} - {!!total_cp && updated > gymValidDataLimit && ( - {numFormatter.format(total_cp)} - )} - last_seen last_modified {enableGymPopupCoords && ( diff --git a/src/services/queries/gym.js b/src/services/queries/gym.js index 2a553068b..34835458a 100644 --- a/src/services/queries/gym.js +++ b/src/services/queries/gym.js @@ -23,6 +23,7 @@ const gym = gql` in_battle guarding_pokemon_id guarding_pokemon_display + defenders total_cp badge power_up_level