From 1e198efc924422232989cac8f6cf32ba8e596d50 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 15:48:01 +0300 Subject: [PATCH] feat(site-finder): debounce weight re-analyze to 300ms (#201 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add useDebouncedValue hook; replace direct mutate call in handleWeightsChange with debounced pendingWeightsChange state to collapse rapid 'Применить' clicks into a single /analyze request. Add 'Пересчёт…' indicator during re-analyze. Closes #201 --- frontend/src/app/site-finder/page.tsx | 76 +++++++++++++++++++++---- frontend/src/hooks/useDebouncedValue.ts | 19 +++++++ 2 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 frontend/src/hooks/useDebouncedValue.ts diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 5f914ca2..baa15870 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -15,6 +15,7 @@ import { LandTab } from "@/components/site-finder/LandTab"; import { MarketTab } from "@/components/site-finder/MarketTab"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, @@ -104,6 +105,18 @@ function SiteFinderContent() { >(() => ({ ...POI_DEFAULT_WEIGHTS })); // Profile selection for the analyze call (null = system defaults). const [activeProfileId, setActiveProfileId] = useState(null); + + // Pending weights change — set immediately when user clicks "Применить". + // Debounced to 300ms before triggering re-analyze, so rapid clicks collapse + // into a single request (#201 Phase 2). + const [pendingWeightsChange, setPendingWeightsChange] = useState<{ + weights: Record; + profileId: number | null; + } | null>(null); + const debouncedWeightsChange = useDebouncedValue(pendingWeightsChange, 300); + // Ref to skip the initial mount effect (we only re-analyze on actual changes). + const weightsChangeInitializedRef = useRef(false); + const [profileUserId, setProfileUserId] = useState(() => typeof window === "undefined" ? "" @@ -144,6 +157,40 @@ function SiteFinderContent() { setTabState(isTabId(urlTab) ? urlTab : "overview"); }, [searchParams]); + // Debounced re-analyze trigger: fires 300ms after the last "Применить" click. + // Skip initial mount (weightsChangeInitializedRef guards the first effect run). + // dataRef lets the effect read current data without listing it as a dep + // (avoids re-registering the timeout on every analyze completion). + const dataRef = useRef(data); + dataRef.current = data; + const profileUserIdRef = useRef(profileUserId); + profileUserIdRef.current = profileUserId; + + useEffect(() => { + if (!weightsChangeInitializedRef.current) { + weightsChangeInitializedRef.current = true; + return; + } + if (!debouncedWeightsChange) return; + const currentData = dataRef.current; + if (!currentData?.cad_num) return; + + const { weights, profileId } = debouncedWeightsChange; + const currentProfileUserId = profileUserIdRef.current; + setIsochrones(undefined); + mutate({ + cad: currentData.cad_num, + options: + profileId != null + ? { profileId } + : currentProfileUserId + ? { profileUserId: currentProfileUserId, weights } + : { weights }, + }); + // mutate is stable from useMutation — safe to omit from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedWeightsChange]); + function handleAnalyze(cadNum: string) { setIsochrones(undefined); setTab("overview"); @@ -168,18 +215,10 @@ function SiteFinderContent() { ) { setCurrentWeights(weights); setActiveProfileId(profileId); - // Re-analyze with the new weights if a parcel is already loaded (#201). + // Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2). + // The actual mutate fires after 300ms of silence via debouncedWeightsChange. if (data?.cad_num) { - setIsochrones(undefined); - mutate({ - cad: data.cad_num, - options: - profileId != null - ? { profileId } - : profileUserId - ? { profileUserId, weights } - : { weights }, - }); + setPendingWeightsChange({ weights, profileId }); } } @@ -304,6 +343,19 @@ function SiteFinderContent() { userId={profileUserId || undefined} adminToken={adminToken || undefined} /> + {/* Recalculation indicator — shown while re-analyze is in-flight after + weights change (data already loaded, pendingWeightsChange set). */} + {isPending && !!data && ( +

+ Пересчёт… +

+ )} {/* Error state */} diff --git a/frontend/src/hooks/useDebouncedValue.ts b/frontend/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..4e737639 --- /dev/null +++ b/frontend/src/hooks/useDebouncedValue.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Returns a debounced copy of `value` that only updates after `delayMs` + * milliseconds of silence. Useful to collapse rapid state changes before + * triggering expensive side-effects (e.g. API calls). + */ +export function useDebouncedValue(value: T, delayMs: number = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(handle); + }, [value, delayMs]); + + return debounced; +}