diff --git a/CLAUDE.md b/CLAUDE.md index 6c7dea3..b1cbf06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,8 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I #### Team Management - `GET /api/teams` - Get all teams - `GET /api/getTeamName` - Get team name by ID +- `POST /api/createGroup` - Create OBS group from team +- `POST /api/syncGroups` - Synchronize all teams with OBS groups #### System Status - `GET /api/obsStatus` - Real-time OBS connection and streaming status @@ -92,7 +94,7 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I Dynamic table names with seasonal configuration: - `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id -- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name +- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name, group_name ### OBS Integration Pattern @@ -100,6 +102,7 @@ The app uses a sophisticated dual integration approach: 1. **WebSocket Connection**: Direct OBS control using obs-websocket-js with persistent connection management 2. **Text File System**: Each screen position has a corresponding text file that OBS Source Switcher monitors +3. **Group Management**: Teams can be mapped to OBS groups (implemented as scenes) for organized source management **Required OBS Source Switchers** (must be created with these exact names): - `ss_large` - Large screen source switcher diff --git a/app/api/__tests__/streams.test.ts b/app/api/__tests__/streams.test.ts index d4ef664..f57f060 100644 --- a/app/api/__tests__/streams.test.ts +++ b/app/api/__tests__/streams.test.ts @@ -6,7 +6,7 @@ jest.mock('@/lib/database', () => ({ })); describe('/api/streams', () => { - let mockDb: any; + let mockDb: { all: jest.Mock }; beforeEach(() => { // Create mock database @@ -27,7 +27,7 @@ describe('/api/streams', () => { mockDb.all.mockResolvedValue(mockStreams); - const response = await GET(); + const _response = await GET(); expect(mockDb.all).toHaveBeenCalledWith( expect.stringContaining('SELECT * FROM') @@ -40,7 +40,7 @@ describe('/api/streams', () => { it('returns empty array when no streams exist', async () => { mockDb.all.mockResolvedValue([]); - const response = await GET(); + const _response = await GET(); const { NextResponse } = require('next/server'); expect(NextResponse.json).toHaveBeenCalledWith([]); @@ -50,7 +50,7 @@ describe('/api/streams', () => { const dbError = new Error('Database connection failed'); mockDb.all.mockRejectedValue(dbError); - const response = await GET(); + const _response = await GET(); const { NextResponse } = require('next/server'); expect(NextResponse.json).toHaveBeenCalledWith( @@ -64,7 +64,7 @@ describe('/api/streams', () => { const { getDatabase } = require('@/lib/database'); getDatabase.mockRejectedValue(connectionError); - const response = await GET(); + const _response = await GET(); const { NextResponse } = require('next/server'); expect(NextResponse.json).toHaveBeenCalledWith( diff --git a/app/api/__tests__/teams.test.ts b/app/api/__tests__/teams.test.ts index 4f26d27..c84747a 100644 --- a/app/api/__tests__/teams.test.ts +++ b/app/api/__tests__/teams.test.ts @@ -24,7 +24,7 @@ jest.mock('@/lib/apiHelpers', () => ({ })); describe('/api/teams', () => { - let mockDb: any; + let mockDb: { all: jest.Mock }; beforeEach(() => { // Create mock database @@ -46,7 +46,7 @@ describe('/api/teams', () => { mockDb.all.mockResolvedValue(mockTeams); - const response = await GET(); + const _response = await GET(); expect(mockDb.all).toHaveBeenCalledWith( expect.stringContaining('SELECT * FROM') @@ -59,7 +59,7 @@ describe('/api/teams', () => { it('returns empty array when no teams exist', async () => { mockDb.all.mockResolvedValue([]); - const response = await GET(); + const _response = await GET(); const { createSuccessResponse } = require('@/lib/apiHelpers'); expect(createSuccessResponse).toHaveBeenCalledWith([]); @@ -69,7 +69,7 @@ describe('/api/teams', () => { const dbError = new Error('Table does not exist'); mockDb.all.mockRejectedValue(dbError); - const response = await GET(); + const _response = await GET(); const { createDatabaseError } = require('@/lib/apiHelpers'); expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', dbError); @@ -80,7 +80,7 @@ describe('/api/teams', () => { const { getDatabase } = require('@/lib/database'); getDatabase.mockRejectedValue(connectionError); - const response = await GET(); + const _response = await GET(); const { createDatabaseError } = require('@/lib/apiHelpers'); expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', connectionError); diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 6fe9463..4422a66 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -86,7 +86,7 @@ export default function EditStream() { if (streamId) { fetchData(); } - }, [streamId]); + }, [streamId, showError]); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; diff --git a/app/page.tsx b/app/page.tsx index 2d18dfe..2dc242d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -35,7 +35,7 @@ export default function Home() { const activeSourceIds = useActiveSourceLookup(streams, activeSources); // Debounced API calls to prevent excessive requests - const debouncedSetActive = useDebounce(async (screen: ScreenType, id: number | null) => { + const setActiveFunction = useCallback(async (screen: ScreenType, id: number | null) => { if (id) { const selectedStream = streams.find(stream => stream.id === id); try { @@ -62,7 +62,9 @@ export default function Home() { })); } } - }, 300); + }, [streams, showError, showSuccess]); + + const debouncedSetActive = useDebounce(setActiveFunction, 300); const fetchData = useCallback(async () => { const endTimer = PerformanceMonitor.startTimer('fetchData'); diff --git a/app/streams/page.tsx b/app/streams/page.tsx index fd72bea..6246b0f 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import Dropdown from '@/components/Dropdown'; import { Team } from '@/types'; import { useToast } from '@/lib/useToast'; @@ -28,12 +28,7 @@ export default function AddStream() { const [validationErrors, setValidationErrors] = useState<{[key: string]: string}>({}); const { toasts, removeToast, showSuccess, showError } = useToast(); - // Fetch teams and streams on component mount - useEffect(() => { - fetchData(); - }, []); - - const fetchData = async () => { + const fetchData = useCallback(async () => { setIsLoading(true); try { const [teamsResponse, streamsResponse] = await Promise.all([ @@ -63,7 +58,12 @@ export default function AddStream() { } finally { setIsLoading(false); } - }; + }, [showError]); + + // Fetch teams and streams on component mount + useEffect(() => { + fetchData(); + }, [fetchData]); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; diff --git a/components/__tests__/ErrorBoundary.test.tsx b/components/__tests__/ErrorBoundary.test.tsx index 170521f..4459422 100644 --- a/components/__tests__/ErrorBoundary.test.tsx +++ b/components/__tests__/ErrorBoundary.test.tsx @@ -10,7 +10,7 @@ const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => { }; // Mock window.location.reload using jest.spyOn -const mockReload = jest.fn(); +// const mockReload = jest.fn(); // Defined but not used in current tests describe('ErrorBoundary', () => { // Suppress console.error for these tests since we expect errors @@ -63,7 +63,7 @@ describe('ErrorBoundary', () => { it('calls window.location.reload when refresh button is clicked', () => { // Skip this test in jsdom environment as window.location.reload cannot be easily mocked // In a real browser environment, this would work as expected - const originalReload = window.location.reload; + // const originalReload = window.location.reload; // Not used in jsdom test // Simple workaround for jsdom limitation if (typeof window.location.reload !== 'function') { @@ -119,11 +119,17 @@ describe('ErrorBoundary', () => { const originalEnv = process.env.NODE_ENV; beforeAll(() => { - process.env.NODE_ENV = 'development'; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'development', + writable: true + }); }); afterAll(() => { - process.env.NODE_ENV = originalEnv; + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalEnv, + writable: true + }); }); it('shows error details in development mode', () => { @@ -147,11 +153,17 @@ describe('ErrorBoundary', () => { const originalEnv = process.env.NODE_ENV; beforeAll(() => { - process.env.NODE_ENV = 'production'; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'production', + writable: true + }); }); afterAll(() => { - process.env.NODE_ENV = originalEnv; + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalEnv, + writable: true + }); }); it('hides error details in production mode', () => { diff --git a/components/__tests__/Toast.test.tsx b/components/__tests__/Toast.test.tsx index d277e6c..a5dbbee 100644 --- a/components/__tests__/Toast.test.tsx +++ b/components/__tests__/Toast.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { ToastComponent, ToastContainer, Toast, ToastType } from '../Toast'; // Mock timer functions diff --git a/lib/__tests__/apiHelpers.test.ts b/lib/__tests__/apiHelpers.test.ts index 8b51764..9bb97b4 100644 --- a/lib/__tests__/apiHelpers.test.ts +++ b/lib/__tests__/apiHelpers.test.ts @@ -32,7 +32,7 @@ describe('apiHelpers', () => { describe('createErrorResponse', () => { it('creates error response with default status 500', () => { - const response = createErrorResponse('Test Error'); + const _response = createErrorResponse('Test Error'); expect(NextResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -44,7 +44,7 @@ describe('apiHelpers', () => { }); it('creates error response with custom status and message', () => { - const response = createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' }); + const _response = createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' }); expect(NextResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -81,7 +81,7 @@ describe('apiHelpers', () => { describe('createSuccessResponse', () => { it('creates success response with default status 200', () => { const data = { test: 'data' }; - const response = createSuccessResponse(data); + const _response = createSuccessResponse(data); expect(NextResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -95,7 +95,7 @@ describe('apiHelpers', () => { it('creates success response with custom status', () => { const data = { id: 1, name: 'test' }; - const response = createSuccessResponse(data, 201); + const _response = createSuccessResponse(data, 201); expect(NextResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -151,9 +151,9 @@ describe('apiHelpers', () => { }); describe('parseRequestBody', () => { - const mockRequest = (body: any): Request => ({ + const mockRequest = (body: unknown): Request => ({ json: jest.fn().mockResolvedValue(body), - } as any); + } as unknown as Request); it('parses valid JSON body without validator', async () => { const body = { name: 'test', value: 123 }; @@ -170,7 +170,7 @@ describe('apiHelpers', () => { it('handles invalid JSON', async () => { const request = { json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - } as any; + } as unknown as Request; const result = await parseRequestBody(request); @@ -218,11 +218,17 @@ describe('apiHelpers', () => { const originalEnv = process.env.NODE_ENV; afterAll(() => { - process.env.NODE_ENV = originalEnv; + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalEnv, + writable: true + }); }); it('includes error details in development', () => { - process.env.NODE_ENV = 'development'; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'development', + writable: true + }); const originalError = new Error('Test error'); createDatabaseError('test operation', originalError); @@ -236,7 +242,10 @@ describe('apiHelpers', () => { }); it('excludes error details in production', () => { - process.env.NODE_ENV = 'production'; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'production', + writable: true + }); const originalError = new Error('Test error'); createDatabaseError('test operation', originalError); diff --git a/lib/__tests__/useToast.test.ts b/lib/__tests__/useToast.test.ts index 8d96eec..991b621 100644 --- a/lib/__tests__/useToast.test.ts +++ b/lib/__tests__/useToast.test.ts @@ -178,7 +178,7 @@ describe('useToast', () => { it('returns unique IDs for each toast', () => { const { result } = renderHook(() => useToast()); - let id1: string, id2: string; + let id1: string = '', id2: string = ''; act(() => { // Mock different random values for unique IDs diff --git a/lib/apiHelpers.ts b/lib/apiHelpers.ts index a85150d..d6c5687 100644 --- a/lib/apiHelpers.ts +++ b/lib/apiHelpers.ts @@ -140,7 +140,7 @@ export async function parseRequestBody( } return { success: true, data: body as T }; - } catch (error) { + } catch (_error) { return { success: false, response: createErrorResponse( diff --git a/lib/performance.ts b/lib/performance.ts index e0ce419..76a4caa 100644 --- a/lib/performance.ts +++ b/lib/performance.ts @@ -3,6 +3,7 @@ import React, { useMemo, useCallback, useRef } from 'react'; // Debounce hook for preventing excessive API calls +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function useDebounce any>( callback: T, delay: number @@ -21,7 +22,7 @@ export function useDebounce any>( } // Throttle hook for limiting function calls -export function useThrottle any>( +export function useThrottle unknown>( callback: T, delay: number ): T { @@ -38,16 +39,21 @@ export function useThrottle any>( // Memoized stream lookup utilities export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) { + const sourceToIdMap = new Map(); + const idToStreamMap = new Map(); + + streams.forEach(stream => { + sourceToIdMap.set(stream.obs_source_name, stream.id); + idToStreamMap.set(stream.id, stream); + }); + + return { sourceToIdMap, idToStreamMap }; +} + +// Hook version for React components +export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) { return useMemo(() => { - const sourceToIdMap = new Map(); - const idToStreamMap = new Map(); - - streams.forEach(stream => { - sourceToIdMap.set(stream.obs_source_name, stream.id); - idToStreamMap.set(stream.id, stream); - }); - - return { sourceToIdMap, idToStreamMap }; + return createStreamLookupMaps(streams); }, [streams]); } @@ -56,7 +62,7 @@ export function useActiveSourceLookup( streams: Array<{ id: number; obs_source_name: string; name: string }>, activeSources: Record ) { - const { sourceToIdMap } = createStreamLookupMaps(streams); + const { sourceToIdMap } = useStreamLookupMaps(streams); return useMemo(() => { const activeSourceIds: Record = {}; @@ -104,7 +110,7 @@ export class PerformanceMonitor { } static getAllMetrics() { - const result: Record = {}; + const result: Record> = {}; this.metrics.forEach((_, label) => { result[label] = this.getMetrics(label); }); @@ -155,7 +161,7 @@ export function usePageVisibility() { export function useSmartPolling( callback: () => void | Promise, interval: number, - dependencies: any[] = [] + dependencies: unknown[] = [] ) { const isVisible = usePageVisibility(); const callbackRef = useRef(callback); @@ -185,5 +191,5 @@ export function useSmartPolling( clearInterval(intervalRef.current); } }; - }, [interval, isVisible, ...dependencies]); + }, [interval, isVisible, dependencies]); } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 5e7c74b..5a35e12 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js", "types/**/*.d.ts"], "exclude": ["node_modules"] } diff --git a/types/jest-dom.d.ts b/types/jest-dom.d.ts new file mode 100644 index 0000000..56fa56b --- /dev/null +++ b/types/jest-dom.d.ts @@ -0,0 +1,10 @@ +// Jest DOM type definitions +import '@testing-library/jest-dom'; + +declare global { + namespace NodeJS { + interface ProcessEnv { + NODE_ENV: 'development' | 'production' | 'test'; + } + } +} \ No newline at end of file