From d75102fb18b27e100cb55854acffdef4f7075d8e Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 26 May 2026 13:53:51 +0200 Subject: [PATCH] perf(grid): optimize state store with O(1) row indexing and debounced summary calculations --- .../grid/components/floating-summary-bar.tsx | 4 +- .../grid/components/grid-filter-bar.tsx | 4 +- src/features/grid/components/heatmap-grid.tsx | 10 +-- src/features/grid/hooks/use-save-drafts.ts | 15 +++-- src/features/grid/store/use-grid-store.ts | 64 +++++++++++++++---- task.md | 26 ++++---- 6 files changed, 86 insertions(+), 37 deletions(-) diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 52876b9..944fe09 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -5,7 +5,7 @@ import { useSession } from "next-auth/react"; import { BulkAiAnalyzer } from "./bulk-ai-analyzer"; import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } from "lucide-react"; -import { useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { saveSnapshot } from "@/features/snapshots/api/save-snapshot"; import { SuccessModal } from "@/components/shared/success-modal"; import { @@ -45,7 +45,7 @@ export function FloatingSummaryBar() { message: "" }); - const visibleCodeins = rows.map(r => r.codein); + const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]); const { save, hasDrafts, count } = useSaveDrafts(filters.magasin || "TOTAL", visibleCodeins); const handleSave = () => { diff --git a/src/features/grid/components/grid-filter-bar.tsx b/src/features/grid/components/grid-filter-bar.tsx index 0d7918b..38f2942 100644 --- a/src/features/grid/components/grid-filter-bar.tsx +++ b/src/features/grid/components/grid-filter-bar.tsx @@ -15,11 +15,13 @@ interface GridFilterBarProps { magasins: { code: string; nom: string }[]; } +const EMPTY_DRAFT_CHANGES: Record = {}; + export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) { const filters = useGridStore((s) => s.filters); const setFilter = useGridStore((s) => s.setFilter); const rows = useGridStore((s) => s.rows); - const draftChanges = useGridStore((s) => s.draftChanges); + const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES); const router = useRouter(); const searchParams = useSearchParams(); const [isRefreshing, setIsRefreshing] = React.useState(false); diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index fe72847..7668e61 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -86,6 +86,8 @@ interface HeatmapGridProps { isAdmin?: boolean; } +const EMPTY_DRAFT_CHANGES: Record = {}; + // ========================================================================= // OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires) // ========================================================================= @@ -272,7 +274,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { const rows = useGridStore((s) => s.rows); const filters = useGridStore((s) => s.filters); const displayDensity = useGridStore((s) => s.displayDensity); - const draftChanges = useGridStore((s) => s.draftChanges); + const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES); const activeMagasin = useGridStore((s) => s.activeMagasin); // Filtre client-side par code3 (famille) et codeGamme @@ -707,7 +709,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { count: tableRows.length, getScrollElement: () => tableContainerRef.current, estimateSize: () => rowHeight, - overscan: 20, + overscan: 8, }); const visibleColumns = table.getVisibleLeafColumns(); @@ -769,7 +771,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
+
s.applyDraftsToRows); // Only consider changes that are in the filter list (if provided) - const activeDrafts = filterCodeins - ? Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => filterCodeins.includes(codein))) - : draftChanges; + const filterCodeinSet = useMemo( + () => filterCodeins ? new Set(filterCodeins) : null, + [filterCodeins] + ); + const activeDrafts = useMemo( + () => filterCodeinSet + ? Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => filterCodeinSet.has(codein))) + : draftChanges, + [draftChanges, filterCodeinSet] + ); const count = Object.keys(activeDrafts).length; diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 3591dac..8bfb316 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -7,6 +7,8 @@ import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/gr interface GridState { /** Source data from server */ rows: ProductRow[]; + /** Fast lookup by codein; not persisted */ + rowsByCodein: Record; /** Draft edits: codein → new GammeCode */ draftChanges: Record; filters: GridFilters; @@ -40,6 +42,14 @@ interface GridState { setColumnSizing: (updater: Record | ((old: Record) => Record)) => void; } +let summaryTimer: ReturnType | null = null; + +function indexRows(rows: ProductRow[]): Record { + const indexed: Record = {}; + for (const row of rows) indexed[row.codein] = row; + return indexed; +} + function computeSummary(rows: ProductRow[], drafts: Record, magasin = "TOTAL"): GridSummary { const active = rows.filter((r) => { const g = drafts[r.codein] ?? r.codeGamme; @@ -64,6 +74,7 @@ export const useGridStore = create()( persist( (set, get) => ({ rows: [], + rowsByCodein: {}, activeMagasin: "TOTAL", draftChanges: {}, filters: { @@ -89,17 +100,26 @@ export const useGridStore = create()( columnSizing: {}, setActiveMagasin: (code) => { - const { rows, draftChanges } = get(); - set({ activeMagasin: code, summary: computeSummary(rows, draftChanges, code) }); + set({ activeMagasin: code }); + if (summaryTimer) clearTimeout(summaryTimer); + summaryTimer = setTimeout(() => { + const state = get(); + set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) }); + }, 80); }, setRows: (rows) => { - set({ rows, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); + set({ rows, rowsByCodein: indexRows(rows) }); + if (summaryTimer) clearTimeout(summaryTimer); + summaryTimer = setTimeout(() => { + const state = get(); + set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) }); + }, 120); }, setDraftGamme: (codein, gamme) => { - const { rows, draftChanges: oldDrafts } = get(); - const originalRow = rows.find(r => r.codein === codein); + const { rowsByCodein, draftChanges: oldDrafts } = get(); + const originalRow = rowsByCodein[codein]; const originalGamme = originalRow?.codeGamme; const draftChanges = { ...oldDrafts }; @@ -112,16 +132,31 @@ export const useGridStore = create()( draftChanges[codein] = gamme; } - set({ draftChanges, summary: computeSummary(rows, draftChanges, get().activeMagasin) }); + set({ draftChanges }); + if (summaryTimer) clearTimeout(summaryTimer); + summaryTimer = setTimeout(() => { + const state = get(); + set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) }); + }, 80); }, resetDrafts: () => { - set({ draftChanges: {}, summary: computeSummary(get().rows, {}, get().activeMagasin) }); + set({ draftChanges: {} }); + if (summaryTimer) clearTimeout(summaryTimer); + summaryTimer = setTimeout(() => { + const state = get(); + set({ summary: computeSummary(state.rows, {}, state.activeMagasin) }); + }, 80); }, clearDrafts: (codeins) => { const draftChanges = { ...get().draftChanges }; codeins.forEach((id) => delete draftChanges[id]); - set({ draftChanges, summary: computeSummary(get().rows, draftChanges, get().activeMagasin) }); + set({ draftChanges }); + if (summaryTimer) clearTimeout(summaryTimer); + summaryTimer = setTimeout(() => { + const state = get(); + set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) }); + }, 80); }, /** Apply saved drafts onto row.codeGamme so isModified works after clearing drafts */ applyDraftsToRows: (draftsToApply: Record) => { @@ -132,7 +167,7 @@ export const useGridStore = create()( } return r; }); - set({ rows }); + set({ rows, rowsByCodein: indexRows(rows), summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); }, setFilter: (key, value) => { set((state) => ({ ...state, filters: { ...state.filters, [key]: value } })); @@ -144,11 +179,11 @@ export const useGridStore = create()( }, batchSetDraftGamme: (newChanges) => { - const { rows, draftChanges: oldDrafts } = get(); + const { rows, rowsByCodein, draftChanges: oldDrafts } = get(); const updatedDrafts = { ...oldDrafts }; Object.entries(newChanges).forEach(([codein, gamme]) => { - const originalRow = rows.find(r => r.codein === codein); + const originalRow = rowsByCodein[codein]; const originalGamme = originalRow?.codeGamme; if (gamme === originalGamme) { @@ -158,7 +193,12 @@ export const useGridStore = create()( } }); - set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts, get().activeMagasin) }); + set({ draftChanges: updatedDrafts }); + if (summaryTimer) clearTimeout(summaryTimer); + summaryTimer = setTimeout(() => { + const state = get(); + set({ summary: computeSummary(rows, state.draftChanges, state.activeMagasin) }); + }, 80); }, setColumnVisibility: (updater) => { diff --git a/task.md b/task.md index f9d52c3..160fa63 100644 --- a/task.md +++ b/task.md @@ -1,22 +1,20 @@ -# Plan - Commit et Push des modifications locales +# Plan - Commit et Push des optimisations de performances de la grille ## Contexte -L'utilisateur a demandé de commiter et pusher ses modifications locales actuelles. L'analyse montre qu'il s'agit d'une implémentation majeure du chargement en streaming pour la grille de produits (11 fichiers modifiés et 2 fichiers non suivis). +L'utilisateur a demandé de commiter et pusher de nouvelles modifications locales. L'analyse révèle des optimisations de performances clés apportées au store Zustand de la grille : +1. Indexation rapide des lignes de produits par `codein` dans `rowsByCodein` pour accélérer la mise à jour des brouillons de O(N) à O(1). +2. Debouncing du calcul des résumés (`computeSummary`) afin d'alléger le thread principal lors des imports en streaming ou des modifications de masse. ## Focus Actuel -Validation finale et dépôt propre. +Validation de la compilation, staging des optimisations de performance, commit et push. ## Master Plan -- [x] Analyser l'état actuel de Git et identifier les fichiers modifiés/ajoutés (Fait) -- [x] Vérifier la compilation globale avec `rtk tsc` pour s'assurer que tout est propre (Fait) -- [x] Ajouter les fichiers à l'index Git (`rtk git add`) (Fait) -- [x] Créer le commit avec un message descriptif et professionnel (`rtk git commit`) (Fait: "feat(grid): implement product streaming with JSON chunks for grid client") -- [x] Envoyer les modifications sur la branche distante (`rtk git push`) (Fait) -- [x] Valider le statut Git final (Fait) +- [x] Analyser l'état de Git et identifier les nouveaux fichiers modifiés (Fait: 5 fichiers) +- [ ] Vérifier la compilation globale du projet avec `rtk tsc` +- [ ] Ajouter les fichiers à l'index Git (`rtk git add`) +- [ ] Créer le commit avec un message décrivant les optimisations de performance (`rtk git commit`) +- [ ] Envoyer le commit vers le dépôt distant (`rtk git push`) +- [ ] Valider le statut Git final ## Progress Log -- **2026-05-26 13:31** : Analyse initiale des modifications. Identification de l'implémentation de la grille en streaming (`src/features/grid` et `/api/grid`). Initialisation du plan de commit et push. -- **2026-05-26 13:32** : Vérification de la compilation effectuée via `rtk tsc`. Aucun problème introduit dans les fichiers de la grille. -- **2026-05-26 13:33** : Ajout de tous les fichiers modifiés et nouveaux à l'index Git via `rtk git add .`. -- **2026-05-26 13:34** : Création du commit avec le message descriptif sur le streaming de la grille. -- **2026-05-26 13:35** : Push réussi des modifications vers la branche distante `origin/main`. +- **2026-05-26 13:53** : Analyse des nouvelles modifications. Découverte des optimisations majeures sur `use-grid-store.ts` (indexation O(1) et debouncing des calculs de résumé).