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.
This commit is contained in:
Nuwan 2026-01-27 08:00:21 +05:30
parent a5aef4bbb4
commit 0144a984d0
3 changed files with 2038 additions and 0 deletions

View File

@ -0,0 +1,454 @@
---
phase: 07-chat-infrastructure
plan: 01
type: tdd
---
<objective>
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.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="tdd">
<name>Task 1: Create sessionChatSlice.js with initial state structure (TDD)</name>
<files>jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js</files>
<action>
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
</action>
<verify>
sessionChatSlice.js exists with correct initial state. Unit tests pass. Slice registered in store. TDD methodology followed (RED-GREEN-REFACTOR).
</verify>
<done>
Redux slice foundation complete with validated initial state structure.
</done>
</task>
<task type="tdd">
<name>Task 2: Implement core reducers with message deduplication (TDD)</name>
<files>jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js</files>
<action>
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
</action>
<verify>
4 core reducers implemented with passing unit tests. Message deduplication logic validated. Unread tracking works correctly.
</verify>
<done>
Core message and window management reducers complete with TDD validation.
</done>
</task>
<task type="tdd">
<name>Task 3: Implement unread tracking reducers (TDD)</name>
<files>jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js</files>
<action>
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
</action>
<verify>
All 7 reducers implemented with passing unit tests. Integration test validates complete message flow. Actions exported correctly.
</verify>
<done>
Complete sessionChatSlice with all reducers, comprehensive unit tests, and integration validation. TDD methodology followed throughout.
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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)
</success_criteria>
<output>
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)
</output>

View File

@ -0,0 +1,744 @@
---
phase: 07-chat-infrastructure
plan: 02
type: tdd
---
<objective>
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.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="tdd">
<name>Task 1: Add REST API client methods (TDD)</name>
<files>jam-ui/src/helpers/rest.js, jam-ui/src/helpers/__tests__/rest.test.js</files>
<action>
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
</action>
<verify>
REST API methods implemented with passing unit tests. All HTTP status codes handled. URL construction validated.
</verify>
<done>
Chat API client methods complete with TDD validation.
</done>
</task>
<task type="tdd">
<name>Task 2: Implement fetchChatHistory async thunk (TDD)</name>
<files>jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js</files>
<action>
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
</action>
<verify>
fetchChatHistory async thunk implemented with passing unit tests. All state transitions validated. Message deduplication working.
</verify>
<done>
Chat history fetching complete with TDD validation.
</done>
</task>
<task type="tdd">
<name>Task 3: Implement sendMessage async thunk with optimistic updates (TDD)</name>
<files>jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js</files>
<action>
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
</action>
<verify>
sendMessage async thunk implemented with passing unit tests. Optimistic UI updates validated. Error rollback working correctly.
</verify>
<done>
Complete async thunk implementation with TDD validation. API integration tested for all success and error paths. Ready for Phase 7 Plan 3 (WebSocket Integration).
</done>
</task>
</tasks>
<verification>
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)
</verification>
<success_criteria>
- 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)
</success_criteria>
<output>
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)
</output>

View File

@ -0,0 +1,840 @@
---
phase: 07-chat-infrastructure
plan: 03
type: tdd
---
<objective>
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.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-phase.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="tdd">
<name>Task 1: Add CHAT_MESSAGE WebSocket handler (TDD)</name>
<files>jam-ui/src/hooks/useSessionWebSocket.js, jam-ui/src/hooks/__tests__/useSessionWebSocket.test.js</files>
<action>
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
</action>
<verify>
CHAT_MESSAGE WebSocket handler implemented with passing unit tests. Message routing validated. Unread increment logic working correctly.
</verify>
<done>
Real-time chat integration complete with TDD validation.
</done>
</task>
<task type="tdd">
<name>Task 2: Implement memoized selectors (TDD)</name>
<files>jam-ui/src/store/features/sessionChatSlice.js, jam-ui/src/store/features/__tests__/sessionChatSlice.test.js</files>
<action>
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)
</action>
<verify>
8 memoized selectors implemented with passing unit tests. Memoization validated. All edge cases handled correctly.
</verify>
<done>
Efficient Redux selectors complete with TDD validation.
</done>
</task>
<task type="tdd">
<name>Task 3: Add localStorage persistence for lastReadAt (TDD)</name>
<files>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</files>
<action>
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
</action>
<verify>
localStorage utilities implemented with passing unit tests. Integration with Redux validated. Error handling working correctly. Edge cases handled.
</verify>
<done>
Complete chat infrastructure with localStorage persistence. Phase 7 complete with comprehensive TDD coverage. Ready for Phase 8 (Chat Window UI & Message Display).
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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)
</success_criteria>
<output>
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.
</output>