diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index 9b52ae895..ac0083db9 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -8986,7 +8986,7 @@ "description": "Normalize nested state structures for better performance", "owner": "frontend", "estimated_hours": 6, - "status": "todo", + "status": "completed", "files_involved": [], "implementation_steps": [ { @@ -9007,7 +9007,8 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "Implemented state normalization utility and applied to LibraryStore. Created normalization utilities (normalize, denormalize, addToNormalized, updateInNormalized, removeFromNormalized, etc.) and updated LibraryStore to use normalized state structure (byId + allIds pattern) for better performance. Updated selectors to convert normalized state to arrays for compatibility. Updated tests and components to use new selectors.", + "completed_at": "2025-12-25T14:10:10.444700Z" }, { "id": "FE-STATE-010", diff --git a/apps/web/src/features/dashboard/pages/DashboardPage.tsx b/apps/web/src/features/dashboard/pages/DashboardPage.tsx index d417060c0..dc4c54b6f 100644 --- a/apps/web/src/features/dashboard/pages/DashboardPage.tsx +++ b/apps/web/src/features/dashboard/pages/DashboardPage.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useAuthStore } from '@/stores/auth'; -import { useLibraryStore } from '@/stores/library'; +import { useLibraryItems, useLibraryActions, useLibraryStatus } from '@/utils/storeSelectors'; import { Card, CardContent, @@ -13,7 +13,10 @@ import { Music, MessageSquare, Library, Users, Heart } from 'lucide-react'; function DashboardPage() { const { user } = useAuthStore(); - const { items, fetchItems, isLoading } = useLibraryStore(); + // FE-STATE-009: Use selector that returns denormalized array + const items = useLibraryItems(); + const { fetchItems } = useLibraryActions(); + const { isLoading } = useLibraryStatus(); useEffect(() => { fetchItems({ limit: 5 }); diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index bf333204a..286acdd78 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; -import { useLibraryStore } from '@/stores/library'; +import { useLibraryItems, useLibraryActions, useLibraryStatus } from '@/utils/storeSelectors'; import { useDashboard } from '@/features/dashboard/hooks/useDashboard'; import { Card, @@ -21,7 +21,10 @@ import { fr } from 'date-fns/locale'; */ export function DashboardPage() { const { user } = useAuthStore(); - const { items, fetchItems, isLoading: isLoadingLibrary } = useLibraryStore(); + // FE-STATE-009: Use selector that returns denormalized array + const items = useLibraryItems(); + const { fetchItems } = useLibraryActions(); + const { isLoading: isLoadingLibrary } = useLibraryStatus(); const { stats, recentActivity, isLoading: isLoadingDashboard } = useDashboard(); const navigate = useNavigate(); @@ -29,9 +32,6 @@ export function DashboardPage() { fetchItems({ limit: 5 }); }, [fetchItems]); - // Sécuriser items pour s'assurer que c'est toujours un tableau - const safeItems = Array.isArray(items) ? items : []; - // FE-PAGE-001: Format stats with real data const formatNumber = (num: number): string => { if (num >= 1000000) { @@ -233,7 +233,7 @@ export function DashboardPage() { ) : (
- {safeItems.slice(0, 3).map((item) => ( + {items.slice(0, 3).map((item) => (
@@ -248,7 +248,7 @@ export function DashboardPage() {
))} - {safeItems.length === 0 && ( + {items.length === 0 && (

Aucune piste dans votre bibliothèque

diff --git a/apps/web/src/stores/library.ts b/apps/web/src/stores/library.ts index 464b48bb1..5aed2a810 100644 --- a/apps/web/src/stores/library.ts +++ b/apps/web/src/stores/library.ts @@ -2,11 +2,23 @@ import { create } from 'zustand'; import { persist, devtools } from 'zustand/middleware'; import { apiClient } from '@/services/api/client'; import { undoRedo } from '@/utils/undoRedo'; +import { + normalize, + denormalize, + addToNormalized, + updateInNormalized, + removeFromNormalized, + replaceNormalized, + mergeNormalized, + createEmptyNormalized, + type NormalizedState, +} from '@/utils/stateNormalization'; import type { LibraryItem, PaginatedResponse, ApiError } from '@/types'; interface LibraryState { - items: LibraryItem[]; - favorites: LibraryItem[]; + // FE-STATE-009: Normalized state for better performance + items: NormalizedState; + favorites: NormalizedState; isLoading: boolean; error: ApiError | null; pagination: { @@ -47,9 +59,9 @@ export const useLibraryStore = create ({ - // État initial - items: [], - favorites: [], + // État initial - FE-STATE-009: Normalized state + items: createEmptyNormalized(), + favorites: createEmptyNormalized(), isLoading: false, error: null, pagination: { @@ -88,8 +100,9 @@ export const useLibraryStore = create(), error: error as ApiError, isLoading: false, }); @@ -130,15 +143,16 @@ export const useLibraryStore = create(), error: error as ApiError, isLoading: false, }); @@ -162,8 +176,9 @@ export const useLibraryStore = create ({ - items: [newItem, ...state.items], + items: addToNormalized(state.items, newItem, 0), isLoading: false, error: null, })); @@ -177,36 +192,62 @@ export const useLibraryStore = create { const previousState = get(); - const item = previousState.items.find((i) => i.id === itemId); + const item = previousState.items.byId[itemId]; const newFavoriteStatus = item ? !item.is_favorite : true; - // Optimistic update - set((state) => ({ - items: state.items.map((i) => - i.id === itemId ? { ...i, is_favorite: newFavoriteStatus } : i, - ), - favorites: newFavoriteStatus && item - ? [...state.favorites.filter((i) => i.id !== itemId), { ...item, is_favorite: true }] - : state.favorites.filter((i) => i.id !== itemId), - })); + // Optimistic update - FE-STATE-009: Update in normalized state + set((state) => { + const updatedItems = updateInNormalized(state.items, itemId, { + is_favorite: newFavoriteStatus, + }); + + let updatedFavorites = state.favorites; + if (newFavoriteStatus && item) { + // Add to favorites + const favoriteItem = { ...item, is_favorite: true }; + updatedFavorites = addToNormalized( + removeFromNormalized(state.favorites, itemId), + favoriteItem, + ); + } else { + // Remove from favorites + updatedFavorites = removeFromNormalized(state.favorites, itemId); + } + + return { + items: updatedItems, + favorites: updatedFavorites, + }; + }); try { const response = await apiClient.post(`/tracks/${itemId}/favorite`); const updatedItem = response.data; - // Apply server response - set((state) => ({ - items: state.items.map((i) => - i.id === itemId - ? { ...i, is_favorite: updatedItem.is_favorite } - : i, - ), - favorites: updatedItem.is_favorite - ? [...state.favorites.filter((i) => i.id !== itemId), updatedItem] - : state.favorites.filter((i) => i.id !== itemId), - })); + // Apply server response - FE-STATE-009: Update in normalized state + set((state) => { + const updatedItems = updateInNormalized(state.items, itemId, { + is_favorite: updatedItem.is_favorite, + }); + + let updatedFavorites = state.favorites; + if (updatedItem.is_favorite) { + updatedFavorites = addToNormalized( + removeFromNormalized(state.favorites, itemId), + updatedItem, + ); + } else { + updatedFavorites = removeFromNormalized(state.favorites, itemId); + } + + return { + items: updatedItems, + favorites: updatedFavorites, + }; + }); } catch (error: any) { // Rollback on error set(() => previousState); @@ -216,13 +257,14 @@ export const useLibraryStore = create { const previousState = get(); - // Optimistic update - remove item immediately + // Optimistic update - remove item immediately from normalized state set((state) => ({ - items: state.items.filter((item) => item.id !== itemId), - favorites: state.favorites.filter((item) => item.id !== itemId), + items: removeFromNormalized(state.items, itemId), + favorites: removeFromNormalized(state.favorites, itemId), })); try { @@ -247,10 +289,10 @@ export const useLibraryStore = create set({ error }), - clearItems: () => + clearItems: () => { set({ - items: [], - favorites: [], + items: createEmptyNormalized(), + favorites: createEmptyNormalized(), pagination: { page: 1, limit: 20, @@ -258,6 +300,8 @@ export const useLibraryStore = create { // Track changes to items and favorites (not loading/error states) + // FE-STATE-009: Compare normalized state structures return ( JSON.stringify(state.items) !== JSON.stringify(prevState?.items) || JSON.stringify(state.favorites) !== JSON.stringify(prevState?.favorites) ); }, - }, - ), - { - name: 'library-storage', + }, + ), + { + name: 'library-storage', partialize: (state) => ({ // FE-STATE-001: Persist favorites and filters for offline support + // FE-STATE-009: Persist normalized favorites state favorites: state.favorites, filters: state.filters, // Don't persist items and pagination as they should be fetched fresh diff --git a/apps/web/src/test/stores.test.ts b/apps/web/src/test/stores.test.ts index b03cc8a9b..46ada613a 100644 --- a/apps/web/src/test/stores.test.ts +++ b/apps/web/src/test/stores.test.ts @@ -4,6 +4,7 @@ import { useUIStore } from '@/stores/ui'; import { useChatStore } from '@/stores/chat'; import { useLibraryStore } from '@/stores/library'; import { usePlayerStore } from '@/stores/player'; +import { createEmptyNormalized } from '@/utils/stateNormalization'; // Mock localStorage const localStorageMock = { @@ -153,8 +154,9 @@ describe('Zustand Stores', () => { describe('Library Store', () => { it('should have initial state', () => { const state = useLibraryStore.getState(); - expect(state.items).toEqual([]); - expect(state.favorites).toEqual([]); + // FE-STATE-009: Normalized state structure + expect(state.items).toEqual(createEmptyNormalized()); + expect(state.favorites).toEqual(createEmptyNormalized()); expect(state.isLoading).toBe(false); expect(state.error).toBeNull(); }); diff --git a/apps/web/src/utils/stateNormalization.ts b/apps/web/src/utils/stateNormalization.ts new file mode 100644 index 000000000..55b520815 --- /dev/null +++ b/apps/web/src/utils/stateNormalization.ts @@ -0,0 +1,247 @@ +/** + * State Normalization Utilities + * FE-STATE-009: Normalize nested state structures for better performance + * + * Provides utilities to normalize nested state structures (arrays of objects) + * into flat structures with indexed lookups (byId + allIds pattern). + * This improves performance by enabling O(1) lookups instead of O(n) array searches. + */ + +/** + * Normalized state structure + * Instead of storing items as arrays: [item1, item2, item3] + * We store them as: { byId: { id1: item1, id2: item2, id3: item3 }, allIds: ['id1', 'id2', 'id3'] } + */ +export interface NormalizedState { + byId: Record; + allIds: string[]; +} + +/** + * Normalize an array of items into a normalized state structure + * @param items Array of items with an 'id' property + * @returns Normalized state with byId and allIds + */ +export function normalize( + items: T[], +): NormalizedState { + const byId: Record = {}; + const allIds: string[] = []; + + for (const item of items) { + if (item.id) { + byId[item.id] = item; + allIds.push(item.id); + } + } + + return { byId, allIds }; +} + +/** + * Denormalize a normalized state back into an array + * @param normalized Normalized state structure + * @returns Array of items in the order of allIds + */ +export function denormalize( + normalized: NormalizedState, +): T[] { + return normalized.allIds + .map((id) => normalized.byId[id]) + .filter((item): item is T => item !== undefined); +} + +/** + * Add an item to normalized state + * @param normalized Current normalized state + * @param item Item to add + * @param position Optional position to insert at (default: append) + * @returns New normalized state + */ +export function addToNormalized( + normalized: NormalizedState, + item: T, + position?: number, +): NormalizedState { + const { byId, allIds } = normalized; + const newById = { ...byId, [item.id]: item }; + + let newAllIds: string[]; + if (position === undefined || position >= allIds.length) { + // Append to end + newAllIds = [...allIds, item.id]; + } else if (position <= 0) { + // Prepend to start + newAllIds = [item.id, ...allIds]; + } else { + // Insert at position + newAllIds = [ + ...allIds.slice(0, position), + item.id, + ...allIds.slice(position), + ]; + } + + return { byId: newById, allIds: newAllIds }; +} + +/** + * Update an item in normalized state + * @param normalized Current normalized state + * @param itemId ID of item to update + * @param updates Partial updates to apply + * @returns New normalized state + */ +export function updateInNormalized( + normalized: NormalizedState, + itemId: string, + updates: Partial, +): NormalizedState { + const existing = normalized.byId[itemId]; + if (!existing) { + return normalized; + } + + return { + ...normalized, + byId: { + ...normalized.byId, + [itemId]: { ...existing, ...updates }, + }, + }; +} + +/** + * Remove an item from normalized state + * @param normalized Current normalized state + * @param itemId ID of item to remove + * @returns New normalized state + */ +export function removeFromNormalized( + normalized: NormalizedState, + itemId: string, +): NormalizedState { + const { [itemId]: removed, ...byId } = normalized.byId; + const allIds = normalized.allIds.filter((id) => id !== itemId); + + return { byId, allIds }; +} + +/** + * Replace all items in normalized state + * @param normalized Current normalized state + * @param items New array of items + * @returns New normalized state + */ +export function replaceNormalized( + normalized: NormalizedState, + items: T[], +): NormalizedState { + return normalize(items); +} + +/** + * Merge items into normalized state (add new, update existing) + * @param normalized Current normalized state + * @param items Items to merge + * @returns New normalized state + */ +export function mergeNormalized( + normalized: NormalizedState, + items: T[], +): NormalizedState { + const newById = { ...normalized.byId }; + const newAllIds = [...normalized.allIds]; + const existingIds = new Set(normalized.allIds); + + for (const item of items) { + if (item.id) { + newById[item.id] = item; + if (!existingIds.has(item.id)) { + newAllIds.push(item.id); + existingIds.add(item.id); + } + } + } + + return { byId: newById, allIds: newAllIds }; +} + +/** + * Get an item by ID from normalized state + * @param normalized Normalized state + * @param itemId ID to look up + * @returns Item or undefined + */ +export function getById( + normalized: NormalizedState, + itemId: string, +): T | undefined { + return normalized.byId[itemId]; +} + +/** + * Get multiple items by IDs from normalized state + * @param normalized Normalized state + * @param itemIds Array of IDs to look up + * @returns Array of items (may contain undefined for missing IDs) + */ +export function getByIds( + normalized: NormalizedState, + itemIds: string[], +): (T | undefined)[] { + return itemIds.map((id) => normalized.byId[id]); +} + +/** + * Check if an item exists in normalized state + * @param normalized Normalized state + * @param itemId ID to check + * @returns True if item exists + */ +export function hasId( + normalized: NormalizedState, + itemId: string, +): boolean { + return itemId in normalized.byId; +} + +/** + * Get the count of items in normalized state + * @param normalized Normalized state + * @returns Number of items + */ +export function getCount(normalized: NormalizedState): number { + return normalized.allIds.length; +} + +/** + * Create an empty normalized state + * @returns Empty normalized state + */ +export function createEmptyNormalized(): NormalizedState { + return { byId: {}, allIds: [] }; +} + +/** + * Reorder items in normalized state + * @param normalized Current normalized state + * @param fromIndex Source index + * @param toIndex Destination index + * @returns New normalized state with reordered allIds + */ +export function reorderNormalized( + normalized: NormalizedState, + fromIndex: number, + toIndex: number, +): NormalizedState { + const newAllIds = [...normalized.allIds]; + const [removed] = newAllIds.splice(fromIndex, 1); + newAllIds.splice(toIndex, 0, removed); + + return { + ...normalized, + allIds: newAllIds, + }; +} + diff --git a/apps/web/src/utils/storeSelectors.ts b/apps/web/src/utils/storeSelectors.ts index 718f2511f..9eebe465b 100644 --- a/apps/web/src/utils/storeSelectors.ts +++ b/apps/web/src/utils/storeSelectors.ts @@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth'; import { useUIStore } from '@/stores/ui'; import { useLibraryStore } from '@/stores/library'; import { useChatStore } from '@/stores/chat'; +import { denormalize } from '@/utils/stateNormalization'; /** * FE-STATE-008: Optimized selectors for AuthStore @@ -79,12 +80,24 @@ export function useUIActions() { /** * FE-STATE-008: Optimized selectors for LibraryStore + * FE-STATE-009: Convert normalized state to arrays for compatibility */ export function useLibraryItems() { - return useLibraryStore(useShallow((state) => state.items)); + return useLibraryStore(useShallow((state) => denormalize(state.items))); } export function useLibraryFavorites() { + return useLibraryStore(useShallow((state) => denormalize(state.favorites))); +} + +/** + * FE-STATE-009: Get normalized state directly (for advanced use cases) + */ +export function useLibraryItemsNormalized() { + return useLibraryStore(useShallow((state) => state.items)); +} + +export function useLibraryFavoritesNormalized() { return useLibraryStore(useShallow((state) => state.favorites)); }