Authentication & Token Management: - Add deviceId to token refresh flow (backend requires both refreshToken and deviceId) - Fix React Strict Mode token clearing race condition with retry logic - Improve AuthContext to handle all token state combinations properly - Store deviceId in localStorage alongside tokens UI/UX Improvements: - Remove deprecated legacyBehavior from Next.js Link components - Update primary theme color to WCAG AA compliant #7c3aed - Fix nested button error in TabBar voice navigation - Fix invalid Tabs value error in DynamicChildDashboard Multi-Child Dashboard: - Load all children into Redux store properly - Fetch metrics for all children, not just selected one - Remove mock data to prevent unauthorized API calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
import { createSlice, createAsyncThunk, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit';
|
|
import { RootState } from '../store';
|
|
import { childrenApi } from '@/lib/api/children';
|
|
|
|
export interface Child {
|
|
id: string;
|
|
familyId: string;
|
|
name: string;
|
|
birthDate: string;
|
|
gender: 'male' | 'female' | 'other';
|
|
photoUrl?: string;
|
|
photoAlt?: string;
|
|
displayColor: string;
|
|
sortOrder: number;
|
|
nickname?: string;
|
|
medicalInfo?: any;
|
|
createdAt: string;
|
|
updatedAt?: string;
|
|
// Offline metadata
|
|
_optimistic?: boolean;
|
|
_localId?: string;
|
|
_version?: number;
|
|
}
|
|
|
|
// Create entity adapter
|
|
const childrenAdapter = createEntityAdapter<Child>({
|
|
selectId: (child) => child.id,
|
|
// Sort by sortOrder (birth order) instead of createdAt
|
|
sortComparer: (a, b) => a.sortOrder - b.sortOrder,
|
|
});
|
|
|
|
// Async thunks
|
|
export const fetchChildren = createAsyncThunk(
|
|
'children/fetch',
|
|
async (familyId: string) => {
|
|
const children = await childrenApi.getChildren(familyId);
|
|
return children;
|
|
}
|
|
);
|
|
|
|
export const createChild = createAsyncThunk(
|
|
'children/create',
|
|
async (child: Omit<Child, 'id' | 'createdAt' | 'updatedAt'>, { rejectWithValue }) => {
|
|
try {
|
|
const response = await fetch('/api/v1/children', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(child),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to create child');
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error: any) {
|
|
return rejectWithValue(error.message);
|
|
}
|
|
}
|
|
);
|
|
|
|
export const updateChild = createAsyncThunk(
|
|
'children/update',
|
|
async ({ id, updates }: { id: string; updates: Partial<Child> }, { rejectWithValue }) => {
|
|
try {
|
|
const response = await fetch(`/api/v1/children/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to update child');
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error: any) {
|
|
return rejectWithValue(error.message);
|
|
}
|
|
}
|
|
);
|
|
|
|
interface ChildrenState {
|
|
loading: boolean;
|
|
error: string | null;
|
|
selectedChildId: string | null;
|
|
selectedChildIds: string[];
|
|
defaultChildId: string | null;
|
|
viewMode: 'auto' | 'tabs' | 'cards';
|
|
lastSelectedPerScreen: Record<string, string>;
|
|
lastSyncTime: string | null;
|
|
}
|
|
|
|
const childrenSlice = createSlice({
|
|
name: 'children',
|
|
initialState: childrenAdapter.getInitialState<ChildrenState>({
|
|
loading: false,
|
|
error: null,
|
|
selectedChildId: null,
|
|
selectedChildIds: [],
|
|
defaultChildId: null,
|
|
viewMode: 'auto',
|
|
lastSelectedPerScreen: {},
|
|
lastSyncTime: null,
|
|
}),
|
|
reducers: {
|
|
// Optimistic operations
|
|
optimisticCreate: (state, action: PayloadAction<Child>) => {
|
|
childrenAdapter.addOne(state, {
|
|
...action.payload,
|
|
_optimistic: true,
|
|
_localId: action.payload.id,
|
|
_version: 1,
|
|
});
|
|
},
|
|
optimisticUpdate: (state, action: PayloadAction<{ id: string; changes: Partial<Child> }>) => {
|
|
const { id, changes } = action.payload;
|
|
const existing = state.entities[id];
|
|
if (existing) {
|
|
childrenAdapter.updateOne(state, {
|
|
id,
|
|
changes: {
|
|
...changes,
|
|
_optimistic: true,
|
|
_version: (existing._version || 0) + 1,
|
|
},
|
|
});
|
|
}
|
|
},
|
|
rollbackOptimistic: (state, action: PayloadAction<string>) => {
|
|
const child = state.entities[action.payload];
|
|
if (child?._optimistic) {
|
|
childrenAdapter.removeOne(state, action.payload);
|
|
}
|
|
},
|
|
markSynced: (state, action: PayloadAction<{ localId: string; serverId: string; serverData: Child }>) => {
|
|
const { localId, serverId, serverData } = action.payload;
|
|
childrenAdapter.removeOne(state, localId);
|
|
childrenAdapter.addOne(state, {
|
|
...serverData,
|
|
_optimistic: false,
|
|
});
|
|
},
|
|
// Select child (single)
|
|
selectChild: (state, action: PayloadAction<string>) => {
|
|
state.selectedChildId = action.payload;
|
|
state.selectedChildIds = [action.payload];
|
|
// Save to localStorage
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('selectedChildId', action.payload);
|
|
}
|
|
},
|
|
// Select multiple children
|
|
selectChildren: (state, action: PayloadAction<string[]>) => {
|
|
state.selectedChildIds = action.payload;
|
|
state.selectedChildId = action.payload[0] || null;
|
|
},
|
|
// Toggle child selection (for multi-select)
|
|
toggleChildSelection: (state, action: PayloadAction<string>) => {
|
|
const childId = action.payload;
|
|
const index = state.selectedChildIds.indexOf(childId);
|
|
|
|
if (index >= 0) {
|
|
state.selectedChildIds.splice(index, 1);
|
|
} else {
|
|
state.selectedChildIds.push(childId);
|
|
}
|
|
|
|
state.selectedChildId = state.selectedChildIds[0] || null;
|
|
},
|
|
// Set default child for quick actions
|
|
setDefaultChild: (state, action: PayloadAction<string>) => {
|
|
state.defaultChildId = action.payload;
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('defaultChildId', action.payload);
|
|
}
|
|
},
|
|
// Set view mode
|
|
setViewMode: (state, action: PayloadAction<'auto' | 'tabs' | 'cards'>) => {
|
|
state.viewMode = action.payload;
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('childViewMode', action.payload);
|
|
}
|
|
},
|
|
// Remember last selected child per screen
|
|
setLastSelectedForScreen: (state, action: PayloadAction<{ screen: string; childId: string }>) => {
|
|
state.lastSelectedPerScreen[action.payload.screen] = action.payload.childId;
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('lastSelectedPerScreen', JSON.stringify(state.lastSelectedPerScreen));
|
|
}
|
|
},
|
|
clearChildren: (state) => {
|
|
childrenAdapter.removeAll(state);
|
|
state.selectedChildId = null;
|
|
state.selectedChildIds = [];
|
|
state.defaultChildId = null;
|
|
state.error = null;
|
|
},
|
|
},
|
|
extraReducers: (builder) => {
|
|
builder
|
|
// Fetch children
|
|
.addCase(fetchChildren.pending, (state) => {
|
|
state.loading = true;
|
|
state.error = null;
|
|
})
|
|
.addCase(fetchChildren.fulfilled, (state, action) => {
|
|
state.loading = false;
|
|
state.lastSyncTime = new Date().toISOString();
|
|
const serverChildren = action.payload.map((c: Child) => ({
|
|
...c,
|
|
_optimistic: false,
|
|
}));
|
|
childrenAdapter.upsertMany(state, serverChildren);
|
|
})
|
|
.addCase(fetchChildren.rejected, (state, action) => {
|
|
state.loading = false;
|
|
state.error = action.error.message || 'Failed to fetch children';
|
|
})
|
|
// Create child
|
|
.addCase(createChild.pending, (state) => {
|
|
state.loading = true;
|
|
state.error = null;
|
|
})
|
|
.addCase(createChild.fulfilled, (state, action) => {
|
|
state.loading = false;
|
|
const serverChild = action.payload.data;
|
|
const optimisticId = action.meta.arg.id || action.meta.requestId;
|
|
if (state.entities[optimisticId]?._optimistic) {
|
|
childrenAdapter.removeOne(state, optimisticId);
|
|
}
|
|
childrenAdapter.addOne(state, {
|
|
...serverChild,
|
|
_optimistic: false,
|
|
});
|
|
})
|
|
.addCase(createChild.rejected, (state, action) => {
|
|
state.loading = false;
|
|
state.error = action.payload as string;
|
|
const optimisticId = action.meta.arg.id || action.meta.requestId;
|
|
if (state.entities[optimisticId]?._optimistic) {
|
|
childrenAdapter.removeOne(state, optimisticId);
|
|
}
|
|
})
|
|
// Update child
|
|
.addCase(updateChild.fulfilled, (state, action) => {
|
|
const serverChild = action.payload.data;
|
|
childrenAdapter.updateOne(state, {
|
|
id: serverChild.id,
|
|
changes: {
|
|
...serverChild,
|
|
_optimistic: false,
|
|
},
|
|
});
|
|
})
|
|
.addCase(updateChild.rejected, (state, action) => {
|
|
state.error = action.payload as string;
|
|
});
|
|
},
|
|
});
|
|
|
|
export const {
|
|
optimisticCreate,
|
|
optimisticUpdate,
|
|
rollbackOptimistic,
|
|
markSynced,
|
|
selectChild,
|
|
selectChildren,
|
|
toggleChildSelection,
|
|
setDefaultChild,
|
|
setViewMode,
|
|
setLastSelectedForScreen,
|
|
clearChildren,
|
|
} = childrenSlice.actions;
|
|
|
|
// Export selectors
|
|
export const childrenSelectors = childrenAdapter.getSelectors<RootState>(
|
|
(state) => state.children
|
|
);
|
|
|
|
// Custom selectors
|
|
export const selectSelectedChild = (state: RootState) => {
|
|
const id = state.children.selectedChildId;
|
|
return id ? state.children.entities[id] : null;
|
|
};
|
|
|
|
export const selectSelectedChildren = (state: RootState) => {
|
|
const ids = state.children.selectedChildIds;
|
|
return ids
|
|
.map(id => state.children.entities[id])
|
|
.filter((child): child is Child => child !== undefined);
|
|
};
|
|
|
|
export const selectDefaultChild = (state: RootState) => {
|
|
const id = state.children.defaultChildId;
|
|
return id ? state.children.entities[id] : null;
|
|
};
|
|
|
|
export const selectChildrenByFamily = (state: RootState, familyId: string) =>
|
|
childrenSelectors
|
|
.selectAll(state)
|
|
.filter((child) => child.familyId === familyId);
|
|
|
|
export const selectPendingChildren = (state: RootState) =>
|
|
childrenSelectors
|
|
.selectAll(state)
|
|
.filter((child) => child._optimistic);
|
|
|
|
export const selectChildrenCount = (state: RootState) =>
|
|
childrenSelectors.selectTotal(state);
|
|
|
|
export const selectViewMode = (state: RootState) => {
|
|
const { viewMode } = state.children;
|
|
const childrenCount = childrenSelectors.selectTotal(state);
|
|
|
|
if (viewMode === 'auto') {
|
|
return childrenCount <= 3 ? 'tabs' : 'cards';
|
|
}
|
|
|
|
return viewMode;
|
|
};
|
|
|
|
export const selectChildColor = (childId: string) => (state: RootState) => {
|
|
const child = state.children.entities[childId];
|
|
return child?.displayColor || '#FF6B9D';
|
|
};
|
|
|
|
export const selectLastSelectedForScreen = (screen: string) => (state: RootState) => {
|
|
return state.children.lastSelectedPerScreen[screen];
|
|
};
|
|
|
|
export default childrenSlice.reducer;
|