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:
parent
a5aef4bbb4
commit
0144a984d0
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue