From 0144a984d0bd318bffc97419385d266063f2180a Mon Sep 17 00:00:00 2001 From: Nuwan Date: Tue, 27 Jan 2026 08:00:21 +0530 Subject: [PATCH] docs(7): create phase 7 plans Create 3 TDD-focused plans for Phase 7 (Chat Infrastructure & State Management): - Plan 7.1: Redux Slice & Core Reducers (7 reducers, message deduplication, unread tracking) - Plan 7.2: Async Thunks & API Integration (REST methods, fetchChatHistory, sendMessage with optimistic updates) - Plan 7.3: WebSocket Integration & Selectors (CHAT_MESSAGE handler, 8 memoized selectors, localStorage persistence) All plans follow TDD methodology per CLAUDE.md requirements (RED-GREEN-REFACTOR). Total: 10-12 tasks estimated for Phase 7. Phase 7 builds Redux foundation for chat feature following patterns from Phase 5 (JamTrack). Ready for execution with /gsd:execute-plan commands. --- .../07-chat-infrastructure/07-01-PLAN.md | 454 ++++++++++ .../07-chat-infrastructure/07-02-PLAN.md | 744 ++++++++++++++++ .../07-chat-infrastructure/07-03-PLAN.md | 840 ++++++++++++++++++ 3 files changed, 2038 insertions(+) create mode 100644 .planning/phases/07-chat-infrastructure/07-01-PLAN.md create mode 100644 .planning/phases/07-chat-infrastructure/07-02-PLAN.md create mode 100644 .planning/phases/07-chat-infrastructure/07-03-PLAN.md diff --git a/.planning/phases/07-chat-infrastructure/07-01-PLAN.md b/.planning/phases/07-chat-infrastructure/07-01-PLAN.md new file mode 100644 index 000000000..2cddd70f7 --- /dev/null +++ b/.planning/phases/07-chat-infrastructure/07-01-PLAN.md @@ -0,0 +1,454 @@ +--- +phase: 07-chat-infrastructure +plan: 01 +type: tdd +--- + + +Create sessionChatSlice with initial state structure and core reducers using TDD methodology. + +Purpose: Build Redux foundation for chat feature with multi-channel state management, message deduplication, and unread tracking. All state transitions must be tested following CLAUDE.md TDD requirements. + +Output: sessionChatSlice.js with 7 reducers and comprehensive unit tests. + + + +@./.claude/get-shit-done/workflows/execute-phase.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md + +# Phase 6 design documents +@.planning/phases/06-session-chat-research-design/CHAT_REDUX_DESIGN.md +@.planning/phases/06-session-chat-research-design/IMPLEMENTATION_ROADMAP.md + +# Existing Redux patterns +@jam-ui/src/store/features/mediaSlice.js +@jam-ui/src/store/features/activeSessionSlice.js + +# Codebase conventions +@.planning/codebase/ARCHITECTURE.md +@.planning/codebase/CONVENTIONS.md +@CLAUDE.md + +**Key context from Phase 6:** +- Multi-channel state (global, session, lesson) keyed by channel ID +- Message deduplication by msg_id (critical for WebSocket + REST) +- Unread tracking is NEW functionality (client-side with localStorage) +- State structure designed in CHAT_REDUX_DESIGN.md lines 0-150 +- 7 reducers defined: addMessageFromWebSocket, setActiveChannel, openChatWindow, closeChatWindow, markAsRead, incrementUnreadCount, setWindowPosition + +**TDD requirements from CLAUDE.md:** +- Write test FIRST (RED phase) +- Implement minimum code to pass (GREEN phase) +- Refactor while keeping tests green +- All reducers must have unit tests +- All state transitions must be validated + + + + + + Task 1: Create sessionChatSlice.js with initial state structure (TDD) + jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js + +Using TDD methodology, create the Redux slice file and initial state: + +**RED Phase - Write failing tests first:** + +1. Create test file `jam-ui/src/store/features/__tests__/sessionChatSlice.test.js` +2. Write tests for initial state structure: + ```javascript + describe('sessionChatSlice initial state', () => { + test('has empty messagesByChannel object', () => { + const state = sessionChatReducer(undefined, { type: 'unknown' }); + expect(state.messagesByChannel).toEqual({}); + }); + + test('has null activeChannel', () => { + const state = sessionChatReducer(undefined, { type: 'unknown' }); + expect(state.activeChannel).toBeNull(); + }); + + test('has empty unreadCounts object', () => { + const state = sessionChatReducer(undefined, { type: 'unknown' }); + expect(state.unreadCounts).toEqual({}); + }); + + // ... tests for all initial state fields + }); + ``` +3. Run tests - should FAIL (slice doesn't exist yet) + +**GREEN Phase - Implement minimum code:** + +4. Create `jam-ui/src/store/features/sessionChatSlice.js` with: + ```javascript + import { createSlice } from '@reduxjs/toolkit'; + + const initialState = { + messagesByChannel: {}, + activeChannel: null, + channelType: null, + unreadCounts: {}, + lastReadAt: {}, + fetchStatus: {}, + fetchError: {}, + sendStatus: 'idle', + sendError: null, + nextCursors: {}, + isWindowOpen: false, + windowPosition: null + }; + + const sessionChatSlice = createSlice({ + name: 'sessionChat', + initialState, + reducers: { + // Reducers added in Tasks 2-3 + } + }); + + export const {} = sessionChatSlice.actions; + export default sessionChatSlice.reducer; + ``` +5. Add slice to store configuration in `jam-ui/src/store/index.js` +6. Run tests - should PASS + +**REFACTOR Phase:** + +7. Review state structure against CHAT_REDUX_DESIGN.md +8. Add TypeScript-style JSDoc comments for type hints +9. Ensure tests still pass + +**Verification:** +- All initial state tests pass +- Slice exports reducer and actions +- Store configuration includes sessionChat slice + + +sessionChatSlice.js exists with correct initial state. Unit tests pass. Slice registered in store. TDD methodology followed (RED-GREEN-REFACTOR). + + +Redux slice foundation complete with validated initial state structure. + + + + + Task 2: Implement core reducers with message deduplication (TDD) + jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js + +Using TDD, implement reducers: addMessageFromWebSocket, setActiveChannel, openChatWindow, closeChatWindow + +**RED Phase - Write failing tests first:** + +1. Write tests for `addMessageFromWebSocket`: + ```javascript + describe('addMessageFromWebSocket', () => { + test('adds message to new channel', () => { + const state = { messagesByChannel: {} }; + const action = { + type: 'sessionChat/addMessageFromWebSocket', + payload: { + id: 'msg-1', + senderId: 'user-1', + message: 'Hello', + channel: 'session', + sessionId: 'session-abc', + createdAt: '2026-01-26T12:00:00Z' + } + }; + const newState = sessionChatReducer(state, action); + expect(newState.messagesByChannel['session-abc']).toHaveLength(1); + }); + + test('deduplicates messages by msg_id', () => { + // Test adding same message twice (critical for WebSocket + REST) + const state = { + messagesByChannel: { + 'session-abc': [{ id: 'msg-1', message: 'Hello' }] + } + }; + const action = { + type: 'sessionChat/addMessageFromWebSocket', + payload: { id: 'msg-1', message: 'Hello', /* ... */ } + }; + const newState = sessionChatReducer(state, action); + expect(newState.messagesByChannel['session-abc']).toHaveLength(1); + }); + + test('increments unread count if window closed', () => { + // Test unread tracking logic + }); + }); + ``` + +2. Write tests for `setActiveChannel`: + ```javascript + describe('setActiveChannel', () => { + test('sets active channel and type', () => { + const state = { activeChannel: null, channelType: null }; + const action = { + type: 'sessionChat/setActiveChannel', + payload: { channel: 'session-abc', channelType: 'session' } + }; + const newState = sessionChatReducer(state, action); + expect(newState.activeChannel).toBe('session-abc'); + expect(newState.channelType).toBe('session'); + }); + }); + ``` + +3. Write tests for `openChatWindow` (resets unread count): + ```javascript + describe('openChatWindow', () => { + test('opens window and resets unread count for active channel', () => { + const state = { + isWindowOpen: false, + activeChannel: 'session-abc', + unreadCounts: { 'session-abc': 5 }, + lastReadAt: {} + }; + const action = { type: 'sessionChat/openChatWindow' }; + const newState = sessionChatReducer(state, action); + expect(newState.isWindowOpen).toBe(true); + expect(newState.unreadCounts['session-abc']).toBe(0); + expect(newState.lastReadAt['session-abc']).toBeTruthy(); + }); + }); + ``` + +4. Write tests for `closeChatWindow`: + ```javascript + describe('closeChatWindow', () => { + test('closes window without changing unread counts', () => { + const state = { + isWindowOpen: true, + unreadCounts: { 'session-abc': 0 } + }; + const action = { type: 'sessionChat/closeChatWindow' }; + const newState = sessionChatReducer(state, action); + expect(newState.isWindowOpen).toBe(false); + expect(newState.unreadCounts['session-abc']).toBe(0); + }); + }); + ``` + +5. Run tests - should FAIL + +**GREEN Phase - Implement reducers:** + +6. Implement reducers in sessionChatSlice.js following CHAT_REDUX_DESIGN.md patterns +7. Key implementation details: + - Channel key construction: session messages use 'session-{id}', global uses 'global' + - Message deduplication: check `msg_id` exists before adding + - Unread increment: only if window closed AND channel not active + - lastReadAt: set to current ISO timestamp on window open +8. Run tests - should PASS + +**REFACTOR Phase:** + +9. Extract channel key construction to helper function +10. Add JSDoc comments +11. Ensure tests still pass + +**Verification:** +- All reducer tests pass +- Message deduplication works correctly +- Unread tracking logic validated + + +4 core reducers implemented with passing unit tests. Message deduplication logic validated. Unread tracking works correctly. + + +Core message and window management reducers complete with TDD validation. + + + + + Task 3: Implement unread tracking reducers (TDD) + jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js + +Using TDD, implement remaining reducers: markAsRead, incrementUnreadCount, setWindowPosition + +**RED Phase - Write failing tests first:** + +1. Write tests for `markAsRead`: + ```javascript + describe('markAsRead', () => { + test('resets unread count for specified channel', () => { + const state = { + unreadCounts: { 'session-abc': 5, 'global': 2 }, + lastReadAt: {} + }; + const action = { + type: 'sessionChat/markAsRead', + payload: { channel: 'session-abc' } + }; + const newState = sessionChatReducer(state, action); + expect(newState.unreadCounts['session-abc']).toBe(0); + expect(newState.unreadCounts['global']).toBe(2); // Unchanged + expect(newState.lastReadAt['session-abc']).toBeTruthy(); + }); + }); + ``` + +2. Write tests for `incrementUnreadCount`: + ```javascript + describe('incrementUnreadCount', () => { + test('increments unread count for channel', () => { + const state = { unreadCounts: { 'session-abc': 3 } }; + const action = { + type: 'sessionChat/incrementUnreadCount', + payload: { channel: 'session-abc' } + }; + const newState = sessionChatReducer(state, action); + expect(newState.unreadCounts['session-abc']).toBe(4); + }); + + test('initializes unread count if not exists', () => { + const state = { unreadCounts: {} }; + const action = { + type: 'sessionChat/incrementUnreadCount', + payload: { channel: 'session-abc' } + }; + const newState = sessionChatReducer(state, action); + expect(newState.unreadCounts['session-abc']).toBe(1); + }); + }); + ``` + +3. Write tests for `setWindowPosition`: + ```javascript + describe('setWindowPosition', () => { + test('updates window position', () => { + const state = { windowPosition: null }; + const action = { + type: 'sessionChat/setWindowPosition', + payload: { x: 300, y: 400 } + }; + const newState = sessionChatReducer(state, action); + expect(newState.windowPosition).toEqual({ x: 300, y: 400 }); + }); + }); + ``` + +4. Run tests - should FAIL + +**GREEN Phase - Implement reducers:** + +5. Implement the 3 remaining reducers in sessionChatSlice.js +6. Key implementation details: + - markAsRead: reset count to 0, update lastReadAt timestamp + - incrementUnreadCount: handle missing channel gracefully + - setWindowPosition: used by WindowPortal for persistence +7. Run tests - should PASS + +**REFACTOR Phase:** + +8. Review all 7 reducers for consistency +9. Add JSDoc comments for all reducers +10. Export all actions +11. Ensure tests still pass + +**Integration verification:** + +12. Write a comprehensive integration test that uses multiple reducers: + ```javascript + describe('sessionChat integration', () => { + test('complete message flow: receive → open window → mark read', () => { + let state = initialState; + + // Receive message (window closed) + state = sessionChatReducer(state, addMessageFromWebSocket({...})); + expect(state.unreadCounts['session-abc']).toBe(1); + + // Set active channel + state = sessionChatReducer(state, setActiveChannel({...})); + expect(state.activeChannel).toBe('session-abc'); + + // Open window (should mark as read) + state = sessionChatReducer(state, openChatWindow()); + expect(state.unreadCounts['session-abc']).toBe(0); + }); + }); + ``` + +**Verification:** +- All 7 reducer tests pass +- Integration test validates complete flow +- All actions exported + + +All 7 reducers implemented with passing unit tests. Integration test validates complete message flow. Actions exported correctly. + + +Complete sessionChatSlice with all reducers, comprehensive unit tests, and integration validation. TDD methodology followed throughout. + + + + + + +Before declaring plan complete: +- [ ] sessionChatSlice.js exists with complete initial state +- [ ] All 7 reducers implemented: addMessageFromWebSocket, setActiveChannel, openChatWindow, closeChatWindow, markAsRead, incrementUnreadCount, setWindowPosition +- [ ] Comprehensive unit tests written (TDD approach) +- [ ] Message deduplication logic tested and working +- [ ] Unread tracking logic tested and working +- [ ] Integration test validates complete message flow +- [ ] Slice registered in store configuration +- [ ] All tests pass (RED-GREEN-REFACTOR cycle followed) +- [ ] TDD requirements from CLAUDE.md satisfied + + + + +- sessionChatSlice.js created with all 7 reducers +- Unit test coverage 100% for all reducers +- Message deduplication working correctly +- Unread tracking validated with tests +- Integration test shows complete flow works +- All tests passing +- TDD methodology strictly followed (test-first approach) +- Ready for Phase 7 Plan 2 (Async Thunks) + + + +After completion, create `.planning/phases/07-chat-infrastructure/07-01-SUMMARY.md`: + +# Phase 7 Plan 1: Redux Slice & Core Reducers Summary + +**Created sessionChatSlice with all reducers using TDD methodology** + +## Accomplishments + +- Created sessionChatSlice.js with complete initial state structure +- Implemented 7 reducers with TDD approach (test-first) +- Comprehensive unit test suite with 100% coverage +- Message deduplication logic validated +- Unread tracking system tested and working +- Integration test validates complete message flow + +## Files Created/Modified + +- `jam-ui/src/store/features/sessionChatSlice.js` - Redux slice with 7 reducers +- `jam-ui/src/store/features/__tests__/sessionChatSlice.test.js` - Unit tests +- `jam-ui/src/store/index.js` - Store configuration (added sessionChat slice) + +## Decisions Made + +[Document any implementation decisions: Helper functions? State structure refinements? Edge cases discovered?] + +## Issues Encountered + +[Any challenges during TDD process, or "None"] + +## Next Phase Readiness + +Ready for Plan 2 (Async Thunks & API Integration) + diff --git a/.planning/phases/07-chat-infrastructure/07-02-PLAN.md b/.planning/phases/07-chat-infrastructure/07-02-PLAN.md new file mode 100644 index 000000000..7663f0e1e --- /dev/null +++ b/.planning/phases/07-chat-infrastructure/07-02-PLAN.md @@ -0,0 +1,744 @@ +--- +phase: 07-chat-infrastructure +plan: 02 +type: tdd +--- + + +Implement async thunks and REST API integration for chat using TDD methodology. + +Purpose: Create API client methods and Redux async thunks for fetching chat history and sending messages. All API interactions must be tested following CLAUDE.md TDD requirements. + +Output: REST API methods and 2 async thunks with comprehensive unit tests. + + + +@./.claude/get-shit-done/workflows/execute-phase.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md + +# Phase 6 design documents +@.planning/phases/06-session-chat-research-design/CHAT_API.md +@.planning/phases/06-session-chat-research-design/CHAT_REDUX_DESIGN.md + +# Phase 7 Plan 1 outputs +@.planning/phases/07-chat-infrastructure/07-01-SUMMARY.md + +# Existing API patterns +@jam-ui/src/helpers/rest.js +@jam-ui/src/store/features/mediaSlice.js + +# Codebase conventions +@.planning/codebase/ARCHITECTURE.md +@CLAUDE.md + +**Key context from Phase 6:** +- REST API endpoints: GET /api/chat, POST /api/chat +- Request format: { channel, session_id (optional), before (pagination) } +- Response format: { messages: [...], next: number | null } +- Error codes: 404 (not found), 403 (unauthorized), 422 (validation), 500 (server error) + +**Existing API patterns from rest.js:** +- HTTP methods: getJSON, postJSON, putJSON, deleteJSON +- Error handling: throw ApiError with status code +- URL construction: baseURL + path + +**TDD requirements from CLAUDE.md:** +- Write test FIRST (RED phase) +- Mock fetch API in tests +- Test success and error paths +- Implement minimum code to pass (GREEN phase) +- Refactor while keeping tests green + + + + + + Task 1: Add REST API client methods (TDD) + jam-ui/src/helpers/rest.js, jam-ui/src/helpers/__tests__/rest.test.js + +Using TDD methodology, add chat API methods to rest.js: + +**RED Phase - Write failing tests first:** + +1. Create or extend test file `jam-ui/src/helpers/__tests__/rest.test.js` +2. Write tests for `getChatMessages`: + ```javascript + describe('getChatMessages', () => { + test('fetches messages for session channel', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + messages: [ + { id: 'msg-1', message: 'Hello', sender_id: 'user-1' } + ], + next: 20 + }) + }); + + const result = await getChatMessages({ + channel: 'session', + sessionId: 'session-abc' + }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/chat?channel=session&session_id=session-abc'), + expect.objectContaining({ method: 'GET' }) + ); + expect(result.messages).toHaveLength(1); + expect(result.next).toBe(20); + }); + + test('fetches messages for global channel', async () => { + // Test without session_id + }); + + test('includes pagination cursor (before)', async () => { + // Test with before parameter + const result = await getChatMessages({ + channel: 'session', + sessionId: 'session-abc', + before: 50 + }); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('before=50'), + expect.any(Object) + ); + }); + + test('throws ApiError on 404', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found' + }); + + await expect( + getChatMessages({ channel: 'session', sessionId: 'invalid' }) + ).rejects.toThrow('Not Found'); + }); + + test('throws ApiError on 403', async () => { + // Test unauthorized access + }); + + test('throws ApiError on 500', async () => { + // Test server error + }); + }); + ``` + +3. Write tests for `sendChatMessage`: + ```javascript + describe('sendChatMessage', () => { + test('sends message to session channel', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + message: { + id: 'msg-new', + message: 'Hello world', + sender_id: 'user-1', + created_at: '2026-01-26T12:00:00Z' + } + }) + }); + + const result = await sendChatMessage({ + channel: 'session', + sessionId: 'session-abc', + message: 'Hello world' + }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/chat'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + channel: 'session', + session_id: 'session-abc', + message: 'Hello world' + }) + }) + ); + expect(result.message.id).toBe('msg-new'); + }); + + test('sends message to global channel', async () => { + // Test without session_id + }); + + test('throws ApiError on 422 (validation error)', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ error: 'Message too long' }) + }); + + await expect( + sendChatMessage({ channel: 'session', sessionId: 'abc', message: 'x'.repeat(1000) }) + ).rejects.toThrow(); + }); + + test('throws ApiError on 403', async () => { + // Test unauthorized + }); + + test('throws ApiError on 500', async () => { + // Test server error + }); + }); + ``` + +4. Run tests - should FAIL (methods don't exist yet) + +**GREEN Phase - Implement API methods:** + +5. Add methods to `jam-ui/src/helpers/rest.js`: + ```javascript + export const getChatMessages = async ({ channel, sessionId, before }) => { + const params = new URLSearchParams({ channel }); + if (sessionId) params.append('session_id', sessionId); + if (before) params.append('before', before); + + const response = await fetch(`${baseURL}/api/chat?${params}`, { + method: 'GET', + credentials: 'include', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + throw new ApiError(response.status, response.statusText); + } + + return response.json(); + }; + + export const sendChatMessage = async ({ channel, sessionId, message }) => { + const body = { channel, message }; + if (sessionId) body.session_id = sessionId; + + const response = await fetch(`${baseURL}/api/chat`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new ApiError(response.status, error.error || response.statusText); + } + + return response.json(); + }; + ``` + +6. Run tests - should PASS + +**REFACTOR Phase:** + +7. Review error handling consistency with existing rest.js methods +8. Add JSDoc comments with parameter types +9. Ensure tests still pass + +**Verification:** +- All API method tests pass +- Error handling for all HTTP status codes +- URL construction correct for all scenarios + + +REST API methods implemented with passing unit tests. All HTTP status codes handled. URL construction validated. + + +Chat API client methods complete with TDD validation. + + + + + Task 2: Implement fetchChatHistory async thunk (TDD) + jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js + +Using TDD methodology, implement fetchChatHistory async thunk with extra reducers: + +**RED Phase - Write failing tests first:** + +1. Add tests to `sessionChatSlice.test.js`: + ```javascript + import { fetchChatHistory } from '../sessionChatSlice'; + + describe('fetchChatHistory async thunk', () => { + test('sets loading state on pending', () => { + const action = { type: fetchChatHistory.pending.type, meta: { arg: { channel: 'session-abc' } } }; + const state = { + fetchStatus: {}, + fetchError: {} + }; + const newState = sessionChatReducer(state, action); + expect(newState.fetchStatus['session-abc']).toBe('loading'); + expect(newState.fetchError['session-abc']).toBeNull(); + }); + + test('adds messages on fulfilled', () => { + const action = { + type: fetchChatHistory.fulfilled.type, + meta: { arg: { channel: 'session-abc' } }, + payload: { + messages: [ + { id: 'msg-1', message: 'Hello', sender_id: 'user-1', created_at: '2026-01-26T12:00:00Z' } + ], + next: 20 + } + }; + const state = { + messagesByChannel: {}, + fetchStatus: { 'session-abc': 'loading' }, + nextCursors: {} + }; + const newState = sessionChatReducer(state, action); + expect(newState.messagesByChannel['session-abc']).toHaveLength(1); + expect(newState.fetchStatus['session-abc']).toBe('succeeded'); + expect(newState.nextCursors['session-abc']).toBe(20); + }); + + test('deduplicates messages on fulfilled', () => { + // Test that fetched messages don't duplicate existing ones + const state = { + messagesByChannel: { + 'session-abc': [{ id: 'msg-1', message: 'Hello' }] + }, + fetchStatus: { 'session-abc': 'loading' } + }; + const action = { + type: fetchChatHistory.fulfilled.type, + meta: { arg: { channel: 'session-abc' } }, + payload: { + messages: [ + { id: 'msg-1', message: 'Hello' }, // Duplicate + { id: 'msg-2', message: 'World' } // New + ], + next: null + } + }; + const newState = sessionChatReducer(state, action); + expect(newState.messagesByChannel['session-abc']).toHaveLength(2); + }); + + test('sets error state on rejected', () => { + const action = { + type: fetchChatHistory.rejected.type, + meta: { arg: { channel: 'session-abc' } }, + error: { message: 'Not Found' } + }; + const state = { + fetchStatus: { 'session-abc': 'loading' }, + fetchError: {} + }; + const newState = sessionChatReducer(state, action); + expect(newState.fetchStatus['session-abc']).toBe('failed'); + expect(newState.fetchError['session-abc']).toBe('Not Found'); + }); + + test('calls API with correct parameters', async () => { + const mockGetChatMessages = jest.fn().mockResolvedValue({ + messages: [], + next: null + }); + + const dispatch = jest.fn(); + const getState = jest.fn(); + const thunk = fetchChatHistory({ channel: 'session-abc', sessionId: 'session-abc' }); + + // Mock getChatMessages temporarily + await thunk(dispatch, getState, undefined); + + expect(mockGetChatMessages).toHaveBeenCalledWith({ + channel: 'session', + sessionId: 'session-abc', + before: undefined + }); + }); + }); + ``` + +2. Run tests - should FAIL (thunk doesn't exist yet) + +**GREEN Phase - Implement async thunk:** + +3. Add fetchChatHistory to sessionChatSlice.js: + ```javascript + import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + import { getChatMessages } from '../../helpers/rest'; + + export const fetchChatHistory = createAsyncThunk( + 'sessionChat/fetchChatHistory', + async ({ channel, sessionId, before }) => { + const response = await getChatMessages({ channel, sessionId, before }); + return { channel, ...response }; + } + ); + + const sessionChatSlice = createSlice({ + name: 'sessionChat', + initialState, + reducers: { + // ... existing reducers + }, + extraReducers: (builder) => { + builder + .addCase(fetchChatHistory.pending, (state, action) => { + const channel = action.meta.arg.channel; + state.fetchStatus[channel] = 'loading'; + state.fetchError[channel] = null; + }) + .addCase(fetchChatHistory.fulfilled, (state, action) => { + const channel = action.meta.arg.channel; + const { messages, next } = action.payload; + + // Initialize channel if not exists + if (!state.messagesByChannel[channel]) { + state.messagesByChannel[channel] = []; + } + + // Deduplicate messages + const existingIds = new Set(state.messagesByChannel[channel].map(m => m.id)); + const newMessages = messages.filter(m => !existingIds.has(m.id)); + + // Prepend new messages (oldest first for pagination) + state.messagesByChannel[channel] = [...newMessages, ...state.messagesByChannel[channel]]; + + state.fetchStatus[channel] = 'succeeded'; + state.nextCursors[channel] = next; + }) + .addCase(fetchChatHistory.rejected, (state, action) => { + const channel = action.meta.arg.channel; + state.fetchStatus[channel] = 'failed'; + state.fetchError[channel] = action.error.message; + }); + } + }); + ``` + +4. Run tests - should PASS + +**REFACTOR Phase:** + +5. Extract message deduplication to helper function if needed +6. Add JSDoc comments for thunk parameters +7. Ensure tests still pass + +**Verification:** +- All fetchChatHistory tests pass +- Loading states work correctly +- Message deduplication validated +- Error handling works + + +fetchChatHistory async thunk implemented with passing unit tests. All state transitions validated. Message deduplication working. + + +Chat history fetching complete with TDD validation. + + + + + Task 3: Implement sendMessage async thunk with optimistic updates (TDD) + jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js + +Using TDD methodology, implement sendMessage async thunk with optimistic UI updates: + +**RED Phase - Write failing tests first:** + +1. Add tests to `sessionChatSlice.test.js`: + ```javascript + import { sendMessage } from '../sessionChatSlice'; + + describe('sendMessage async thunk', () => { + test('sets loading state on pending', () => { + const action = { type: sendMessage.pending.type }; + const state = { + sendStatus: 'idle', + sendError: null + }; + const newState = sessionChatReducer(state, action); + expect(newState.sendStatus).toBe('loading'); + expect(newState.sendError).toBeNull(); + }); + + test('adds message optimistically on pending', () => { + const action = { + type: sendMessage.pending.type, + meta: { + arg: { + channel: 'session-abc', + message: 'Hello world', + optimisticId: 'temp-1', + userId: 'user-1', + userName: 'John Doe' + } + } + }; + const state = { + messagesByChannel: { 'session-abc': [] }, + sendStatus: 'idle' + }; + const newState = sessionChatReducer(state, action); + expect(newState.messagesByChannel['session-abc']).toHaveLength(1); + expect(newState.messagesByChannel['session-abc'][0].id).toBe('temp-1'); + expect(newState.messagesByChannel['session-abc'][0].message).toBe('Hello world'); + }); + + test('replaces optimistic message with real one on fulfilled', () => { + const state = { + messagesByChannel: { + 'session-abc': [ + { id: 'temp-1', message: 'Hello world', sender_id: 'user-1' } + ] + }, + sendStatus: 'loading' + }; + const action = { + type: sendMessage.fulfilled.type, + meta: { + arg: { optimisticId: 'temp-1', channel: 'session-abc' } + }, + payload: { + message: { + id: 'msg-real', + message: 'Hello world', + sender_id: 'user-1', + created_at: '2026-01-26T12:00:00Z' + } + } + }; + const newState = sessionChatReducer(state, action); + expect(newState.messagesByChannel['session-abc']).toHaveLength(1); + expect(newState.messagesByChannel['session-abc'][0].id).toBe('msg-real'); + expect(newState.sendStatus).toBe('succeeded'); + }); + + test('removes optimistic message on rejected', () => { + const state = { + messagesByChannel: { + 'session-abc': [ + { id: 'temp-1', message: 'Hello world', sender_id: 'user-1' } + ] + }, + sendStatus: 'loading' + }; + const action = { + type: sendMessage.rejected.type, + meta: { + arg: { optimisticId: 'temp-1', channel: 'session-abc' } + }, + error: { message: 'Network error' } + }; + const newState = sessionChatReducer(state, action); + expect(newState.messagesByChannel['session-abc']).toHaveLength(0); + expect(newState.sendStatus).toBe('failed'); + expect(newState.sendError).toBe('Network error'); + }); + + test('calls API with correct parameters', async () => { + const mockSendChatMessage = jest.fn().mockResolvedValue({ + message: { id: 'msg-1', message: 'Hello' } + }); + + const dispatch = jest.fn(); + const getState = jest.fn(); + const thunk = sendMessage({ + channel: 'session-abc', + sessionId: 'session-abc', + message: 'Hello' + }); + + await thunk(dispatch, getState, undefined); + + expect(mockSendChatMessage).toHaveBeenCalledWith({ + channel: 'session', + sessionId: 'session-abc', + message: 'Hello' + }); + }); + }); + ``` + +2. Run tests - should FAIL (thunk doesn't exist yet) + +**GREEN Phase - Implement async thunk:** + +3. Add sendMessage to sessionChatSlice.js: + ```javascript + import { sendChatMessage } from '../../helpers/rest'; + + export const sendMessage = createAsyncThunk( + 'sessionChat/sendMessage', + async ({ channel, sessionId, message }) => { + const response = await sendChatMessage({ channel, sessionId, message }); + return response; + } + ); + + // In extraReducers: + builder + .addCase(sendMessage.pending, (state, action) => { + state.sendStatus = 'loading'; + state.sendError = null; + + // Optimistic update: add message immediately + const { channel, message, optimisticId, userId, userName } = action.meta.arg; + if (!state.messagesByChannel[channel]) { + state.messagesByChannel[channel] = []; + } + + state.messagesByChannel[channel].push({ + id: optimisticId, + senderId: userId, + senderName: userName, + message, + createdAt: new Date().toISOString(), + channel, + isOptimistic: true + }); + }) + .addCase(sendMessage.fulfilled, (state, action) => { + state.sendStatus = 'succeeded'; + state.sendError = null; + + // Replace optimistic message with real one + const { channel, optimisticId } = action.meta.arg; + const realMessage = action.payload.message; + + const messages = state.messagesByChannel[channel]; + const index = messages.findIndex(m => m.id === optimisticId); + if (index !== -1) { + messages[index] = { + id: realMessage.id, + senderId: realMessage.sender_id, + senderName: realMessage.sender_name, + message: realMessage.message, + createdAt: realMessage.created_at, + channel: realMessage.channel + }; + } + }) + .addCase(sendMessage.rejected, (state, action) => { + state.sendStatus = 'failed'; + state.sendError = action.error.message; + + // Remove optimistic message on failure + const { channel, optimisticId } = action.meta.arg; + const messages = state.messagesByChannel[channel]; + const index = messages.findIndex(m => m.id === optimisticId); + if (index !== -1) { + messages.splice(index, 1); + } + }); + ``` + +4. Run tests - should PASS + +**REFACTOR Phase:** + +5. Extract optimistic message creation to helper function +6. Add JSDoc comments +7. Ensure tests still pass + +**Integration test:** + +8. Write integration test showing complete send flow: + ```javascript + describe('sendMessage integration', () => { + test('complete send flow: optimistic → fulfilled → state clean', async () => { + // Test complete flow from optimistic to final state + }); + + test('complete send flow: optimistic → rejected → rollback', async () => { + // Test error rollback + }); + }); + ``` + +**Verification:** +- All sendMessage tests pass +- Optimistic updates work correctly +- Error rollback validated +- Integration test shows complete flow + + +sendMessage async thunk implemented with passing unit tests. Optimistic UI updates validated. Error rollback working correctly. + + +Complete async thunk implementation with TDD validation. API integration tested for all success and error paths. Ready for Phase 7 Plan 3 (WebSocket Integration). + + + + + + +Before declaring plan complete: +- [ ] REST API methods added to rest.js: getChatMessages, sendChatMessage +- [ ] Unit tests for API methods with all HTTP status codes +- [ ] fetchChatHistory async thunk implemented with extra reducers +- [ ] sendMessage async thunk implemented with optimistic updates +- [ ] All async thunk state transitions tested (pending, fulfilled, rejected) +- [ ] Message deduplication works in fetchChatHistory +- [ ] Optimistic UI updates work in sendMessage +- [ ] Error handling validated for all failure scenarios +- [ ] All tests passing (TDD methodology followed) + + + + +- REST API methods implemented and tested +- fetchChatHistory async thunk complete with extra reducers +- sendMessage async thunk complete with optimistic updates +- Unit test coverage 100% for all async operations +- All HTTP error codes handled correctly +- Message deduplication validated +- Optimistic updates and rollback working +- TDD methodology strictly followed (test-first approach) +- Ready for Phase 7 Plan 3 (WebSocket Integration & Selectors) + + + +After completion, create `.planning/phases/07-chat-infrastructure/07-02-SUMMARY.md`: + +# Phase 7 Plan 2: Async Thunks & API Integration Summary + +**Implemented REST API methods and async thunks using TDD methodology** + +## Accomplishments + +- Added getChatMessages and sendChatMessage to rest.js +- Implemented fetchChatHistory async thunk with message deduplication +- Implemented sendMessage async thunk with optimistic UI updates +- Comprehensive unit test suite with 100% coverage +- All HTTP error codes handled and tested +- Optimistic updates and error rollback validated + +## Files Created/Modified + +- `jam-ui/src/helpers/rest.js` - Added chat API methods +- `jam-ui/src/helpers/__tests__/rest.test.js` - API method tests +- `jam-ui/src/store/features/sessionChatSlice.js` - Added async thunks and extra reducers +- `jam-ui/src/store/features/__tests__/sessionChatSlice.test.js` - Async thunk tests + +## Decisions Made + +[Document any implementation decisions: Optimistic update strategy? Error handling approach? Pagination cursor handling?] + +## Issues Encountered + +[Any challenges during API integration, or "None"] + +## Next Phase Readiness + +Ready for Plan 3 (WebSocket Integration & Selectors) + diff --git a/.planning/phases/07-chat-infrastructure/07-03-PLAN.md b/.planning/phases/07-chat-infrastructure/07-03-PLAN.md new file mode 100644 index 000000000..4f750f79d --- /dev/null +++ b/.planning/phases/07-chat-infrastructure/07-03-PLAN.md @@ -0,0 +1,840 @@ +--- +phase: 07-chat-infrastructure +plan: 03 +type: tdd +--- + + +Implement WebSocket message handling and memoized selectors using TDD methodology. + +Purpose: Integrate chat with real-time WebSocket messaging and create efficient Redux selectors. All WebSocket routing and selector logic must be tested following CLAUDE.md TDD requirements. + +Output: CHAT_MESSAGE WebSocket handler, 8 memoized selectors, and localStorage persistence with comprehensive tests. + + + +@./.claude/get-shit-done/workflows/execute-phase.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md + +# Phase 6 design documents +@.planning/phases/06-session-chat-research-design/CHAT_API.md +@.planning/phases/06-session-chat-research-design/CHAT_REDUX_DESIGN.md + +# Phase 7 Plan 1 & 2 outputs +@.planning/phases/07-chat-infrastructure/07-01-SUMMARY.md +@.planning/phases/07-chat-infrastructure/07-02-SUMMARY.md + +# Existing WebSocket patterns +@jam-ui/src/hooks/useSessionWebSocket.js +@jam-ui/src/store/features/mediaSlice.js + +# Codebase conventions +@.planning/codebase/ARCHITECTURE.md +@CLAUDE.md + +**Key context from Phase 6:** +- WebSocket message type: CHAT_MESSAGE (Protocol Buffer) +- Message format: { msg_id, user_id, user_name, message, channel, session_id, created_at } +- Unread increment: only if window closed AND channel not active +- localStorage persistence: lastReadAt timestamps for unread tracking + +**Existing WebSocket patterns:** +- useSessionWebSocket.js handles message routing +- Message types: MIXER_CHANGES, JAM_TRACK_CHANGES, etc. +- Pattern: Switch on message type → dispatch Redux action + +**Selector patterns from existing slices:** +- Use Reselect createSelector for memoization +- Chain selectors for derived data +- Input selectors → output selector pattern + +**TDD requirements from CLAUDE.md:** +- Write test FIRST (RED phase) +- Mock JamServer for WebSocket tests +- Test all selector scenarios +- Implement minimum code to pass (GREEN phase) +- Refactor while keeping tests green + + + + + + Task 1: Add CHAT_MESSAGE WebSocket handler (TDD) + jam-ui/src/hooks/useSessionWebSocket.js, jam-ui/src/hooks/__tests__/useSessionWebSocket.test.js + +Using TDD methodology, add CHAT_MESSAGE handler to WebSocket hook: + +**RED Phase - Write failing tests first:** + +1. Create or extend test file `jam-ui/src/hooks/__tests__/useSessionWebSocket.test.js` +2. Write tests for CHAT_MESSAGE routing: + ```javascript + import { renderHook } from '@testing-library/react-hooks'; + import { useSessionWebSocket } from '../useSessionWebSocket'; + import { addMessageFromWebSocket, incrementUnreadCount } from '../../store/features/sessionChatSlice'; + + describe('useSessionWebSocket - CHAT_MESSAGE', () => { + let mockDispatch; + let mockJamServer; + + beforeEach(() => { + mockDispatch = jest.fn(); + mockJamServer = { + on: jest.fn(), + off: jest.fn() + }; + }); + + test('dispatches addMessageFromWebSocket on CHAT_MESSAGE', () => { + renderHook(() => useSessionWebSocket(mockJamServer, mockDispatch)); + + // Simulate CHAT_MESSAGE from server + const messageHandler = mockJamServer.on.mock.calls.find( + call => call[0] === 'message' + )[1]; + + const chatMessage = { + type: 'CHAT_MESSAGE', + msg_id: 'msg-1', + user_id: 'user-1', + user_name: 'John Doe', + message: 'Hello world', + channel: 'session', + session_id: 'session-abc', + created_at: '2026-01-26T12:00:00Z' + }; + + messageHandler(chatMessage); + + expect(mockDispatch).toHaveBeenCalledWith( + addMessageFromWebSocket({ + id: 'msg-1', + senderId: 'user-1', + senderName: 'John Doe', + message: 'Hello world', + channel: 'session', + sessionId: 'session-abc', + createdAt: '2026-01-26T12:00:00Z' + }) + ); + }); + + test('increments unread count if window closed', () => { + // Mock Redux state where chat window is closed + const mockGetState = jest.fn(() => ({ + sessionChat: { + isWindowOpen: false, + activeChannel: 'global' + } + })); + + renderHook(() => useSessionWebSocket(mockJamServer, mockDispatch, mockGetState)); + + const messageHandler = mockJamServer.on.mock.calls.find( + call => call[0] === 'message' + )[1]; + + const chatMessage = { + type: 'CHAT_MESSAGE', + channel: 'session', + session_id: 'session-abc', + // ... other fields + }; + + messageHandler(chatMessage); + + expect(mockDispatch).toHaveBeenCalledWith(addMessageFromWebSocket(expect.any(Object))); + expect(mockDispatch).toHaveBeenCalledWith(incrementUnreadCount({ channel: 'session-abc' })); + }); + + test('does NOT increment unread if window open and active channel', () => { + const mockGetState = jest.fn(() => ({ + sessionChat: { + isWindowOpen: true, + activeChannel: 'session-abc' + } + })); + + renderHook(() => useSessionWebSocket(mockJamServer, mockDispatch, mockGetState)); + + const messageHandler = mockJamServer.on.mock.calls.find( + call => call[0] === 'message' + )[1]; + + const chatMessage = { + type: 'CHAT_MESSAGE', + channel: 'session', + session_id: 'session-abc', + // ... other fields + }; + + messageHandler(chatMessage); + + expect(mockDispatch).toHaveBeenCalledWith(addMessageFromWebSocket(expect.any(Object))); + expect(mockDispatch).not.toHaveBeenCalledWith(incrementUnreadCount(expect.any(Object))); + }); + + test('handles global channel messages', () => { + // Test without session_id + }); + + test('handles lesson channel messages', () => { + // Test with lesson_session_id + }); + }); + ``` + +3. Run tests - should FAIL (handler doesn't exist yet) + +**GREEN Phase - Implement WebSocket handler:** + +4. Add CHAT_MESSAGE case to `useSessionWebSocket.js`: + ```javascript + import { addMessageFromWebSocket, incrementUnreadCount } from '../store/features/sessionChatSlice'; + + export const useSessionWebSocket = (jamServer, dispatch, getState) => { + useEffect(() => { + if (!jamServer) return; + + const handleMessage = (message) => { + switch (message.type) { + case 'CHAT_MESSAGE': { + // Transform Protocol Buffer format to Redux format + const chatMessage = { + id: message.msg_id, + senderId: message.user_id, + senderName: message.user_name, + message: message.message, + channel: message.channel, + sessionId: message.session_id || null, + lessonSessionId: message.lesson_session_id || null, + createdAt: message.created_at, + purpose: message.purpose || null, + musicNotation: message.music_notation || null, + claimedRecording: message.claimed_recording || null + }; + + // Add message to Redux + dispatch(addMessageFromWebSocket(chatMessage)); + + // Increment unread count if window closed or different channel active + const state = getState(); + const { isWindowOpen, activeChannel } = state.sessionChat; + const messageChannel = message.channel === 'session' + ? `session-${message.session_id}` + : message.channel === 'lesson' + ? `lesson-${message.lesson_session_id}` + : 'global'; + + if (!isWindowOpen || activeChannel !== messageChannel) { + dispatch(incrementUnreadCount({ channel: messageChannel })); + } + + break; + } + + // ... existing cases (MIXER_CHANGES, JAM_TRACK_CHANGES, etc.) + } + }; + + jamServer.on('message', handleMessage); + return () => jamServer.off('message', handleMessage); + }, [jamServer, dispatch, getState]); + }; + ``` + +5. Run tests - should PASS + +**REFACTOR Phase:** + +6. Extract channel key construction to helper function (shared with sessionChatSlice) +7. Add JSDoc comments +8. Ensure tests still pass + +**Verification:** +- All WebSocket handler tests pass +- Message routing works correctly +- Unread increment logic validated + + +CHAT_MESSAGE WebSocket handler implemented with passing unit tests. Message routing validated. Unread increment logic working correctly. + + +Real-time chat integration complete with TDD validation. + + + + + Task 2: Implement memoized selectors (TDD) + jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js + +Using TDD methodology, implement 8 memoized selectors using Reselect: + +**RED Phase - Write failing tests first:** + +1. Add tests to `sessionChatSlice.test.js`: + ```javascript + import { + selectChatMessages, + selectUnreadCount, + selectTotalUnreadCount, + selectIsChatWindowOpen, + selectActiveChannel, + selectFetchStatus, + selectSendStatus, + selectSendError + } from '../sessionChatSlice'; + + describe('sessionChat selectors', () => { + const mockState = { + sessionChat: { + messagesByChannel: { + 'session-abc': [ + { id: 'msg-1', message: 'Hello' }, + { id: 'msg-2', message: 'World' } + ], + 'global': [ + { id: 'msg-3', message: 'Global msg' } + ] + }, + activeChannel: 'session-abc', + unreadCounts: { + 'session-abc': 0, + 'global': 5, + 'session-xyz': 3 + }, + isWindowOpen: true, + fetchStatus: { + 'session-abc': 'succeeded', + 'global': 'loading' + }, + sendStatus: 'idle', + sendError: null + } + }; + + describe('selectChatMessages', () => { + test('returns messages for specified channel', () => { + const messages = selectChatMessages(mockState, 'session-abc'); + expect(messages).toHaveLength(2); + expect(messages[0].id).toBe('msg-1'); + }); + + test('returns empty array for non-existent channel', () => { + const messages = selectChatMessages(mockState, 'session-nonexistent'); + expect(messages).toEqual([]); + }); + + test('memoizes result for same channel', () => { + const result1 = selectChatMessages(mockState, 'session-abc'); + const result2 = selectChatMessages(mockState, 'session-abc'); + expect(result1).toBe(result2); // Same reference + }); + }); + + describe('selectUnreadCount', () => { + test('returns unread count for channel', () => { + const count = selectUnreadCount(mockState, 'global'); + expect(count).toBe(5); + }); + + test('returns 0 for channel with no unread', () => { + const count = selectUnreadCount(mockState, 'session-abc'); + expect(count).toBe(0); + }); + + test('returns 0 for non-existent channel', () => { + const count = selectUnreadCount(mockState, 'nonexistent'); + expect(count).toBe(0); + }); + }); + + describe('selectTotalUnreadCount', () => { + test('sums all unread counts across channels', () => { + const total = selectTotalUnreadCount(mockState); + expect(total).toBe(8); // 0 + 5 + 3 + }); + + test('returns 0 when no unread messages', () => { + const emptyState = { + sessionChat: { unreadCounts: {} } + }; + const total = selectTotalUnreadCount(emptyState); + expect(total).toBe(0); + }); + + test('memoizes result', () => { + const result1 = selectTotalUnreadCount(mockState); + const result2 = selectTotalUnreadCount(mockState); + expect(result1).toBe(result2); + }); + }); + + describe('selectIsChatWindowOpen', () => { + test('returns window open state', () => { + const isOpen = selectIsChatWindowOpen(mockState); + expect(isOpen).toBe(true); + }); + }); + + describe('selectActiveChannel', () => { + test('returns active channel', () => { + const channel = selectActiveChannel(mockState); + expect(channel).toBe('session-abc'); + }); + }); + + describe('selectFetchStatus', () => { + test('returns fetch status for channel', () => { + const status = selectFetchStatus(mockState, 'session-abc'); + expect(status).toBe('succeeded'); + }); + + test('returns idle for non-existent channel', () => { + const status = selectFetchStatus(mockState, 'nonexistent'); + expect(status).toBe('idle'); + }); + }); + + describe('selectSendStatus', () => { + test('returns send status', () => { + const status = selectSendStatus(mockState); + expect(status).toBe('idle'); + }); + }); + + describe('selectSendError', () => { + test('returns send error', () => { + const error = selectSendError(mockState); + expect(error).toBeNull(); + }); + }); + }); + ``` + +2. Run tests - should FAIL (selectors don't exist yet) + +**GREEN Phase - Implement selectors:** + +3. Add selectors to sessionChatSlice.js: + ```javascript + import { createSelector } from '@reduxjs/toolkit'; + + // Input selectors (direct state access) + const selectSessionChatState = (state) => state.sessionChat; + const selectMessagesByChannel = (state) => state.sessionChat.messagesByChannel; + const selectUnreadCounts = (state) => state.sessionChat.unreadCounts; + + // Output selectors (memoized) + export const selectChatMessages = createSelector( + [selectMessagesByChannel, (state, channel) => channel], + (messagesByChannel, channel) => messagesByChannel[channel] || [] + ); + + export const selectUnreadCount = createSelector( + [selectUnreadCounts, (state, channel) => channel], + (unreadCounts, channel) => unreadCounts[channel] || 0 + ); + + export const selectTotalUnreadCount = createSelector( + [selectUnreadCounts], + (unreadCounts) => Object.values(unreadCounts).reduce((sum, count) => sum + count, 0) + ); + + export const selectIsChatWindowOpen = createSelector( + [selectSessionChatState], + (chatState) => chatState.isWindowOpen + ); + + export const selectActiveChannel = createSelector( + [selectSessionChatState], + (chatState) => chatState.activeChannel + ); + + export const selectFetchStatus = createSelector( + [selectSessionChatState, (state, channel) => channel], + (chatState, channel) => chatState.fetchStatus[channel] || 'idle' + ); + + export const selectSendStatus = createSelector( + [selectSessionChatState], + (chatState) => chatState.sendStatus + ); + + export const selectSendError = createSelector( + [selectSessionChatState], + (chatState) => chatState.sendError + ); + ``` + +4. Run tests - should PASS + +**REFACTOR Phase:** + +5. Review selector memoization (ensure proper input selectors) +6. Add JSDoc comments with @param and @returns +7. Ensure tests still pass + +**Performance tests:** + +8. Add memoization tests: + ```javascript + describe('selector memoization', () => { + test('selectChatMessages returns same reference for unchanged state', () => { + const result1 = selectChatMessages(mockState, 'session-abc'); + const result2 = selectChatMessages(mockState, 'session-abc'); + expect(result1).toBe(result2); + }); + + test('selectChatMessages returns new reference when messages change', () => { + const result1 = selectChatMessages(mockState, 'session-abc'); + const newState = { + ...mockState, + sessionChat: { + ...mockState.sessionChat, + messagesByChannel: { + ...mockState.sessionChat.messagesByChannel, + 'session-abc': [...mockState.sessionChat.messagesByChannel['session-abc'], { id: 'msg-new' }] + } + } + }; + const result2 = selectChatMessages(newState, 'session-abc'); + expect(result1).not.toBe(result2); + }); + }); + ``` + +**Verification:** +- All selector tests pass +- Memoization validated +- Edge cases handled (empty, null, non-existent) + + +8 memoized selectors implemented with passing unit tests. Memoization validated. All edge cases handled correctly. + + +Efficient Redux selectors complete with TDD validation. + + + + + Task 3: Add localStorage persistence for lastReadAt (TDD) + jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js, jam-ui/src/utils/chatStorage.js, jam-ui/src/utils/__tests__/chatStorage.test.js + +Using TDD methodology, implement localStorage persistence for unread tracking: + +**RED Phase - Write failing tests first:** + +1. Create test file `jam-ui/src/utils/__tests__/chatStorage.test.js`: + ```javascript + import { saveLastReadAt, loadLastReadAt, clearLastReadAt } from '../chatStorage'; + + describe('chatStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('saveLastReadAt', () => { + test('saves lastReadAt timestamp to localStorage', () => { + saveLastReadAt('session-abc', '2026-01-26T12:00:00Z'); + const stored = localStorage.getItem('jk_chat_lastReadAt'); + expect(stored).toBeTruthy(); + const parsed = JSON.parse(stored); + expect(parsed['session-abc']).toBe('2026-01-26T12:00:00Z'); + }); + + test('merges with existing data', () => { + saveLastReadAt('session-abc', '2026-01-26T12:00:00Z'); + saveLastReadAt('global', '2026-01-26T13:00:00Z'); + const stored = localStorage.getItem('jk_chat_lastReadAt'); + const parsed = JSON.parse(stored); + expect(parsed['session-abc']).toBe('2026-01-26T12:00:00Z'); + expect(parsed['global']).toBe('2026-01-26T13:00:00Z'); + }); + + test('handles localStorage errors gracefully', () => { + // Mock localStorage.setItem to throw error + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + expect(() => saveLastReadAt('session-abc', '2026-01-26T12:00:00Z')).not.toThrow(); + }); + }); + + describe('loadLastReadAt', () => { + test('loads lastReadAt timestamps from localStorage', () => { + localStorage.setItem('jk_chat_lastReadAt', JSON.stringify({ + 'session-abc': '2026-01-26T12:00:00Z', + 'global': '2026-01-26T13:00:00Z' + })); + const data = loadLastReadAt(); + expect(data['session-abc']).toBe('2026-01-26T12:00:00Z'); + expect(data['global']).toBe('2026-01-26T13:00:00Z'); + }); + + test('returns empty object if no data', () => { + const data = loadLastReadAt(); + expect(data).toEqual({}); + }); + + test('handles JSON parse errors gracefully', () => { + localStorage.setItem('jk_chat_lastReadAt', 'invalid json'); + const data = loadLastReadAt(); + expect(data).toEqual({}); + }); + }); + + describe('clearLastReadAt', () => { + test('clears specific channel', () => { + localStorage.setItem('jk_chat_lastReadAt', JSON.stringify({ + 'session-abc': '2026-01-26T12:00:00Z', + 'global': '2026-01-26T13:00:00Z' + })); + clearLastReadAt('session-abc'); + const stored = localStorage.getItem('jk_chat_lastReadAt'); + const parsed = JSON.parse(stored); + expect(parsed['session-abc']).toBeUndefined(); + expect(parsed['global']).toBe('2026-01-26T13:00:00Z'); + }); + + test('clears all data if no channel specified', () => { + localStorage.setItem('jk_chat_lastReadAt', JSON.stringify({ + 'session-abc': '2026-01-26T12:00:00Z' + })); + clearLastReadAt(); + const stored = localStorage.getItem('jk_chat_lastReadAt'); + expect(stored).toBeNull(); + }); + }); + }); + ``` + +2. Add integration tests to `sessionChatSlice.test.js`: + ```javascript + import { loadLastReadAt } from '../../utils/chatStorage'; + + describe('sessionChat localStorage integration', () => { + test('loads lastReadAt on init', () => { + localStorage.setItem('jk_chat_lastReadAt', JSON.stringify({ + 'session-abc': '2026-01-26T12:00:00Z' + })); + + // Initialize Redux store (would call initializeChatStorage) + const state = sessionChatReducer(undefined, { type: '@@INIT' }); + // After initialization, lastReadAt should be loaded + expect(state.lastReadAt['session-abc']).toBe('2026-01-26T12:00:00Z'); + }); + + test('saves lastReadAt when marking as read', () => { + localStorage.clear(); + const state = { + activeChannel: 'session-abc', + unreadCounts: { 'session-abc': 5 }, + lastReadAt: {} + }; + const action = { type: 'sessionChat/openChatWindow' }; + sessionChatReducer(state, action); + + // Check localStorage was updated + const stored = localStorage.getItem('jk_chat_lastReadAt'); + expect(stored).toBeTruthy(); + }); + }); + ``` + +3. Run tests - should FAIL (utility doesn't exist yet) + +**GREEN Phase - Implement localStorage utilities:** + +4. Create `jam-ui/src/utils/chatStorage.js`: + ```javascript + const STORAGE_KEY = 'jk_chat_lastReadAt'; + + export const saveLastReadAt = (channel, timestamp) => { + try { + const existing = loadLastReadAt(); + existing[channel] = timestamp; + localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); + } catch (error) { + console.error('Failed to save lastReadAt:', error); + } + }; + + export const loadLastReadAt = () => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return {}; + return JSON.parse(stored); + } catch (error) { + console.error('Failed to load lastReadAt:', error); + return {}; + } + }; + + export const clearLastReadAt = (channel) => { + try { + if (channel) { + const existing = loadLastReadAt(); + delete existing[channel]; + if (Object.keys(existing).length === 0) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); + } + } else { + localStorage.removeItem(STORAGE_KEY); + } + } catch (error) { + console.error('Failed to clear lastReadAt:', error); + } + }; + ``` + +5. Integrate with Redux slice: + ```javascript + import { saveLastReadAt, loadLastReadAt } from '../../utils/chatStorage'; + + // In initialState: + const initialState = { + // ... other fields + lastReadAt: loadLastReadAt() // Load from localStorage on init + }; + + // In reducers that update lastReadAt: + openChatWindow: (state) => { + state.isWindowOpen = true; + if (state.activeChannel) { + const now = new Date().toISOString(); + state.lastReadAt[state.activeChannel] = now; + state.unreadCounts[state.activeChannel] = 0; + saveLastReadAt(state.activeChannel, now); // Save to localStorage + } + }, + + markAsRead: (state, action) => { + const { channel } = action.payload; + const now = new Date().toISOString(); + state.lastReadAt[channel] = now; + state.unreadCounts[channel] = 0; + saveLastReadAt(channel, now); // Save to localStorage + } + ``` + +6. Run tests - should PASS + +**REFACTOR Phase:** + +7. Review error handling in chatStorage utilities +8. Add JSDoc comments +9. Ensure tests still pass + +**Edge case tests:** + +10. Add tests for edge cases: + ```javascript + describe('chatStorage edge cases', () => { + test('handles very old timestamps', () => { + // Test with timestamps from months ago + }); + + test('handles invalid timestamp formats', () => { + // Test with malformed timestamps + }); + + test('handles large data sets', () => { + // Test with many channels + }); + }); + ``` + +**Verification:** +- All localStorage tests pass +- Error handling validated +- Integration with Redux slice works +- Edge cases handled + + +localStorage utilities implemented with passing unit tests. Integration with Redux validated. Error handling working correctly. Edge cases handled. + + +Complete chat infrastructure with localStorage persistence. Phase 7 complete with comprehensive TDD coverage. Ready for Phase 8 (Chat Window UI & Message Display). + + + + + + +Before declaring plan complete: +- [ ] CHAT_MESSAGE WebSocket handler added to useSessionWebSocket.js +- [ ] Unit tests for WebSocket message routing +- [ ] 8 memoized selectors implemented: selectChatMessages, selectUnreadCount, selectTotalUnreadCount, selectIsChatWindowOpen, selectActiveChannel, selectFetchStatus, selectSendStatus, selectSendError +- [ ] Unit tests for all selectors with edge cases +- [ ] Memoization validated with performance tests +- [ ] localStorage utilities created: saveLastReadAt, loadLastReadAt, clearLastReadAt +- [ ] Unit tests for localStorage with error handling +- [ ] Integration with Redux slice validated +- [ ] All tests passing (TDD methodology followed) +- [ ] Phase 7 complete - ready for Phase 8 + + + + +- WebSocket CHAT_MESSAGE handler implemented and tested +- 8 memoized selectors complete with 100% test coverage +- localStorage persistence working with error handling +- All WebSocket routing validated +- Selector memoization confirmed +- Integration tests show complete data flow +- TDD methodology strictly followed (test-first approach) +- Phase 7 complete - Redux infrastructure ready for UI components +- Ready for Phase 8 (Chat Window UI & Message Display) + + + +After completion, create `.planning/phases/07-chat-infrastructure/07-03-SUMMARY.md`: + +# Phase 7 Plan 3: WebSocket Integration & Selectors Summary + +**Implemented WebSocket message handling and memoized selectors using TDD methodology** + +## Accomplishments + +- Added CHAT_MESSAGE handler to useSessionWebSocket.js +- Implemented 8 memoized selectors using Reselect +- Created localStorage utilities for lastReadAt persistence +- Comprehensive unit test suite with 100% coverage +- WebSocket message routing validated +- Selector memoization performance confirmed +- localStorage error handling validated + +## Files Created/Modified + +- `jam-ui/src/hooks/useSessionWebSocket.js` - Added CHAT_MESSAGE handler +- `jam-ui/src/hooks/__tests__/useSessionWebSocket.test.js` - WebSocket handler tests +- `jam-ui/src/store/features/sessionChatSlice.js` - Added 8 selectors and localStorage integration +- `jam-ui/src/store/features/__tests__/sessionChatSlice.test.js` - Selector and localStorage tests +- `jam-ui/src/utils/chatStorage.js` - localStorage utilities +- `jam-ui/src/utils/__tests__/chatStorage.test.js` - localStorage utility tests + +## Decisions Made + +[Document any implementation decisions: Channel key construction? Unread increment logic? localStorage error strategy?] + +## Issues Encountered + +[Any challenges during WebSocket integration, or "None"] + +## Next Phase Readiness + +**Phase 7 complete!** Redux infrastructure (state, thunks, selectors, WebSocket) fully implemented and tested. + +Ready for Phase 8 (Chat Window UI & Message Display). + +Use `/gsd:plan-phase 8` to break down Phase 8 into executable plans. +