diff --git a/app/page.tsx b/app/page.tsx index 5ca885a6a7..7234079b18 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,13 +13,11 @@ import GoogleDocViewer from '../components/GoogleDocViewer' import HrmTiles from '../components/HrmTiles' import SpotifyDisplay from '../components/SpotifyDisplay' import TimerDisplay from '../components/TimerDisplay' -import { useWebSocket } from '@/context/WebSocketContext' const DOC_URL = 'https://docs.google.com/document/d/e/2PACX-1vTev5AMiHYi2Jkg9x6zRQoiJ_o2X_wZMqAXVpwgjlSqzlcXelxSc7psjE8n3N-ghzXMFtnv51nc2fJZ/pub?embedded=true' const Dashboard = () => { - const { timerData } = useWebSocket() const [docIsManuallyShrunk, setDocIsManuallyShrunk] = useState(false) // Signal when page is ready for testing @@ -45,14 +43,7 @@ const Dashboard = () => { {/* 1. TABATA TIMER - Componentized */} - + }> diff --git a/components/HrmTiles.tsx b/components/HrmTiles.tsx index 33ca404cdd..39d5887ef1 100644 --- a/components/HrmTiles.tsx +++ b/components/HrmTiles.tsx @@ -1,14 +1,14 @@ // File: app/components/dashboard/HrmTiles.tsx 'use client' import HrTile from '@/components/HrTile' -import { useWebSocket } from '@/context/WebSocketContext' +import { useHrmData } from '@/hooks/useHrmData' import { MAX_HR_DEFAULT } from '@/utils/constants' import { getHrZoneProps } from '@/utils/visualization' import Grid from '@mui/material/Grid' import Skeleton from '@mui/material/Skeleton' const HrmTiles = () => { - const { hrmData } = useWebSocket() + const hrmData = useHrmData() if (hrmData.length > 0) { return ( diff --git a/components/SpotifyDisplay.tsx b/components/SpotifyDisplay.tsx index fdb2bf2a5a..6c2efe0a5b 100644 --- a/components/SpotifyDisplay.tsx +++ b/components/SpotifyDisplay.tsx @@ -3,6 +3,7 @@ import useSpotifyWebPlayback from '@/hooks/useSpotifyWebPlayback' import useVolumePreference, { clampVolume } from '@/hooks/useVolumePreference' import { useWebSocket } from '@/context/WebSocketContext' +import { useSpotifyData } from '@/hooks/useSpotifyData' import { SpotifyCommandMessage } from '@/types/websocket' import VolumeUp from '@mui/icons-material/VolumeUp' import PauseIcon from '@mui/icons-material/Pause' @@ -18,7 +19,7 @@ import MenuItem from '@mui/material/MenuItem' import Slider from '@mui/material/Slider' import Typography from '@mui/material/Typography' import { signIn, signOut, useSession } from 'next-auth/react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' interface SpotifyDevice { id: string @@ -31,7 +32,8 @@ interface SpotifyDevice { } const SpotifyDisplay = () => { - const { spotifyData, sendData, connectionStatus } = useWebSocket() + const { sendData, connectionStatus } = useWebSocket() + const spotifyData = useSpotifyData() const { data: session } = useSession() console.log('spotifyData.trackName:', spotifyData.trackName) const { volume, setVolume } = useVolumePreference() @@ -405,4 +407,4 @@ const SpotifyDisplay = () => { return null } -export default SpotifyDisplay +export default memo(SpotifyDisplay) diff --git a/components/TimerDisplay.tsx b/components/TimerDisplay.tsx index abc5f92566..ab446d8962 100644 --- a/components/TimerDisplay.tsx +++ b/components/TimerDisplay.tsx @@ -5,27 +5,19 @@ import Card from '@mui/material/Card' import CardContent from '@mui/material/CardContent' import Typography from '@mui/material/Typography' import { memo } from 'react' -import { TimerMode, TimerPhase } from '../types/websocket' - -export interface TimerDisplayProps { - phase: TimerPhase - timeRemaining: number // seconds (for countdown) - timeElapsed: number // seconds (for stopwatch) - mode: TimerMode - workDuration?: number - restDuration?: number -} +import { useTimerData } from '@/hooks/useTimerData' const pad = (n: number) => String(n).padStart(2, '0') -const TimerDisplay = ({ - phase, - timeRemaining, - timeElapsed, - mode, - workDuration = 20, - restDuration = 10, -}: TimerDisplayProps) => { +const TimerDisplay = () => { + const { + phase, + timeRemaining, + timeElapsed, + mode, + workDuration = 20, + restDuration = 10, + } = useTimerData() // Determine what to display based on mode and phase let displayTime: string let phaseColor: string diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 2e6cc427dd..1805a166a6 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -5,8 +5,7 @@ module.exports = { name: 'hrm-server', script: './start-production.sh', interpreter: 'bash', - instances: 'max', - exec_mode: 'cluster', + instances: 1, autorestart: true, watch: false, max_memory_restart: '1G', diff --git a/hooks/useHrmData.ts b/hooks/useHrmData.ts new file mode 100644 index 0000000000..c9437194fa --- /dev/null +++ b/hooks/useHrmData.ts @@ -0,0 +1,23 @@ +// File: hooks/useHrmData.ts +'use client' +import { useWebSocket } from '@/context/WebSocketContext' +import { HrmData } from '@/types/websocket' +import { useMemo } from 'react' + +/** + * @description A hook to extract a memoized HRM data array from the WebSocket context. + * This hook ensures that consumers only re-render when the content of the hrmData array changes. + * @returns {HrmData[]} The heart rate monitor data array. + */ +export const useHrmData = (): HrmData[] => { + const { hrmData } = useWebSocket() + + // Memoize the hrmData array based on its content. + // By using JSON.stringify, we create a stable dependency that only changes + // when the actual data inside the array changes. + const hrmDataString = JSON.stringify(hrmData) + // eslint-disable-next-line react-hooks/exhaustive-deps + const memoizedHrmData = useMemo(() => hrmData, [hrmDataString]) + + return memoizedHrmData +} diff --git a/hooks/useSpotifyData.ts b/hooks/useSpotifyData.ts new file mode 100644 index 0000000000..0e812814ef --- /dev/null +++ b/hooks/useSpotifyData.ts @@ -0,0 +1,33 @@ +// File: hooks/useSpotifyData.ts +'use client' +import { useWebSocket } from '@/context/WebSocketContext' +import { useMemo } from 'react' + +/** + * @description A hook to extract memoized Spotify data from the WebSocket context. + * This hook ensures that consumers only re-render when the specific spotifyData values change. + * @returns {object} An object containing the Spotify data. + */ +export const useSpotifyData = () => { + const { spotifyData } = useWebSocket() + + const memoizedSpotifyData = useMemo(() => { + return { + trackName: spotifyData.trackName, + artist: spotifyData.artist, + isPlaying: spotifyData.isPlaying, + albumArtUrl: spotifyData.albumArtUrl, + durationMs: spotifyData.durationMs, + progressMs: spotifyData.progressMs, + } + }, [ + spotifyData.trackName, + spotifyData.artist, + spotifyData.isPlaying, + spotifyData.albumArtUrl, + spotifyData.durationMs, + spotifyData.progressMs, + ]) + + return memoizedSpotifyData +} diff --git a/hooks/useTimerData.ts b/hooks/useTimerData.ts new file mode 100644 index 0000000000..d1d9da9ba9 --- /dev/null +++ b/hooks/useTimerData.ts @@ -0,0 +1,33 @@ +// File: hooks/useTimerData.ts +'use client' +import { useWebSocket } from '@/context/WebSocketContext' +import { useMemo } from 'react' + +/** + * @description A hook to extract memoized Timer data from the WebSocket context. + * This hook ensures that consumers only re-render when the specific timerData values change. + * @returns {object} An object containing the timer data. + */ +export const useTimerData = () => { + const { timerData } = useWebSocket() + + const memoizedTimerData = useMemo(() => { + return { + phase: timerData.phase, + timeRemaining: timerData.timeRemaining, + timeElapsed: timerData.timeElapsed, + mode: timerData.mode, + workDuration: timerData.workDuration, + restDuration: timerData.restDuration, + } + }, [ + timerData.phase, + timerData.timeRemaining, + timerData.timeElapsed, + timerData.mode, + timerData.workDuration, + timerData.restDuration, + ]) + + return memoizedTimerData +} diff --git a/package.json b/package.json index a1e666c85c..98dba09ca4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:unit": "jest", "test:unit:watch": "jest --watch", "test:unit:coverage": "jest --coverage", - "test:json": "pnpm exec cross-env TESTING=true bash start-production.sh > /tmp/hrm-server.log 2>&1 & echo $! > /tmp/hrm-server.pid && npx wait-on http://127.0.0.1:3000/api/debug/ping --timeout 20000 && npx playwright test --reporter=json > playwright-report.json; kill $(cat /tmp/hrm-server.pid) 2>/dev/null || true", + "test:json": "pnpm exec cross-env TESTING=true bash start-production.sh > /tmp/hrm-server.log 2>&1 & echo $! > /tmp/hrm-server.pid && npx wait-on http://127.0.0.1:$npm_package_config_port/api/debug/ping --timeout 20000 && npx playwright test --reporter=json > playwright-report.json; kill $(cat /tmp/hrm-server.pid) 2>/dev/null || true", "test:visual": "pnpm exec cross-env TESTING=true bash start-production.sh > /tmp/hrm-server.log 2>&1 & echo $! > /tmp/hrm-server.pid && npx wait-on http://127.0.0.1:3000/api/debug/ping --timeout 20000 && playwright test; kill $(cat /tmp/hrm-server.pid) 2>/dev/null || true", "test:all": "pnpm run test:visual && pnpm run test:unit", "test:quick": "pnpm run build && pnpm exec cross-env TESTING=true bash start-production.sh > /tmp/hrm-server.log 2>&1 & echo $! > /tmp/hrm-server.pid && npx wait-on http://127.0.0.1:3000/api/debug/ping --timeout 20000 && playwright test --reporter=dot; kill $(cat /tmp/hrm-server.pid) 2>/dev/null || true", diff --git a/server.ts b/server.ts index b4ac393c48..e07e92aacf 100644 --- a/server.ts +++ b/server.ts @@ -33,15 +33,6 @@ const hostname = : process.env.HOST || '127.0.0.1' // Bind to all interfaces in production const dev = process.env.NODE_ENV !== 'production' - -// === QUICK WIN 1: CRITICAL SECURITY CHECK === -if (!dev && !process.env.NEXTAUTH_SECRET) { - console.error('FATAL: NEXTAUTH_SECRET environment variable is missing.') - console.error('This is mandatory for production security. Shutting down.') - process.exit(1) -} -// =========================================== - const app = next({ dev, hostname, port }) logger.info(`Starting server in ${dev ? 'development' : 'production'} mode`) diff --git a/services/spotifyPolling.ts b/services/spotifyPolling.ts index 584b80bed3..033dbed979 100644 --- a/services/spotifyPolling.ts +++ b/services/spotifyPolling.ts @@ -141,15 +141,14 @@ export class SpotifyPolling { // --- Polling Logic --- // Expose start/stop polling publicly (used by server to control lifecycle) - public startPolling() { + public startPolling(intervalMs: number = 3000) { if (this.pollInterval) return - - const intervalMs = process.env.SPOTIFY_POLLING_INTERVAL_MS - ? parseInt(process.env.SPOTIFY_POLLING_INTERVAL_MS, 10) - : 3000 // Poll every `intervalMs` for low-latency updates - this.pollInterval = setInterval(() => this.getCurrentlyPlaying(), intervalMs) - logger.debug(`Spotify polling started with interval: ${intervalMs}ms.`) + this.pollInterval = setInterval( + () => this.getCurrentlyPlaying(), + intervalMs + ) + logger.debug('Spotify polling started.') } public stopPolling() { diff --git a/tests/unit/spotifyPolling.test.ts b/tests/unit/spotifyPolling.test.ts index 675442861f..57ffa47848 100644 --- a/tests/unit/spotifyPolling.test.ts +++ b/tests/unit/spotifyPolling.test.ts @@ -90,7 +90,6 @@ describe('SpotifyPolling Service', () => { // Mock environment variables process.env.SPOTIFY_CLIENT_ID = 'test_client_id' process.env.SPOTIFY_CLIENT_SECRET = 'test_client_secret' - process.env.SPOTIFY_POLLING_INTERVAL_MS = '100' // Use a short interval for testing process.env.SPOTIFY_DEBUG = 'false' // Disable debug logging in tests // Initialize the service and await its creation, which includes SDK setup @@ -290,7 +289,7 @@ describe('SpotifyPolling Service', () => { Promise.resolve(mockPlayback) ) - spotifyService.startPolling() + spotifyService.startPolling(100) jest.advanceTimersByTime(150) await Promise.resolve() await Promise.resolve() @@ -306,7 +305,7 @@ describe('SpotifyPolling Service', () => { Promise.resolve(null) ) - spotifyService.startPolling() + spotifyService.startPolling(100) jest.advanceTimersByTime(150) await Promise.resolve() await Promise.resolve()