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()