From 288ce07c1794517d0fcc51954648323da8cc65c5 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 31 Mar 2026 01:09:53 -0600 Subject: [PATCH 1/6] feat(games): add Snake, Asteroids, Race Car, and Tetris game modals with search integration --- src/components/AsteroidsGameModal.tsx | 331 +++++++++++++++ src/components/RaceCarGameModal.tsx | 510 +++++++++++++++++++++++ src/components/SearchModal.tsx | 333 +++++++++------ src/components/SnakeGameModal.tsx | 251 +++++++++++ src/components/TetrisGameModal.tsx | 507 ++++++++++++++++++++++ src/test/components/SearchModal.test.tsx | 104 +++++ 6 files changed, 1903 insertions(+), 133 deletions(-) create mode 100644 src/components/AsteroidsGameModal.tsx create mode 100644 src/components/RaceCarGameModal.tsx create mode 100644 src/components/SnakeGameModal.tsx create mode 100644 src/components/TetrisGameModal.tsx create mode 100644 src/test/components/SearchModal.test.tsx diff --git a/src/components/AsteroidsGameModal.tsx b/src/components/AsteroidsGameModal.tsx new file mode 100644 index 00000000..7dc39b06 --- /dev/null +++ b/src/components/AsteroidsGameModal.tsx @@ -0,0 +1,331 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material' + +type Vector = { + x: number + y: number +} + +type Bullet = Vector & { + id: number + dx: number + dy: number + life: number +} + +type Asteroid = Vector & { + id: number + dx: number + dy: number + radius: number +} + +type Ship = Vector & { + angle: number + dx: number + dy: number +} + +type AsteroidsGameModalProps = { + open: boolean + onClose: () => void +} + +const ARENA_WIDTH = 640 +const ARENA_HEIGHT = 420 +const SHIP_RADIUS = 12 +const SHOT_SPEED = 6 +const TURN_SPEED = 0.09 +const THRUST = 0.18 +const DRAG = 0.992 +const BULLET_LIFE = 60 +const FIRE_COOLDOWN = 10 + +const wrapPosition = (value: number, max: number) => { + if (value < 0) return value + max + if (value > max) return value - max + return value +} + +const distance = (a: Vector, b: Vector) => Math.hypot(a.x - b.x, a.y - b.y) + +const createAsteroids = (): Asteroid[] => [ + { id: 1, x: 96, y: 88, dx: 1.1, dy: 0.7, radius: 34 }, + { id: 2, x: 516, y: 124, dx: -0.9, dy: 1, radius: 28 }, + { id: 3, x: 180, y: 330, dx: 0.7, dy: -0.8, radius: 30 }, + { id: 4, x: 530, y: 300, dx: -1.2, dy: -0.6, radius: 22 }, +] + +const createShip = (): Ship => ({ + x: ARENA_WIDTH / 2, + y: ARENA_HEIGHT / 2, + angle: -Math.PI / 2, + dx: 0, + dy: 0, +}) + +export const AsteroidsGameModal = ({ open, onClose }: AsteroidsGameModalProps) => { + const keysRef = useRef({ left: false, right: false, thrust: false, firing: false }) + const bulletIdRef = useRef(0) + const fireCooldownRef = useRef(0) + const shipRef = useRef(createShip()) + const bulletsRef = useRef([]) + const asteroidsRef = useRef(createAsteroids()) + + const [ship, setShip] = useState(createShip) + const [bullets, setBullets] = useState([]) + const [asteroids, setAsteroids] = useState(createAsteroids) + const [score, setScore] = useState(0) + const [isGameOver, setIsGameOver] = useState(false) + const [isVictory, setIsVictory] = useState(false) + + const resetGame = () => { + keysRef.current = { left: false, right: false, thrust: false, firing: false } + bulletIdRef.current = 0 + fireCooldownRef.current = 0 + const nextShip = createShip() + const nextAsteroids = createAsteroids() + shipRef.current = nextShip + bulletsRef.current = [] + asteroidsRef.current = nextAsteroids + setShip(nextShip) + setBullets([]) + setAsteroids(nextAsteroids) + setScore(0) + setIsGameOver(false) + setIsVictory(false) + } + + useEffect(() => { + if (open) { + resetGame() + } + }, [open]) + + useEffect(() => { + if (!open) return + + const onKeyChange = (pressed: boolean) => (event: KeyboardEvent) => { + const key = event.key.toLowerCase() + if (['arrowleft', 'arrowright', 'arrowup', 'a', 'd', 'w', ' '].includes(key)) { + event.preventDefault() + } + + if (key === 'arrowleft' || key === 'a') keysRef.current.left = pressed + if (key === 'arrowright' || key === 'd') keysRef.current.right = pressed + if (key === 'arrowup' || key === 'w') keysRef.current.thrust = pressed + if (key === ' ') keysRef.current.firing = pressed + } + + const onKeyDown = onKeyChange(true) + const onKeyUp = onKeyChange(false) + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [open]) + + useEffect(() => { + if (!open || isGameOver || isVictory) return + + const interval = window.setInterval(() => { + const currentShip = shipRef.current + let angle = currentShip.angle + let dx = currentShip.dx + let dy = currentShip.dy + + if (keysRef.current.left) angle -= TURN_SPEED + if (keysRef.current.right) angle += TURN_SPEED + + if (keysRef.current.thrust) { + dx += Math.cos(angle) * THRUST + dy += Math.sin(angle) * THRUST + } + + dx *= DRAG + dy *= DRAG + + const nextShip = { + ...currentShip, + angle, + dx, + dy, + x: wrapPosition(currentShip.x + dx, ARENA_WIDTH), + y: wrapPosition(currentShip.y + dy, ARENA_HEIGHT), + } + + let nextBullets = bulletsRef.current + .map((bullet) => ({ + ...bullet, + x: wrapPosition(bullet.x + bullet.dx, ARENA_WIDTH), + y: wrapPosition(bullet.y + bullet.dy, ARENA_HEIGHT), + life: bullet.life - 1, + })) + .filter((bullet) => bullet.life > 0) + + if (fireCooldownRef.current > 0) { + fireCooldownRef.current -= 1 + } + + if (keysRef.current.firing && fireCooldownRef.current === 0) { + fireCooldownRef.current = FIRE_COOLDOWN + nextBullets = [ + ...nextBullets, + { + id: bulletIdRef.current += 1, + x: nextShip.x + Math.cos(angle) * (SHIP_RADIUS + 4), + y: nextShip.y + Math.sin(angle) * (SHIP_RADIUS + 4), + dx: Math.cos(angle) * SHOT_SPEED + dx, + dy: Math.sin(angle) * SHOT_SPEED + dy, + life: BULLET_LIFE, + }, + ] + } + + const movedAsteroids = asteroidsRef.current.map((asteroid) => ({ + ...asteroid, + x: wrapPosition(asteroid.x + asteroid.dx, ARENA_WIDTH), + y: wrapPosition(asteroid.y + asteroid.dy, ARENA_HEIGHT), + })) + + const hitBulletIds = new Set() + const hitAsteroidIds = new Set() + + for (const bullet of nextBullets) { + const hit = movedAsteroids.find((asteroid) => distance(bullet, asteroid) < asteroid.radius) + if (hit) { + hitBulletIds.add(bullet.id) + hitAsteroidIds.add(hit.id) + } + } + + const remainingBullets = nextBullets.filter((bullet) => !hitBulletIds.has(bullet.id)) + const remainingAsteroids = movedAsteroids.filter((asteroid) => !hitAsteroidIds.has(asteroid.id)) + + if (hitAsteroidIds.size > 0) { + setScore((currentScore) => currentScore + hitAsteroidIds.size * 100) + } + + if (remainingAsteroids.some((asteroid) => distance(nextShip, asteroid) < asteroid.radius + SHIP_RADIUS - 2)) { + setIsGameOver(true) + } else if (remainingAsteroids.length === 0) { + setIsVictory(true) + } + + shipRef.current = nextShip + bulletsRef.current = remainingBullets + asteroidsRef.current = remainingAsteroids + setShip(nextShip) + setBullets(remainingBullets) + setAsteroids(remainingAsteroids) + }, 16) + + return () => window.clearInterval(interval) + }, [isGameOver, isVictory, open]) + + const shipPoints = useMemo(() => { + const nose = `${ship.x + Math.cos(ship.angle) * 16},${ship.y + Math.sin(ship.angle) * 16}` + const left = `${ship.x + Math.cos(ship.angle + 2.45) * 13},${ship.y + Math.sin(ship.angle + 2.45) * 13}` + const right = `${ship.x + Math.cos(ship.angle - 2.45) * 13},${ship.y + Math.sin(ship.angle - 2.45) * 13}` + return `${nose} ${left} ${right}` + }, [ship.angle, ship.x, ship.y]) + + return ( + + Asteroids + + + + + Score: {score} + + + + + + + + {asteroids.map((asteroid) => ( + + ))} + {bullets.map((bullet) => ( + + ))} + + {keysRef.current.thrust && !isGameOver && !isVictory && ( + + )} + + + + + Use left/right or A/D to rotate, up or W to thrust, and space to fire. Clear every asteroid without colliding. + + + {(isGameOver || isVictory) && ( + + + {isVictory ? 'You cleared the field' : 'Ship destroyed'} + + + {isVictory ? `Final score: ${score}.` : `Final score: ${score}.`} Restart to play again. + + + )} + + + + ) +} + +export default AsteroidsGameModal diff --git a/src/components/RaceCarGameModal.tsx b/src/components/RaceCarGameModal.tsx new file mode 100644 index 00000000..1ac0d532 --- /dev/null +++ b/src/components/RaceCarGameModal.tsx @@ -0,0 +1,510 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material' + +type Segment = { + y: number + center: number + width: number +} + +type Obstacle = { + id: number + y: number + offsetRatio: number +} + +type RoadsideDecoration = { + key: string + type: 'tree' | 'crowd' + x: number + y: number + scale: number +} + +type RaceCarGameModalProps = { + open: boolean + onClose: () => void +} + +const VIEW_WIDTH = 420 +const VIEW_HEIGHT = 560 +const SEGMENT_HEIGHT = 28 +const SEGMENT_COUNT = Math.ceil(VIEW_HEIGHT / SEGMENT_HEIGHT) + 3 +const BASE_ROAD_WIDTH = 350 +const ROAD_NARROW_RATE = 0.05 +const SCROLL_SPEED = 5 +const PLAYER_Y = VIEW_HEIGHT - 96 +const PLAYER_HALF_WIDTH = 18 +const PLAYER_HALF_HEIGHT = 30 +const MIN_ROAD_WIDTH = PLAYER_HALF_WIDTH * 2 + 10 +const PLAYER_SPEED = 7 +const CENTER_SWAY = 72 +const SAFE_INSET = 10 +const OBSTACLE_WIDTH = 28 +const OBSTACLE_HEIGHT = 36 +const OBSTACLE_SPAWN_INTERVAL_MIN = 45 +const OBSTACLE_SPAWN_INTERVAL_MAX = 95 + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) + +const createInitialSegments = (): Segment[] => + Array.from({ length: SEGMENT_COUNT }, (_, index) => ({ + y: index * SEGMENT_HEIGHT - SEGMENT_HEIGHT, + center: VIEW_WIDTH / 2, + width: BASE_ROAD_WIDTH, + })) + +const makeNextSegment = (y: number, distance: number, previousCenter: number): Segment => { + const targetCenter = + VIEW_WIDTH / 2 + + Math.sin(distance / 220) * CENTER_SWAY + + Math.sin(distance / 97) * 26 + const center = previousCenter + (targetCenter - previousCenter) * 0.24 + const width = Math.max(MIN_ROAD_WIDTH, BASE_ROAD_WIDTH - distance * ROAD_NARROW_RATE) + + return { y, center, width } +} + +const getSegmentAtY = (segments: Segment[], y: number) => + segments.find((segment) => y >= segment.y && y < segment.y + SEGMENT_HEIGHT) ?? + segments[segments.length - 1] + +const randomInt = (min: number, max: number) => + Math.floor(Math.random() * (max - min + 1)) + min + +const createDecorationsForSegment = ( + segment: Segment, + index: number, + counter: { current: number } +): RoadsideDecoration[] => { + const leftEdge = segment.center - segment.width / 2 + const rightEdge = segment.center + segment.width / 2 + const baseY = segment.y + SEGMENT_HEIGHT / 2 + const scale = 0.55 + index / Math.max(SEGMENT_COUNT, 1) * 0.45 + const decorations: RoadsideDecoration[] = [] + + const maybePush = (type: 'tree' | 'crowd', side: 'left' | 'right', chance: number) => { + if (Math.random() > chance) return + counter.current += 1 + const isLeft = side === 'left' + const edge = isLeft ? leftEdge : rightEdge + const xOffset = + type === 'tree' + ? 24 + Math.random() * 18 + : 52 + Math.random() * 22 + + decorations.push({ + key: `${type}-${side}-${counter.current}`, + type, + x: edge + (isLeft ? -xOffset : xOffset), + y: baseY + (Math.random() * 10 - 5), + scale: scale * (0.85 + Math.random() * 0.2), + }) + } + + maybePush('tree', 'left', 0.24) + maybePush('tree', 'right', 0.24) + maybePush('crowd', 'left', 0.15) + maybePush('crowd', 'right', 0.15) + + return decorations +} + +export const RaceCarGameModal = ({ open, onClose }: RaceCarGameModalProps) => { + const keysRef = useRef({ left: false, right: false }) + const playerXRef = useRef(VIEW_WIDTH / 2) + const distanceRef = useRef(0) + const segmentsRef = useRef(createInitialSegments()) + const obstaclesRef = useRef([]) + const obstacleIdRef = useRef(0) + const obstacleSpawnTimerRef = useRef(randomInt(OBSTACLE_SPAWN_INTERVAL_MIN, OBSTACLE_SPAWN_INTERVAL_MAX)) + const decorationsRef = useRef([]) + const decorationIdRef = useRef(0) + + const [playerX, setPlayerX] = useState(VIEW_WIDTH / 2) + const [distanceScore, setDistanceScore] = useState(0) + const [segments, setSegments] = useState(createInitialSegments()) + const [obstacles, setObstacles] = useState([]) + const [roadsideDecor, setRoadsideDecor] = useState([]) + const [isGameOver, setIsGameOver] = useState(false) + + const resetGame = () => { + const initialSegments = createInitialSegments() + keysRef.current = { left: false, right: false } + playerXRef.current = VIEW_WIDTH / 2 + distanceRef.current = 0 + segmentsRef.current = initialSegments + obstaclesRef.current = [] + obstacleIdRef.current = 0 + obstacleSpawnTimerRef.current = randomInt( + OBSTACLE_SPAWN_INTERVAL_MIN, + OBSTACLE_SPAWN_INTERVAL_MAX + ) + decorationIdRef.current = 0 + decorationsRef.current = initialSegments.flatMap((segment, index) => + createDecorationsForSegment(segment, index, decorationIdRef) + ) + setPlayerX(VIEW_WIDTH / 2) + setDistanceScore(0) + setSegments(initialSegments) + setObstacles([]) + setRoadsideDecor(decorationsRef.current) + setIsGameOver(false) + } + + useEffect(() => { + if (open) { + resetGame() + } + }, [open]) + + useEffect(() => { + if (!open) return + + const onKeyChange = (pressed: boolean) => (event: KeyboardEvent) => { + const key = event.key.toLowerCase() + if (['arrowleft', 'arrowright', 'a', 'd'].includes(key)) { + event.preventDefault() + } + if (key === 'arrowleft' || key === 'a') keysRef.current.left = pressed + if (key === 'arrowright' || key === 'd') keysRef.current.right = pressed + } + + const onKeyDown = onKeyChange(true) + const onKeyUp = onKeyChange(false) + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [open]) + + useEffect(() => { + if (!open || isGameOver) return + + const interval = window.setInterval(() => { + const movement = + (keysRef.current.right ? PLAYER_SPEED : 0) - (keysRef.current.left ? PLAYER_SPEED : 0) + playerXRef.current = clamp( + playerXRef.current + movement, + PLAYER_HALF_WIDTH + 12, + VIEW_WIDTH - PLAYER_HALF_WIDTH - 12 + ) + + distanceRef.current += SCROLL_SPEED + + let nextSegments = segmentsRef.current + .map((segment) => ({ ...segment, y: segment.y + SCROLL_SPEED })) + .filter((segment) => segment.y < VIEW_HEIGHT + SEGMENT_HEIGHT) + let nextDecorations = decorationsRef.current + .map((item) => ({ ...item, y: item.y + SCROLL_SPEED })) + .filter((item) => item.y < VIEW_HEIGHT + 40) + + while (nextSegments.length < SEGMENT_COUNT) { + const topY = nextSegments.length > 0 ? Math.min(...nextSegments.map((segment) => segment.y)) : 0 + const previousTop = nextSegments.reduce( + (best, segment) => (best === null || segment.y < best.y ? segment : best), + null + ) + const nextY = topY - SEGMENT_HEIGHT + const nextSegment = makeNextSegment( + nextY, + distanceRef.current + Math.abs(nextY), + previousTop?.center ?? VIEW_WIDTH / 2 + ) + nextSegments = [nextSegment, ...nextSegments] + nextDecorations = [ + ...createDecorationsForSegment(nextSegment, 0, decorationIdRef), + ...nextDecorations, + ] + } + + segmentsRef.current = nextSegments + let nextObstacles = obstaclesRef.current + .map((obstacle) => ({ ...obstacle, y: obstacle.y + SCROLL_SPEED })) + .filter((obstacle) => obstacle.y < VIEW_HEIGHT + OBSTACLE_HEIGHT) + + obstacleSpawnTimerRef.current -= 1 + const topPlayableSegment = getSegmentAtY(nextSegments, 0) + const canSpawnObstacle = + topPlayableSegment.width >= PLAYER_HALF_WIDTH * 2 + OBSTACLE_WIDTH + SAFE_INSET * 2 + + if (canSpawnObstacle && obstacleSpawnTimerRef.current <= 0) { + obstacleSpawnTimerRef.current = randomInt( + OBSTACLE_SPAWN_INTERVAL_MIN, + OBSTACLE_SPAWN_INTERVAL_MAX + ) + const offsetMagnitude = 0.45 + Math.random() * 0.2 + const offsetRatio = Math.random() < 0.5 ? -offsetMagnitude : offsetMagnitude + nextObstacles = [ + ...nextObstacles, + { + id: obstacleIdRef.current += 1, + y: -OBSTACLE_HEIGHT, + offsetRatio, + }, + ] + } else if (!canSpawnObstacle) { + obstacleSpawnTimerRef.current = randomInt( + OBSTACLE_SPAWN_INTERVAL_MIN, + OBSTACLE_SPAWN_INTERVAL_MAX + ) + } + + obstaclesRef.current = nextObstacles + decorationsRef.current = nextDecorations + setSegments(nextSegments) + setObstacles(nextObstacles) + setRoadsideDecor(nextDecorations) + setPlayerX(playerXRef.current) + setDistanceScore(Math.floor(distanceRef.current)) + + const playerSegment = + getSegmentAtY(nextSegments, PLAYER_Y) + + const leftEdge = playerSegment.center - playerSegment.width / 2 + const rightEdge = playerSegment.center + playerSegment.width / 2 + + if ( + playerXRef.current - PLAYER_HALF_WIDTH < leftEdge + SAFE_INSET || + playerXRef.current + PLAYER_HALF_WIDTH > rightEdge - SAFE_INSET + ) { + setIsGameOver(true) + } + + const hitsObstacle = nextObstacles.some((obstacle) => { + const segment = getSegmentAtY(nextSegments, obstacle.y + OBSTACLE_HEIGHT / 2) + const usableHalfWidth = Math.max( + 0, + segment.width / 2 - SAFE_INSET - OBSTACLE_WIDTH / 2 + ) + const obstacleCenterX = segment.center + obstacle.offsetRatio * usableHalfWidth + const verticalOverlap = + obstacle.y + OBSTACLE_HEIGHT > PLAYER_Y - PLAYER_HALF_HEIGHT && + obstacle.y < PLAYER_Y + PLAYER_HALF_HEIGHT + const horizontalOverlap = + Math.abs(obstacleCenterX - playerXRef.current) < + OBSTACLE_WIDTH / 2 + PLAYER_HALF_WIDTH + + return verticalOverlap && horizontalOverlap + }) + + if (hitsObstacle) { + setIsGameOver(true) + } + }, 32) + + return () => window.clearInterval(interval) + }, [isGameOver, open]) + + const roadPath = useMemo(() => { + const ordered = [...segments].sort((a, b) => a.y - b.y) + const left = ordered.map((segment) => `${segment.center - segment.width / 2},${segment.y}`) + const right = ordered + .slice() + .reverse() + .map((segment) => `${segment.center + segment.width / 2},${segment.y}`) + return [...left, ...right].join(' ') + }, [segments]) + + const renderedObstacles = useMemo( + () => + obstacles.map((obstacle) => { + const segment = getSegmentAtY(segments, obstacle.y + OBSTACLE_HEIGHT / 2) + const usableHalfWidth = Math.max( + 0, + segment.width / 2 - SAFE_INSET - OBSTACLE_WIDTH / 2 + ) + const centerX = segment.center + obstacle.offsetRatio * usableHalfWidth + return { + id: obstacle.id, + x: centerX - OBSTACLE_WIDTH / 2, + y: obstacle.y, + } + }), + [obstacles, segments] + ) + + return ( + + Race Car + + + + + Distance: {distanceScore} m + + + + + + + + + + + + + + + + + + + + + + + + + + {renderedObstacles.map((obstacle) => ( + + + + + ))} + + {roadsideDecor.map((item) => + item.type === 'tree' ? ( + + + + + + + ) : ( + + + + + + + + + ) + )} + + + + + + + + + + + + + + + + + + + + + + + Use left/right or A/D to steer. Stay inside the road as it shifts sideways and narrows over time. + + + {isGameOver && ( + + + Off the road + + + Final distance: {distanceScore} m. Restart to race again. + + + )} + + + + ) +} + +export default RaceCarGameModal diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 99ef9cf0..240119f6 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -23,6 +23,12 @@ import { useDebounce, useAbortableList, useSearchHistory } from '@/hooks' import { GroupType } from '@/constants' import { SearchResult, WellResult, ContactResult } from '@/interfaces/ocotillo' import { highlight } from '@/utils' +import { SnakeGameModal } from '@/components/SnakeGameModal' +import { AsteroidsGameModal } from '@/components/AsteroidsGameModal' +import { RaceCarGameModal } from '@/components/RaceCarGameModal' +import { TetrisGameModal } from '@/components/TetrisGameModal' + +type ArcadeGame = 'snake' | 'asteroids' | 'racecar' | 'tetris' // ---- type icon mapping ------------------------------------------------ @@ -162,24 +168,61 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { const [query, setQuery] = useState('') const [recentSearches, setRecentSearches] = useState([]) + const [activeGame, setActiveGame] = useState(null) + const [dismissedGame, setDismissedGame] = useState(null) const debounced = useDebounce(query, 400) + const normalizedQuery = query.trim().toLowerCase() + const normalizedDebounced = debounced.trim().toLowerCase() + const requestedGame = + normalizedQuery === 'snake' + ? 'snake' + : normalizedQuery === 'asteroids' + ? 'asteroids' + : normalizedQuery === 'racecar' + ? 'racecar' + : normalizedQuery === 'tetris' + ? 'tetris' + : null // Reload history each time modal opens useEffect(() => { if (open) { setQuery('') setRecentSearches(history.get()) - // Small delay so Dialog is mounted before we focus - setTimeout(() => inputRef.current?.focus(), 50) + setDismissedGame(null) + setActiveGame(null) + } + }, [open]) + + useEffect(() => { + if (open) { + inputRef.current?.focus() } }, [open]) + useEffect(() => { + if (!requestedGame) { + setDismissedGame(null) + return + } + + if (open && activeGame !== requestedGame && dismissedGame !== requestedGame) { + setActiveGame(requestedGame) + } + }, [activeGame, dismissedGame, open, requestedGame]) + const { query: searchQuery, result } = useAbortableList({ resource: 'search', dataProviderName: 'ocotillo', pagination: { pageSize: 100 }, queryOptions: { - enabled: open && debounced.length >= 1, + enabled: + open && + normalizedDebounced.length >= 1 && + normalizedDebounced !== 'snake' && + normalizedDebounced !== 'asteroids' && + normalizedDebounced !== 'racecar' && + normalizedDebounced !== 'tetris', staleTime: 120_000, refetchOnReconnect: false, refetchOnWindowFocus: false, @@ -257,146 +300,170 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { const handleClose = () => { if (query.trim()) history.add(query) setQuery('') + setActiveGame(null) + setDismissedGame(null) onClose() } + const handleGameClose = () => { + setDismissedGame(activeGame) + setActiveGame(null) + } + const showRecent = !query.trim() && recentSearches.length > 0 - const showEmpty = query.trim() && !searchQuery.isFetching && !searchQuery.isError && results.length === 0 - const showError = query.trim() && !searchQuery.isFetching && searchQuery.isError + const showEmpty = + query.trim() && + !requestedGame && + !searchQuery.isFetching && + !searchQuery.isError && + results.length === 0 + const showError = query.trim() && !requestedGame && !searchQuery.isFetching && searchQuery.isError return ( - - {/* Search input row */} - + - - setQuery(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Escape') handleClose() }} - placeholder="Search" - fullWidth - sx={{ fontSize: 15 }} - inputProps={{ 'aria-label': 'Search' }} - endAdornment={ - query ? ( - - setQuery('')} edge="end"> - - - - ) : null - } - /> - - - {/* Results area */} - - - {/* Loading indicator */} - {searchQuery.isFetching && ( - - Searching... - - )} - - {/* Recent searches */} - {showRecent && ( - - - - Recent searches - - - Clear history - - - {recentSearches.map((q) => ( - handleRecentClick(q)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1.5, - px: 1.5, - py: 0.75, - cursor: 'pointer', - borderRadius: 1, - '&:hover': { bgcolor: 'action.hover' }, - }} - > - - - {q} + {/* Search input row */} + + + setQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape') handleClose() }} + placeholder="Search" + fullWidth + sx={{ fontSize: 15 }} + inputProps={{ 'aria-label': 'Search' }} + endAdornment={ + query ? ( + + setQuery('')} edge="end"> + + + + ) : null + } + /> + + + {/* Results area */} + + + {/* Loading indicator */} + {searchQuery.isFetching && ( + + Searching... + + )} + + {requestedGame && ( + + Opening {requestedGame === 'snake' ? 'Snake' : requestedGame === 'asteroids' ? 'Asteroids' : requestedGame === 'racecar' ? 'Race Car' : 'Tetris'}... + + )} + + {/* Recent searches */} + {showRecent && ( + + + + Recent searches - - ))} - - )} - - {/* Empty state */} - {showEmpty && ( - - No results for "{query}". Try a well ID, site name, or contact name. - - )} - - {/* Error state */} - {showError && ( - - Search failed. Please try again. - - )} - - {/* Grouped results */} - {!searchQuery.isFetching && grouped.size > 0 && ( - - {Array.from(grouped.entries()).map(([group, items], groupIndex) => ( - - {groupIndex > 0 && } - {items.map((option, i) => ( - handleSelect(option)} - /> - ))} - - ))} - - )} - - - + + Clear history + + + {recentSearches.map((q) => ( + handleRecentClick(q)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.5, + px: 1.5, + py: 0.75, + cursor: 'pointer', + borderRadius: 1, + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + {q} + + + ))} + + )} + + {/* Empty state */} + {showEmpty && ( + + No results for "{query}". Try a well ID, site name, or contact name. + + )} + + {/* Error state */} + {showError && ( + + Search failed. Please try again. + + )} + + {/* Grouped results */} + {!searchQuery.isFetching && !requestedGame && grouped.size > 0 && ( + + {Array.from(grouped.entries()).map(([group, items], groupIndex) => ( + + {groupIndex > 0 && } + {items.map((option, i) => ( + handleSelect(option)} + /> + ))} + + ))} + + )} + + + + + + + + ) } diff --git a/src/components/SnakeGameModal.tsx b/src/components/SnakeGameModal.tsx new file mode 100644 index 00000000..95a9cc54 --- /dev/null +++ b/src/components/SnakeGameModal.tsx @@ -0,0 +1,251 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material' + +type Direction = 'up' | 'down' | 'left' | 'right' + +type Point = { + x: number + y: number +} + +type SnakeGameModalProps = { + open: boolean + onClose: () => void +} + +const BOARD_SIZE = 20 +const TICK_MS = 140 +const INITIAL_SNAKE: Point[] = [ + { x: 10, y: 10 }, + { x: 9, y: 10 }, + { x: 8, y: 10 }, +] +const INITIAL_DIRECTION: Direction = 'right' + +const randomFood = (snake: Point[]): Point => { + const occupied = new Set(snake.map((segment) => `${segment.x},${segment.y}`)) + const available: Point[] = [] + + for (let y = 0; y < BOARD_SIZE; y += 1) { + for (let x = 0; x < BOARD_SIZE; x += 1) { + if (!occupied.has(`${x},${y}`)) { + available.push({ x, y }) + } + } + } + + return available[Math.floor(Math.random() * available.length)] ?? { x: 0, y: 0 } +} + +const isOppositeDirection = (next: Direction, current: Direction) => { + return ( + (next === 'up' && current === 'down') || + (next === 'down' && current === 'up') || + (next === 'left' && current === 'right') || + (next === 'right' && current === 'left') + ) +} + +export const SnakeGameModal = ({ open, onClose }: SnakeGameModalProps) => { + const [snake, setSnake] = useState(INITIAL_SNAKE) + const [direction, setDirection] = useState(INITIAL_DIRECTION) + const [food, setFood] = useState(() => randomFood(INITIAL_SNAKE)) + const [score, setScore] = useState(0) + const [isGameOver, setIsGameOver] = useState(false) + const queuedDirectionRef = useRef(INITIAL_DIRECTION) + + const resetGame = () => { + queuedDirectionRef.current = INITIAL_DIRECTION + setSnake(INITIAL_SNAKE) + setDirection(INITIAL_DIRECTION) + setFood(randomFood(INITIAL_SNAKE)) + setScore(0) + setIsGameOver(false) + } + + useEffect(() => { + if (!open) return + resetGame() + }, [open]) + + useEffect(() => { + if (!open) return + + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase() + const nextDirection = + key === 'arrowup' || key === 'w' + ? 'up' + : key === 'arrowdown' || key === 's' + ? 'down' + : key === 'arrowleft' || key === 'a' + ? 'left' + : key === 'arrowright' || key === 'd' + ? 'right' + : null + + if (!nextDirection) return + + event.preventDefault() + if (isOppositeDirection(nextDirection, queuedDirectionRef.current)) return + queuedDirectionRef.current = nextDirection + setDirection(nextDirection) + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [open]) + + useEffect(() => { + if (!open || isGameOver) return + + const interval = window.setInterval(() => { + setSnake((currentSnake) => { + const currentHead = currentSnake[0] + const nextDirection = queuedDirectionRef.current + const nextHead = + nextDirection === 'up' + ? { x: currentHead.x, y: currentHead.y - 1 } + : nextDirection === 'down' + ? { x: currentHead.x, y: currentHead.y + 1 } + : nextDirection === 'left' + ? { x: currentHead.x - 1, y: currentHead.y } + : { x: currentHead.x + 1, y: currentHead.y } + + const hitsWall = + nextHead.x < 0 || + nextHead.x >= BOARD_SIZE || + nextHead.y < 0 || + nextHead.y >= BOARD_SIZE + + const grows = nextHead.x === food.x && nextHead.y === food.y + const nextBody = grows ? currentSnake : currentSnake.slice(0, -1) + const hitsSelf = nextBody.some((segment) => segment.x === nextHead.x && segment.y === nextHead.y) + + if (hitsWall || hitsSelf) { + setIsGameOver(true) + return currentSnake + } + + const updatedSnake = [nextHead, ...nextBody] + + if (grows) { + setScore((currentScore) => currentScore + 1) + setFood(randomFood(updatedSnake)) + } + + return updatedSnake + }) + }, TICK_MS) + + return () => window.clearInterval(interval) + }, [food, isGameOver, open]) + + const cells = useMemo(() => { + const snakeCells = new Set(snake.map((segment) => `${segment.x},${segment.y}`)) + + return Array.from({ length: BOARD_SIZE * BOARD_SIZE }, (_, index) => { + const x = index % BOARD_SIZE + const y = Math.floor(index / BOARD_SIZE) + const key = `${x},${y}` + const isHead = snake[0]?.x === x && snake[0]?.y === y + const isSnakeCell = snakeCells.has(key) + const isFoodCell = food.x === x && food.y === y + + return { key, isHead, isSnakeCell, isFoodCell } + }) + }, [food, snake]) + + return ( + + Snake + + + + + Score: {score} + + + + + + {cells.map((cell) => ( + + ))} + + + + Use the arrow keys or WASD to move. Eat the red food and avoid walls or your own tail. + + + {isGameOver && ( + + + Game over + + + Final score: {score}. Restart to play again. + + + )} + + + + ) +} + +export default SnakeGameModal diff --git a/src/components/TetrisGameModal.tsx b/src/components/TetrisGameModal.tsx new file mode 100644 index 00000000..ec7d8184 --- /dev/null +++ b/src/components/TetrisGameModal.tsx @@ -0,0 +1,507 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material' + +type PieceType = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L' + +type Point = { + x: number + y: number +} + +type ActivePiece = { + type: PieceType + rotation: number + position: Point +} + +type TetrisGameModalProps = { + open: boolean + onClose: () => void +} + +const BOARD_WIDTH = 10 +const BOARD_HEIGHT = 20 +const DROP_MS = 260 + +const PIECE_COLORS: Record = { + I: '#38bdf8', + O: '#facc15', + T: '#a855f7', + S: '#22c55e', + Z: '#ef4444', + J: '#3b82f6', + L: '#fb923c', +} + +const PIECE_SHAPES: Record = { + I: [ + [ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 3, y: 1 }, + ], + [ + { x: 2, y: 0 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, + { x: 2, y: 3 }, + ], + [ + { x: 0, y: 2 }, + { x: 1, y: 2 }, + { x: 2, y: 2 }, + { x: 3, y: 2 }, + ], + [ + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 1, y: 2 }, + { x: 1, y: 3 }, + ], + ], + O: [ + [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + ], + T: [ + [ + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + [ + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 1, y: 2 }, + ], + [ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 1, y: 2 }, + ], + [ + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 1, y: 2 }, + ], + ], + S: [ + [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + ], + [ + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, + ], + [ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 0, y: 2 }, + { x: 1, y: 2 }, + ], + [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 1, y: 2 }, + ], + ], + Z: [ + [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + [ + { x: 2, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 1, y: 2 }, + ], + [ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 1, y: 2 }, + { x: 2, y: 2 }, + ], + [ + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 0, y: 2 }, + ], + ], + J: [ + [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 1, y: 1 }, + { x: 1, y: 2 }, + ], + [ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, + ], + [ + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 0, y: 2 }, + { x: 1, y: 2 }, + ], + ], + L: [ + [ + { x: 2, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + [ + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 1, y: 2 }, + { x: 2, y: 2 }, + ], + [ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 0, y: 2 }, + ], + [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 1, y: 2 }, + ], + ], +} + +const PIECE_SEQUENCE: PieceType[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'] + +const createEmptyBoard = () => + Array.from({ length: BOARD_HEIGHT }, () => Array.from({ length: BOARD_WIDTH }, () => '')) + +const getPieceCells = (piece: ActivePiece) => + PIECE_SHAPES[piece.type][piece.rotation].map((cell) => ({ + x: piece.position.x + cell.x, + y: piece.position.y + cell.y, + })) + +const isValidPosition = (piece: ActivePiece, board: string[][]) => + getPieceCells(piece).every( + (cell) => + cell.x >= 0 && + cell.x < BOARD_WIDTH && + cell.y < BOARD_HEIGHT && + (cell.y < 0 || board[cell.y][cell.x] === '') + ) + +const spawnPiece = (index: number): ActivePiece => ({ + type: PIECE_SEQUENCE[index % PIECE_SEQUENCE.length], + rotation: 0, + position: { x: 3, y: -1 }, +}) + +const mergePiece = (board: string[][], piece: ActivePiece) => { + const nextBoard = board.map((row) => [...row]) + for (const cell of getPieceCells(piece)) { + if (cell.y >= 0) { + nextBoard[cell.y][cell.x] = PIECE_COLORS[piece.type] + } + } + return nextBoard +} + +const clearRows = (board: string[][]) => { + const remainingRows = board.filter((row) => row.some((cell) => cell === '')) + const cleared = BOARD_HEIGHT - remainingRows.length + const newRows = Array.from({ length: cleared }, () => Array.from({ length: BOARD_WIDTH }, () => '')) + return { + board: [...newRows, ...remainingRows], + cleared, + } +} + +export const TetrisGameModal = ({ open, onClose }: TetrisGameModalProps) => { + const pieceIndexRef = useRef(0) + const boardRef = useRef(createEmptyBoard()) + const activePieceRef = useRef(spawnPiece(0)) + + const [board, setBoard] = useState(createEmptyBoard()) + const [activePiece, setActivePiece] = useState(spawnPiece(0)) + const [score, setScore] = useState(0) + const [lines, setLines] = useState(0) + const [isGameOver, setIsGameOver] = useState(false) + + const resetGame = () => { + const nextBoard = createEmptyBoard() + const nextPiece = spawnPiece(0) + pieceIndexRef.current = 1 + boardRef.current = nextBoard + activePieceRef.current = nextPiece + setBoard(nextBoard) + setActivePiece(nextPiece) + setScore(0) + setLines(0) + setIsGameOver(false) + } + + const commitState = (nextBoard: string[][], nextPiece: ActivePiece | null) => { + boardRef.current = nextBoard + if (nextPiece) { + activePieceRef.current = nextPiece + setActivePiece(nextPiece) + } + setBoard(nextBoard) + } + + const lockPiece = () => { + const merged = mergePiece(boardRef.current, activePieceRef.current) + const { board: clearedBoard, cleared } = clearRows(merged) + + if (cleared > 0) { + setLines((current) => current + cleared) + setScore((current) => current + cleared * 100) + } + + const nextPiece = spawnPiece(pieceIndexRef.current) + pieceIndexRef.current += 1 + + if (!isValidPosition(nextPiece, clearedBoard)) { + boardRef.current = clearedBoard + setBoard(clearedBoard) + setIsGameOver(true) + return + } + + commitState(clearedBoard, nextPiece) + } + + const movePiece = (dx: number, dy: number) => { + if (isGameOver) return + const candidate = { + ...activePieceRef.current, + position: { + x: activePieceRef.current.position.x + dx, + y: activePieceRef.current.position.y + dy, + }, + } + + if (isValidPosition(candidate, boardRef.current)) { + activePieceRef.current = candidate + setActivePiece(candidate) + return true + } + + if (dy > 0) { + lockPiece() + } + return false + } + + const rotatePiece = () => { + if (isGameOver) return + const candidate = { + ...activePieceRef.current, + rotation: (activePieceRef.current.rotation + 1) % 4, + } + const kicks = [0, -1, 1, -2, 2] + + for (const kick of kicks) { + const kickedPiece = { + ...candidate, + position: { + x: candidate.position.x + kick, + y: candidate.position.y, + }, + } + if (isValidPosition(kickedPiece, boardRef.current)) { + activePieceRef.current = kickedPiece + setActivePiece(kickedPiece) + return + } + } + } + + const hardDrop = () => { + if (isGameOver) return + while (movePiece(0, 1)) { + continue + } + } + + useEffect(() => { + if (open) { + resetGame() + } + }, [open]) + + useEffect(() => { + if (!open) return + + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase() + if (['arrowleft', 'arrowright', 'arrowdown', 'arrowup', 'a', 'd', 's', 'w', ' '].includes(key)) { + event.preventDefault() + } + + if (key === 'arrowleft' || key === 'a') movePiece(-1, 0) + if (key === 'arrowright' || key === 'd') movePiece(1, 0) + if (key === 'arrowdown' || key === 's') movePiece(0, 1) + if (key === 'arrowup' || key === 'w') rotatePiece() + if (key === ' ') hardDrop() + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [open, isGameOver]) + + useEffect(() => { + if (!open || isGameOver) return + + const interval = window.setInterval(() => { + movePiece(0, 1) + }, DROP_MS) + + return () => window.clearInterval(interval) + }, [open, isGameOver]) + + const displayBoard = useMemo(() => { + const nextBoard = board.map((row) => [...row]) + for (const cell of getPieceCells(activePiece)) { + if (cell.y >= 0 && cell.y < BOARD_HEIGHT && cell.x >= 0 && cell.x < BOARD_WIDTH) { + nextBoard[cell.y][cell.x] = PIECE_COLORS[activePiece.type] + } + } + return nextBoard + }, [activePiece, board]) + + return ( + + Tetris + + + + + Score: {score} + + + Lines: {lines} + + + + + + {displayBoard.flatMap((row, rowIndex) => + row.map((cell, colIndex) => ( + + )) + )} + + + + Use left/right to move, up to rotate, down to soft drop, and space to hard drop. + + + {isGameOver && ( + + + Game over + + + Final score: {score}. Restart to play again. + + + )} + + + + ) +} + +export default TetrisGameModal diff --git a/src/test/components/SearchModal.test.tsx b/src/test/components/SearchModal.test.tsx new file mode 100644 index 00000000..3e1b352b --- /dev/null +++ b/src/test/components/SearchModal.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SearchModal } from '@/components/SearchModal' + +const { useAbortableListMock, useSearchHistoryMock } = vi.hoisted(() => ({ + useAbortableListMock: vi.fn(), + useSearchHistoryMock: vi.fn(), +})) + +vi.mock('@refinedev/core', async () => { + return { + useGo: () => vi.fn(), + } +}) + +vi.mock('@/hooks', () => { + return { + useDebounce: (value: string) => value, + useAbortableList: (...args: unknown[]) => useAbortableListMock(...args), + useSearchHistory: () => useSearchHistoryMock(), + } +}) + +describe('SearchModal arcade easter eggs', () => { + beforeEach(() => { + useAbortableListMock.mockReset() + useSearchHistoryMock.mockReset() + useSearchHistoryMock.mockReturnValue({ + get: () => [], + add: vi.fn(), + clear: vi.fn(), + }) + useAbortableListMock.mockReturnValue({ + query: { + isFetching: false, + isError: false, + }, + result: { data: [] }, + }) + }) + + it('opens the Snake game when the query is snake', async () => { + const user = userEvent.setup() + + render() + + await user.type(screen.getByRole('textbox', { name: 'Search' }), 'snake') + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: 'Snake' })).toBeTruthy() + }) + + expect(screen.getByText('Score: 0')).toBeTruthy() + expect(screen.getByRole('grid', { name: 'Snake game board' })).toBeTruthy() + }) + + it('opens the Asteroids game when the query is asteroids', async () => { + const user = userEvent.setup() + + render() + + await user.type(screen.getByRole('textbox', { name: 'Search' }), 'asteroids') + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: 'Asteroids' })).toBeTruthy() + }) + + expect(screen.getByText('Score: 0')).toBeTruthy() + expect(screen.getByRole('img', { name: 'Asteroids game board' })).toBeTruthy() + }) + + it('opens the Race Car game when the query is racecar', async () => { + const user = userEvent.setup() + + render() + + await user.type(screen.getByRole('textbox', { name: 'Search' }), 'racecar') + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: 'Race Car' })).toBeTruthy() + }) + + expect(screen.getByText('Distance: 0 m')).toBeTruthy() + expect(screen.getByRole('img', { name: 'Race car game board' })).toBeTruthy() + }) + + it('opens the Tetris game when the query is tetris', async () => { + const user = userEvent.setup() + + render() + + await user.type(screen.getByRole('textbox', { name: 'Search' }), 'tetris') + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: 'Tetris' })).toBeTruthy() + }) + + expect(screen.getByText('Score: 0')).toBeTruthy() + expect(screen.getByText('Lines: 0')).toBeTruthy() + expect(screen.getByRole('grid', { name: 'Tetris game board' })).toBeTruthy() + }) +}) From 1da97ceae9b178ce309b6b6b52501d5830dcb7f8 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 31 Mar 2026 07:29:07 -0600 Subject: [PATCH 2/6] feat(well-details): update WellDetailsResponse and WellExportResponse types; allow null for level_status and data_quality --- src/generated/types.gen.ts | 115 +++++++++++++++++++++++++++++++++++-- src/generated/zod.gen.ts | 62 +++++++++++++++++++- 2 files changed, 171 insertions(+), 6 deletions(-) diff --git a/src/generated/types.gen.ts b/src/generated/types.gen.ts index f375ee84..ebce1625 100644 --- a/src/generated/types.gen.ts +++ b/src/generated/types.gen.ts @@ -128,7 +128,7 @@ export type BodyBulkUploadGroundwaterLevelsObservationGroundwaterLevelBulkUpload /** * File */ - file: Blob | File; + file: string; }; /** @@ -138,7 +138,7 @@ export type BodyUploadAssetAssetUploadPost = { /** * File */ - file: Blob | File; + file: string; }; /** @@ -3218,11 +3218,11 @@ export type WaterLevelBulkUploadRow = { /** * Level Status */ - level_status: string; + level_status: string | null; /** * Data Quality */ - data_quality: string; + data_quality: string | null; }; /** @@ -3243,6 +3243,53 @@ export type WaterLevelBulkUploadSummary = { validation_errors_or_warnings: number; }; +/** + * WellDetailsResponse + */ +export type WellDetailsResponse = { + well: WellResponse; + /** + * Contacts + */ + contacts?: Array; + /** + * Sensors + */ + sensors?: Array; + /** + * Deployments + */ + deployments?: Array; + /** + * Well Screens + */ + well_screens?: Array; + /** + * Recent Groundwater Level Observations + */ + recent_groundwater_level_observations?: Array; + latest_field_event_sample?: SampleResponse | null; +}; + +/** + * WellExportResponse + */ +export type WellExportResponse = { + well: WellResponse; + /** + * Contacts + */ + contacts?: Array; + /** + * Sensors + */ + sensors?: Array; + /** + * Deployments + */ + deployments?: Array; +}; + /** * WellResponse * @@ -6610,6 +6657,66 @@ export type UpdateWaterWellThingWaterWellThingIdPatchResponses = { export type UpdateWaterWellThingWaterWellThingIdPatchResponse = UpdateWaterWellThingWaterWellThingIdPatchResponses[keyof UpdateWaterWellThingWaterWellThingIdPatchResponses]; +export type GetWellDetailsThingWaterWellThingIdDetailsGetData = { + body?: never; + path: { + /** + * Thing Id + */ + thing_id: number; + }; + query?: never; + url: '/thing/water-well/{thing_id}/details'; +}; + +export type GetWellDetailsThingWaterWellThingIdDetailsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetWellDetailsThingWaterWellThingIdDetailsGetError = GetWellDetailsThingWaterWellThingIdDetailsGetErrors[keyof GetWellDetailsThingWaterWellThingIdDetailsGetErrors]; + +export type GetWellDetailsThingWaterWellThingIdDetailsGetResponses = { + /** + * Successful Response + */ + 200: WellDetailsResponse; +}; + +export type GetWellDetailsThingWaterWellThingIdDetailsGetResponse = GetWellDetailsThingWaterWellThingIdDetailsGetResponses[keyof GetWellDetailsThingWaterWellThingIdDetailsGetResponses]; + +export type GetWellExportThingWaterWellThingIdExportGetData = { + body?: never; + path: { + /** + * Thing Id + */ + thing_id: number; + }; + query?: never; + url: '/thing/water-well/{thing_id}/export'; +}; + +export type GetWellExportThingWaterWellThingIdExportGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetWellExportThingWaterWellThingIdExportGetError = GetWellExportThingWaterWellThingIdExportGetErrors[keyof GetWellExportThingWaterWellThingIdExportGetErrors]; + +export type GetWellExportThingWaterWellThingIdExportGetResponses = { + /** + * Successful Response + */ + 200: WellExportResponse; +}; + +export type GetWellExportThingWaterWellThingIdExportGetResponse = GetWellExportThingWaterWellThingIdExportGetResponses[keyof GetWellExportThingWaterWellThingIdExportGetResponses]; + export type GetWellScreensByWellIdThingWaterWellThingIdWellScreenGetData = { body?: never; path: { diff --git a/src/generated/zod.gen.ts b/src/generated/zod.gen.ts index f316646b..64163104 100644 --- a/src/generated/zod.gen.ts +++ b/src/generated/zod.gen.ts @@ -3649,8 +3649,14 @@ export const zWaterLevelBulkUploadRow = z.object({ sample_id: z.int(), observation_id: z.int(), measurement_date_time: z.string(), - level_status: z.string(), - data_quality: z.string() + level_status: z.union([ + z.string(), + z.null() + ]), + data_quality: z.union([ + z.string(), + z.null() + ]) }); /** @@ -3662,6 +3668,32 @@ export const zWaterLevelBulkUploadResponse = z.object({ validation_errors: z.array(z.string()) }); +/** + * WellDetailsResponse + */ +export const zWellDetailsResponse = z.object({ + well: zWellResponse, + contacts: z.optional(z.array(zContactResponse)), + sensors: z.optional(z.array(zSensorResponse)), + deployments: z.optional(z.array(zDeploymentResponse)), + well_screens: z.optional(z.array(zWellScreenResponse)), + recent_groundwater_level_observations: z.optional(z.array(zGroundwaterLevelObservationResponse)), + latest_field_event_sample: z.optional(z.union([ + zSampleResponse, + z.null() + ])) +}); + +/** + * WellExportResponse + */ +export const zWellExportResponse = z.object({ + well: zWellResponse, + contacts: z.optional(z.array(zContactResponse)), + sensors: z.optional(z.array(zSensorResponse)), + deployments: z.optional(z.array(zDeploymentResponse)) +}); + export const zUploadAssetAssetUploadPostData = z.object({ body: zBodyUploadAssetAssetUploadPost, path: z.optional(z.never()), @@ -4935,6 +4967,32 @@ export const zUpdateWaterWellThingWaterWellThingIdPatchData = z.object({ */ export const zUpdateWaterWellThingWaterWellThingIdPatchResponse = zWellResponse; +export const zGetWellDetailsThingWaterWellThingIdDetailsGetData = z.object({ + body: z.optional(z.never()), + path: z.object({ + thing_id: z.int() + }), + query: z.optional(z.never()) +}); + +/** + * Successful Response + */ +export const zGetWellDetailsThingWaterWellThingIdDetailsGetResponse = zWellDetailsResponse; + +export const zGetWellExportThingWaterWellThingIdExportGetData = z.object({ + body: z.optional(z.never()), + path: z.object({ + thing_id: z.int() + }), + query: z.optional(z.never()) +}); + +/** + * Successful Response + */ +export const zGetWellExportThingWaterWellThingIdExportGetResponse = zWellExportResponse; + export const zGetWellScreensByWellIdThingWaterWellThingIdWellScreenGetData = z.object({ body: z.optional(z.never()), path: z.object({ From 48602fff8a6462e52f41be351300fd23d6960f40 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 31 Mar 2026 08:14:18 -0600 Subject: [PATCH 3/6] feat(games): add Minesweeper game modal and integrate with search functionality; adjust dimensions for existing game modals --- src/components/AsteroidsGameModal.tsx | 19 +- src/components/MinesweeperGameModal.tsx | 268 +++++++++++++++++++++++ src/components/RaceCarGameModal.tsx | 17 +- src/components/SearchModal.tsx | 11 +- src/components/SnakeGameModal.tsx | 9 +- src/components/TetrisGameModal.tsx | 15 +- src/test/components/SearchModal.test.tsx | 15 ++ 7 files changed, 340 insertions(+), 14 deletions(-) create mode 100644 src/components/MinesweeperGameModal.tsx diff --git a/src/components/AsteroidsGameModal.tsx b/src/components/AsteroidsGameModal.tsx index 7dc39b06..c9867acf 100644 --- a/src/components/AsteroidsGameModal.tsx +++ b/src/components/AsteroidsGameModal.tsx @@ -39,8 +39,8 @@ type AsteroidsGameModalProps = { onClose: () => void } -const ARENA_WIDTH = 640 -const ARENA_HEIGHT = 420 +const ARENA_WIDTH = 520 +const ARENA_HEIGHT = 320 const SHIP_RADIUS = 12 const SHOT_SPEED = 6 const TURN_SPEED = 0.09 @@ -242,9 +242,20 @@ export const AsteroidsGameModal = ({ open, onClose }: AsteroidsGameModalProps) = }, [ship.angle, ship.x, ship.y]) return ( - + Asteroids - + diff --git a/src/components/MinesweeperGameModal.tsx b/src/components/MinesweeperGameModal.tsx new file mode 100644 index 00000000..c51f6c07 --- /dev/null +++ b/src/components/MinesweeperGameModal.tsx @@ -0,0 +1,268 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material' + +type Cell = { + isMine: boolean + isRevealed: boolean + isFlagged: boolean + adjacent: number +} + +type MinesweeperGameModalProps = { + open: boolean + onClose: () => void +} + +const ROWS = 9 +const COLS = 9 +const MINES = 10 + +const createBoard = (): Cell[][] => { + const board = Array.from({ length: ROWS }, () => + Array.from({ length: COLS }, () => ({ + isMine: false, + isRevealed: false, + isFlagged: false, + adjacent: 0, + })) + ) + + let placed = 0 + while (placed < MINES) { + const row = Math.floor(Math.random() * ROWS) + const col = Math.floor(Math.random() * COLS) + if (!board[row][col].isMine) { + board[row][col].isMine = true + placed += 1 + } + } + + for (let row = 0; row < ROWS; row += 1) { + for (let col = 0; col < COLS; col += 1) { + let adjacent = 0 + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (!dx && !dy) continue + const nextRow = row + dy + const nextCol = col + dx + if ( + nextRow >= 0 && + nextRow < ROWS && + nextCol >= 0 && + nextCol < COLS && + board[nextRow][nextCol].isMine + ) { + adjacent += 1 + } + } + } + board[row][col].adjacent = adjacent + } + } + + return board +} + +const revealFlood = (board: Cell[][], row: number, col: number) => { + const nextBoard = board.map((cells) => cells.map((cell) => ({ ...cell }))) + const queue: Array<[number, number]> = [[row, col]] + + while (queue.length > 0) { + const [currentRow, currentCol] = queue.shift()! + const cell = nextBoard[currentRow][currentCol] + if (cell.isRevealed || cell.isFlagged) continue + cell.isRevealed = true + + if (cell.adjacent !== 0 || cell.isMine) continue + + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (!dx && !dy) continue + const nextRow = currentRow + dy + const nextCol = currentCol + dx + if (nextRow >= 0 && nextRow < ROWS && nextCol >= 0 && nextCol < COLS) { + queue.push([nextRow, nextCol]) + } + } + } + } + + return nextBoard +} + +export const MinesweeperGameModal = ({ open, onClose }: MinesweeperGameModalProps) => { + const [board, setBoard] = useState(createBoard) + const [isGameOver, setIsGameOver] = useState(false) + const [isVictory, setIsVictory] = useState(false) + + const resetGame = () => { + setBoard(createBoard()) + setIsGameOver(false) + setIsVictory(false) + } + + useEffect(() => { + if (open) { + resetGame() + } + }, [open]) + + const revealCell = (row: number, col: number) => { + if (isGameOver || isVictory) return + const cell = board[row][col] + if (cell.isFlagged || cell.isRevealed) return + + if (cell.isMine) { + setBoard((currentBoard) => + currentBoard.map((cells) => + cells.map((currentCell) => ({ + ...currentCell, + isRevealed: currentCell.isRevealed || currentCell.isMine, + })) + ) + ) + setIsGameOver(true) + return + } + + const nextBoard = revealFlood(board, row, col) + setBoard(nextBoard) + + const hiddenSafeCells = nextBoard.flat().filter((currentCell) => !currentCell.isMine && !currentCell.isRevealed) + if (hiddenSafeCells.length === 0) { + setIsVictory(true) + } + } + + const toggleFlag = (row: number, col: number) => { + if (isGameOver || isVictory) return + setBoard((currentBoard) => + currentBoard.map((cells, rowIndex) => + cells.map((cell, colIndex) => + rowIndex === row && colIndex === col && !cell.isRevealed + ? { ...cell, isFlagged: !cell.isFlagged } + : cell + ) + ) + ) + } + + const minesLeft = useMemo( + () => MINES - board.flat().filter((cell) => cell.isFlagged).length, + [board] + ) + + return ( + + Minesweeper + + + + + Mines left: {minesLeft} + + + + + + {board.flatMap((row, rowIndex) => + row.map((cell, colIndex) => ( + revealCell(rowIndex, colIndex)} + onContextMenu={(event) => { + event.preventDefault() + toggleFlag(rowIndex, colIndex) + }} + sx={{ + aspectRatio: '1 / 1', + border: 'none', + borderRadius: 0.5, + fontSize: 14, + fontWeight: 700, + cursor: 'pointer', + bgcolor: cell.isRevealed ? '#e2e8f0' : '#94a3b8', + color: cell.isMine ? '#dc2626' : '#0f172a', + boxShadow: cell.isRevealed ? 'inset 0 1px 0 rgba(255,255,255,0.4)' : 'inset 0 1px 0 rgba(255,255,255,0.25)', + }} + > + {cell.isRevealed + ? cell.isMine + ? '*' + : cell.adjacent || '' + : cell.isFlagged + ? 'F' + : ''} + + )) + )} + + + + Click to reveal. Right-click to flag. Clear every safe cell without opening a mine. + + + {(isGameOver || isVictory) && ( + + + {isVictory ? 'Board cleared' : 'Boom'} + + + {isVictory ? 'You found every mine.' : 'You hit a mine.'} Restart to play again. + + + )} + + + + ) +} + +export default MinesweeperGameModal diff --git a/src/components/RaceCarGameModal.tsx b/src/components/RaceCarGameModal.tsx index 1ac0d532..7fa0c030 100644 --- a/src/components/RaceCarGameModal.tsx +++ b/src/components/RaceCarGameModal.tsx @@ -35,7 +35,7 @@ type RaceCarGameModalProps = { } const VIEW_WIDTH = 420 -const VIEW_HEIGHT = 560 +const VIEW_HEIGHT = 430 const SEGMENT_HEIGHT = 28 const SEGMENT_COUNT = Math.ceil(VIEW_HEIGHT / SEGMENT_HEIGHT) + 3 const BASE_ROAD_WIDTH = 350 @@ -336,9 +336,20 @@ export const RaceCarGameModal = ({ open, onClose }: RaceCarGameModalProps) => { ) return ( - + Race Car - + diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 240119f6..efd002d0 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -27,8 +27,9 @@ import { SnakeGameModal } from '@/components/SnakeGameModal' import { AsteroidsGameModal } from '@/components/AsteroidsGameModal' import { RaceCarGameModal } from '@/components/RaceCarGameModal' import { TetrisGameModal } from '@/components/TetrisGameModal' +import { MinesweeperGameModal } from '@/components/MinesweeperGameModal' -type ArcadeGame = 'snake' | 'asteroids' | 'racecar' | 'tetris' +type ArcadeGame = 'snake' | 'asteroids' | 'racecar' | 'tetris' | 'minesweeper' // ---- type icon mapping ------------------------------------------------ @@ -182,6 +183,8 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { ? 'racecar' : normalizedQuery === 'tetris' ? 'tetris' + : normalizedQuery === 'minesweeper' + ? 'minesweeper' : null // Reload history each time modal opens @@ -222,7 +225,8 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { normalizedDebounced !== 'snake' && normalizedDebounced !== 'asteroids' && normalizedDebounced !== 'racecar' && - normalizedDebounced !== 'tetris', + normalizedDebounced !== 'tetris' && + normalizedDebounced !== 'minesweeper', staleTime: 120_000, refetchOnReconnect: false, refetchOnWindowFocus: false, @@ -380,7 +384,7 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { {requestedGame && ( - Opening {requestedGame === 'snake' ? 'Snake' : requestedGame === 'asteroids' ? 'Asteroids' : requestedGame === 'racecar' ? 'Race Car' : 'Tetris'}... + Opening {requestedGame === 'snake' ? 'Snake' : requestedGame === 'asteroids' ? 'Asteroids' : requestedGame === 'racecar' ? 'Race Car' : requestedGame === 'tetris' ? 'Tetris' : 'Minesweeper'}... )} @@ -463,6 +467,7 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { + ) } diff --git a/src/components/SnakeGameModal.tsx b/src/components/SnakeGameModal.tsx index 95a9cc54..1330af45 100644 --- a/src/components/SnakeGameModal.tsx +++ b/src/components/SnakeGameModal.tsx @@ -171,9 +171,14 @@ export const SnakeGameModal = ({ open, onClose }: SnakeGameModalProps) => { fullWidth maxWidth="sm" aria-labelledby="snake-game-title" + sx={{ + '& .MuiDialog-paper': { + maxHeight: 'calc(100vh - 32px)', + }, + }} > Snake - + @@ -193,7 +198,7 @@ export const SnakeGameModal = ({ open, onClose }: SnakeGameModalProps) => { gap: 0.5, p: 1, width: '100%', - maxWidth: 560, + maxWidth: 420, mx: 'auto', borderRadius: 2, bgcolor: '#0f172a', diff --git a/src/components/TetrisGameModal.tsx b/src/components/TetrisGameModal.tsx index ec7d8184..0f031b4d 100644 --- a/src/components/TetrisGameModal.tsx +++ b/src/components/TetrisGameModal.tsx @@ -427,9 +427,20 @@ export const TetrisGameModal = ({ open, onClose }: TetrisGameModalProps) => { }, [activePiece, board]) return ( - + Tetris - + diff --git a/src/test/components/SearchModal.test.tsx b/src/test/components/SearchModal.test.tsx index 3e1b352b..d228facc 100644 --- a/src/test/components/SearchModal.test.tsx +++ b/src/test/components/SearchModal.test.tsx @@ -101,4 +101,19 @@ describe('SearchModal arcade easter eggs', () => { expect(screen.getByText('Lines: 0')).toBeTruthy() expect(screen.getByRole('grid', { name: 'Tetris game board' })).toBeTruthy() }) + + it('opens the Minesweeper game when the query is minesweeper', async () => { + const user = userEvent.setup() + + render() + + await user.type(screen.getByRole('textbox', { name: 'Search' }), 'minesweeper') + + await waitFor(() => { + expect(screen.getByRole('dialog', { name: 'Minesweeper' })).toBeTruthy() + }) + + expect(screen.getByText('Mines left: 10')).toBeTruthy() + expect(screen.getByRole('grid', { name: 'Minesweeper game board' })).toBeTruthy() + }) }) From b0884499fd24e1c73ad22385e3310d2871ea27e7 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 31 Mar 2026 08:55:43 -0600 Subject: [PATCH 4/6] feat(games): add Asteroids, Minesweeper, Race Car, Snake, and Tetris game modals with integrated game logic and UI --- .../EasterEggsGames}/AsteroidsGameModal.tsx | 0 .../EasterEggsGames}/MinesweeperGameModal.tsx | 0 .../EasterEggsGames}/RaceCarGameModal.tsx | 0 .../{ => Search/EasterEggsGames}/SnakeGameModal.tsx | 0 .../{ => Search/EasterEggsGames}/TetrisGameModal.tsx | 0 src/components/Search/EasterEggsGames/index.ts | 5 +++++ src/components/SearchModal.tsx | 12 +++++++----- 7 files changed, 12 insertions(+), 5 deletions(-) rename src/components/{ => Search/EasterEggsGames}/AsteroidsGameModal.tsx (100%) rename src/components/{ => Search/EasterEggsGames}/MinesweeperGameModal.tsx (100%) rename src/components/{ => Search/EasterEggsGames}/RaceCarGameModal.tsx (100%) rename src/components/{ => Search/EasterEggsGames}/SnakeGameModal.tsx (100%) rename src/components/{ => Search/EasterEggsGames}/TetrisGameModal.tsx (100%) create mode 100644 src/components/Search/EasterEggsGames/index.ts diff --git a/src/components/AsteroidsGameModal.tsx b/src/components/Search/EasterEggsGames/AsteroidsGameModal.tsx similarity index 100% rename from src/components/AsteroidsGameModal.tsx rename to src/components/Search/EasterEggsGames/AsteroidsGameModal.tsx diff --git a/src/components/MinesweeperGameModal.tsx b/src/components/Search/EasterEggsGames/MinesweeperGameModal.tsx similarity index 100% rename from src/components/MinesweeperGameModal.tsx rename to src/components/Search/EasterEggsGames/MinesweeperGameModal.tsx diff --git a/src/components/RaceCarGameModal.tsx b/src/components/Search/EasterEggsGames/RaceCarGameModal.tsx similarity index 100% rename from src/components/RaceCarGameModal.tsx rename to src/components/Search/EasterEggsGames/RaceCarGameModal.tsx diff --git a/src/components/SnakeGameModal.tsx b/src/components/Search/EasterEggsGames/SnakeGameModal.tsx similarity index 100% rename from src/components/SnakeGameModal.tsx rename to src/components/Search/EasterEggsGames/SnakeGameModal.tsx diff --git a/src/components/TetrisGameModal.tsx b/src/components/Search/EasterEggsGames/TetrisGameModal.tsx similarity index 100% rename from src/components/TetrisGameModal.tsx rename to src/components/Search/EasterEggsGames/TetrisGameModal.tsx diff --git a/src/components/Search/EasterEggsGames/index.ts b/src/components/Search/EasterEggsGames/index.ts new file mode 100644 index 00000000..3d620818 --- /dev/null +++ b/src/components/Search/EasterEggsGames/index.ts @@ -0,0 +1,5 @@ +export * from './AsteroidsGameModal' +export * from './MinesweeperGameModal' +export * from './RaceCarGameModal' +export * from './SnakeGameModal' +export * from './TetrisGameModal' diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index efd002d0..0d611fe0 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -23,11 +23,13 @@ import { useDebounce, useAbortableList, useSearchHistory } from '@/hooks' import { GroupType } from '@/constants' import { SearchResult, WellResult, ContactResult } from '@/interfaces/ocotillo' import { highlight } from '@/utils' -import { SnakeGameModal } from '@/components/SnakeGameModal' -import { AsteroidsGameModal } from '@/components/AsteroidsGameModal' -import { RaceCarGameModal } from '@/components/RaceCarGameModal' -import { TetrisGameModal } from '@/components/TetrisGameModal' -import { MinesweeperGameModal } from '@/components/MinesweeperGameModal' +import { + SnakeGameModal, + AsteroidsGameModal, + RaceCarGameModal, + TetrisGameModal, + MinesweeperGameModal, +} from '@/components/Search/EasterEggsGames' type ArcadeGame = 'snake' | 'asteroids' | 'racecar' | 'tetris' | 'minesweeper' From 5580023d033af9d676dede4f800f2ecb20d23977 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 31 Mar 2026 10:20:07 -0600 Subject: [PATCH 5/6] refactor(hooks): update import paths for usePrimaryAndSecondaryContact, useAllNotes, and useMostRecentObservation hooks --- src/components/pdf/FieldCompilationNotesPdf.tsx | 2 +- src/components/pdf/well.tsx | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/pdf/FieldCompilationNotesPdf.tsx b/src/components/pdf/FieldCompilationNotesPdf.tsx index 64fed13f..cb698a34 100644 --- a/src/components/pdf/FieldCompilationNotesPdf.tsx +++ b/src/components/pdf/FieldCompilationNotesPdf.tsx @@ -16,7 +16,7 @@ import { formatAppDate, sanitizeContacts, } from '@/utils' -import { usePrimaryAndSecondaryContact } from '@/hooks' +import { usePrimaryAndSecondaryContact } from '@/hooks/usePrimaryAndSecondaryContact' import { formatContactPhones } from './fieldCompilationPhoneFormatter' const styles = StyleSheet.create({ diff --git a/src/components/pdf/well.tsx b/src/components/pdf/well.tsx index 26f9694a..94899c52 100644 --- a/src/components/pdf/well.tsx +++ b/src/components/pdf/well.tsx @@ -10,11 +10,9 @@ import { SensorDeploymentRow, } from '@/utils' import { PDF_DEFAULT_VALUES } from '@/config' -import { - useAllNotes, - useMostRecentObservation, - usePrimaryAndSecondaryContact, -} from '@/hooks' +import { useAllNotes } from '@/hooks/useAllNotes' +import { useMostRecentObservation } from '@/hooks/useMostRecentObservation' +import { usePrimaryAndSecondaryContact } from '@/hooks/usePrimaryAndSecondaryContact' import { AdditionalInformation, CoreInformation, From 9ab1ca6ac4aba9ed51a8a1085f51c3be60948d8a Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 31 Mar 2026 10:30:37 -0600 Subject: [PATCH 6/6] refactor(pdf): enhance FieldCompilationNotesPdf tests with PDF stream decoding and improve SearchModal distance assertion --- .../FieldCompilationNotesPdf.test.ts | 49 +++++++++++++++---- src/test/components/SearchModal.test.tsx | 2 +- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/test/components/FieldCompilationNotesPdf.test.ts b/src/test/components/FieldCompilationNotesPdf.test.ts index 864b98b5..1c834b35 100644 --- a/src/test/components/FieldCompilationNotesPdf.test.ts +++ b/src/test/components/FieldCompilationNotesPdf.test.ts @@ -1,6 +1,7 @@ import { createElement } from 'react' -import { renderToString } from '@react-pdf/renderer' +import { pdf } from '@react-pdf/renderer' import { describe, expect, it } from 'vitest' +import { inflate } from 'pako' import { FieldCompilationNotesPdf } from '@/components/pdf/FieldCompilationNotesPdf' import type { IContact, IWell } from '@/interfaces/ocotillo' import { formatContactPhones } from '@/components/pdf/fieldCompilationPhoneFormatter' @@ -50,6 +51,35 @@ const makeWell = (): IWell => measuring_point_description: 'Top of casing', }) as IWell +const decodePdfStreams = (pdfText: string) => { + const streamPattern = /stream\r?\n([\s\S]*?)\r?\nendstream/g + const decoded: string[] = [] + + for (const match of pdfText.matchAll(streamPattern)) { + const streamContent = match[1] + const bytes = Uint8Array.from(streamContent, (char) => char.charCodeAt(0) & 0xff) + + try { + decoded.push(inflate(bytes, { to: 'string' })) + } catch { + // Ignore non-deflated streams. + } + } + + return decoded.join('\n') +} + +const decodePdfHexStrings = (decodedPdfText: string) => { + const fragments: string[] = [] + + for (const match of decodedPdfText.matchAll(/<([0-9A-Fa-f]+)>/g)) { + const hex = match[1] + fragments.push(Buffer.from(hex, 'hex').toString('latin1')) + } + + return fragments.join('') +} + describe('formatContactPhones', () => { it('formats a single phone number with its type', () => { const contact = makeContact([ @@ -125,7 +155,7 @@ describe('formatContactPhones', () => { describe('FieldCompilationNotesPdf', () => { it('appends a final blank page with the requested text', async () => { - const pdfText = await renderToString( + const pdfBlob = await pdf( createElement(FieldCompilationNotesPdf, { well: makeWell(), contacts: [], @@ -134,17 +164,18 @@ describe('FieldCompilationNotesPdf', () => { sensorDeployments: [], hydrographImage: null, }) as any - ) + ).toBlob() + + const pdfText = Buffer.from(await pdfBlob.arrayBuffer()).toString('latin1') const pageMatches = pdfText.match(/\/Type \/Page\b/g) ?? [] + const decodedText = decodePdfStreams(pdfText) + const decodedVisibleText = decodePdfHexStrings(decodedText) expect(pageMatches).toHaveLength(4) - expect(pdfText).toContain('Hydrograph and Manual Measurements: Well-1') - expect(pdfText).toContain('This page is intentionally left blank') - expect( - pdfText.indexOf('This page is intentionally left blank') - ).toBeGreaterThan( - pdfText.indexOf('Hydrograph and Manual Measurements: Well-1') + expect(decodedVisibleText).toContain( + 'Hydrograph and Manual Measurements: Well-1' ) + expect(decodedVisibleText).toContain('This page is intentionally left blank') }) }) diff --git a/src/test/components/SearchModal.test.tsx b/src/test/components/SearchModal.test.tsx index d228facc..7677a69a 100644 --- a/src/test/components/SearchModal.test.tsx +++ b/src/test/components/SearchModal.test.tsx @@ -82,7 +82,7 @@ describe('SearchModal arcade easter eggs', () => { expect(screen.getByRole('dialog', { name: 'Race Car' })).toBeTruthy() }) - expect(screen.getByText('Distance: 0 m')).toBeTruthy() + expect(screen.getByText(/Distance:\s+\d+\s+m/)).toBeTruthy() expect(screen.getByRole('img', { name: 'Race car game board' })).toBeTruthy() })