perf(grid): optimize state store with O(1) row indexing and debounced summary calculations

This commit is contained in:
Michael committed 2026-05-26 13:53:51 +02:00
1 parent 8f2c4c77ba
commit d75102fb18
6 files changed
+86 -37

No files matched your search

@@ -5,7 +5,7 @@ import { useSession } from "next-auth/react";
import { BulkAiAnalyzer } from "./bulk-ai-analyzer"; import { BulkAiAnalyzer } from "./bulk-ai-analyzer";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } from "lucide-react"; 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 { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
import { SuccessModal } from "@/components/shared/success-modal"; import { SuccessModal } from "@/components/shared/success-modal";
import { import {
@@ -45,7 +45,7 @@ export function FloatingSummaryBar() {
message: "" 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 { save, hasDrafts, count } = useSaveDrafts(filters.magasin || "TOTAL", visibleCodeins);
const handleSave = () => { const handleSave = () => {
@@ -15,11 +15,13 @@ interface GridFilterBarProps {
magasins: { code: string; nom: string }[]; magasins: { code: string; nom: string }[];
} }
const EMPTY_DRAFT_CHANGES: Record<string, string> = {};
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) { export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const filters = useGridStore((s) => s.filters); const filters = useGridStore((s) => s.filters);
const setFilter = useGridStore((s) => s.setFilter); const setFilter = useGridStore((s) => s.setFilter);
const rows = useGridStore((s) => s.rows); 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 router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isRefreshing, setIsRefreshing] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false);
@@ -86,6 +86,8 @@ interface HeatmapGridProps {
isAdmin?: boolean; isAdmin?: boolean;
} }
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
// ========================================================================= // =========================================================================
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires) // OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
// ========================================================================= // =========================================================================
@@ -272,7 +274,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
const rows = useGridStore((s) => s.rows); const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters); const filters = useGridStore((s) => s.filters);
const displayDensity = useGridStore((s) => s.displayDensity); 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); const activeMagasin = useGridStore((s) => s.activeMagasin);
// Filtre client-side par code3 (famille) et codeGamme // Filtre client-side par code3 (famille) et codeGamme
@@ -707,7 +709,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
count: tableRows.length, count: tableRows.length,
getScrollElement: () => tableContainerRef.current, getScrollElement: () => tableContainerRef.current,
estimateSize: () => rowHeight, estimateSize: () => rowHeight,
overscan: 20, overscan: 8,
}); });
const visibleColumns = table.getVisibleLeafColumns(); const visibleColumns = table.getVisibleLeafColumns();
@@ -769,7 +771,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<div <div
ref={tableContainerRef} ref={tableContainerRef}
className="h-full w-full overflow-auto rounded-[12px] scroll-smooth relative" className="h-full w-full overflow-auto rounded-[12px] relative"
style={{ style={{
background: "var(--bg-surface)", background: "var(--bg-surface)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
@@ -835,7 +837,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
const isSelected = row.getIsSelected(); const isSelected = row.getIsSelected();
return ( return (
<div key={row.id} ref={rowVirtualizer.measureElement} style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}> <div key={row.id} style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
<GridRow <GridRow
virtualRow={virtualRow} virtualRow={virtualRow}
row={row} row={row}
+11 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useGridStore } from "@/features/grid/store/use-grid-store";
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes"; import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
import { GammeCode } from "@/types/grid"; import { GammeCode } from "@/types/grid";
@@ -17,9 +17,16 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows); const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
// Only consider changes that are in the filter list (if provided) // Only consider changes that are in the filter list (if provided)
const activeDrafts = filterCodeins const filterCodeinSet = useMemo(
? Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => filterCodeins.includes(codein))) () => filterCodeins ? new Set(filterCodeins) : null,
: draftChanges; [filterCodeins]
);
const activeDrafts = useMemo(
() => filterCodeinSet
? Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => filterCodeinSet.has(codein)))
: draftChanges,
[draftChanges, filterCodeinSet]
);
const count = Object.keys(activeDrafts).length; const count = Object.keys(activeDrafts).length;
+52 -12
View File
@@ -7,6 +7,8 @@ import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/gr
interface GridState { interface GridState {
/** Source data from server */ /** Source data from server */
rows: ProductRow[]; rows: ProductRow[];
/** Fast lookup by codein; not persisted */
rowsByCodein: Record<string, ProductRow>;
/** Draft edits: codein → new GammeCode */ /** Draft edits: codein → new GammeCode */
draftChanges: Record<string, GammeCode>; draftChanges: Record<string, GammeCode>;
filters: GridFilters; filters: GridFilters;
@@ -40,6 +42,14 @@ interface GridState {
setColumnSizing: (updater: Record<string, number> | ((old: Record<string, number>) => Record<string, number>)) => void; setColumnSizing: (updater: Record<string, number> | ((old: Record<string, number>) => Record<string, number>)) => void;
} }
let summaryTimer: ReturnType<typeof setTimeout> | null = null;
function indexRows(rows: ProductRow[]): Record<string, ProductRow> {
const indexed: Record<string, ProductRow> = {};
for (const row of rows) indexed[row.codein] = row;
return indexed;
}
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, magasin = "TOTAL"): GridSummary { function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, magasin = "TOTAL"): GridSummary {
const active = rows.filter((r) => { const active = rows.filter((r) => {
const g = drafts[r.codein] ?? r.codeGamme; const g = drafts[r.codein] ?? r.codeGamme;
@@ -64,6 +74,7 @@ export const useGridStore = create<GridState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
rows: [], rows: [],
rowsByCodein: {},
activeMagasin: "TOTAL", activeMagasin: "TOTAL",
draftChanges: {}, draftChanges: {},
filters: { filters: {
@@ -89,17 +100,26 @@ export const useGridStore = create<GridState>()(
columnSizing: {}, columnSizing: {},
setActiveMagasin: (code) => { setActiveMagasin: (code) => {
const { rows, draftChanges } = get(); set({ activeMagasin: code });
set({ activeMagasin: code, summary: computeSummary(rows, draftChanges, code) }); if (summaryTimer) clearTimeout(summaryTimer);
summaryTimer = setTimeout(() => {
const state = get();
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
}, 80);
}, },
setRows: (rows) => { 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) => { setDraftGamme: (codein, gamme) => {
const { rows, draftChanges: oldDrafts } = get(); const { rowsByCodein, draftChanges: oldDrafts } = get();
const originalRow = rows.find(r => r.codein === codein); const originalRow = rowsByCodein[codein];
const originalGamme = originalRow?.codeGamme; const originalGamme = originalRow?.codeGamme;
const draftChanges = { ...oldDrafts }; const draftChanges = { ...oldDrafts };
@@ -112,16 +132,31 @@ export const useGridStore = create<GridState>()(
draftChanges[codein] = gamme; 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: () => { 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) => { clearDrafts: (codeins) => {
const draftChanges = { ...get().draftChanges }; const draftChanges = { ...get().draftChanges };
codeins.forEach((id) => delete draftChanges[id]); 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 */ /** Apply saved drafts onto row.codeGamme so isModified works after clearing drafts */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => { applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => {
@@ -132,7 +167,7 @@ export const useGridStore = create<GridState>()(
} }
return r; return r;
}); });
set({ rows }); set({ rows, rowsByCodein: indexRows(rows), summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
}, },
setFilter: (key, value) => { setFilter: (key, value) => {
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } })); set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
@@ -144,11 +179,11 @@ export const useGridStore = create<GridState>()(
}, },
batchSetDraftGamme: (newChanges) => { batchSetDraftGamme: (newChanges) => {
const { rows, draftChanges: oldDrafts } = get(); const { rows, rowsByCodein, draftChanges: oldDrafts } = get();
const updatedDrafts = { ...oldDrafts }; const updatedDrafts = { ...oldDrafts };
Object.entries(newChanges).forEach(([codein, gamme]) => { Object.entries(newChanges).forEach(([codein, gamme]) => {
const originalRow = rows.find(r => r.codein === codein); const originalRow = rowsByCodein[codein];
const originalGamme = originalRow?.codeGamme; const originalGamme = originalRow?.codeGamme;
if (gamme === originalGamme) { if (gamme === originalGamme) {
@@ -158,7 +193,12 @@ export const useGridStore = create<GridState>()(
} }
}); });
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) => { setColumnVisibility: (updater) => {
+12 -14
View File
@@ -1,22 +1,20 @@
# Plan - Commit et Push des modifications locales # Plan - Commit et Push des optimisations de performances de la grille
## Contexte ## 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 ## Focus Actuel
Validation finale et dépôt propre. Validation de la compilation, staging des optimisations de performance, commit et push.
## Master Plan ## Master Plan
- [x] Analyser l'état actuel de Git et identifier les fichiers modifiés/ajoutés (Fait) - [x] Analyser l'état de Git et identifier les nouveaux fichiers modifiés (Fait: 5 fichiers)
- [x] Vérifier la compilation globale avec `rtk tsc` pour s'assurer que tout est propre (Fait) - [ ] Vérifier la compilation globale du projet avec `rtk tsc`
- [x] Ajouter les fichiers à l'index Git (`rtk git add`) (Fait) - [ ] Ajouter les fichiers à l'index Git (`rtk git add`)
- [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") - [ ] Créer le commit avec un message décrivant les optimisations de performance (`rtk git commit`)
- [x] Envoyer les modifications sur la branche distante (`rtk git push`) (Fait) - [ ] Envoyer le commit vers le dépôt distant (`rtk git push`)
- [x] Valider le statut Git final (Fait) - [ ] Valider le statut Git final
## Progress Log ## 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: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é).
- **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`.