feat(site-finder): debounce weight re-analyze to 300ms (#201 Phase 2)

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
This commit is contained in:
lekss361 2026-05-16 15:48:01 +03:00
parent 46203ab365
commit 1e198efc92
2 changed files with 83 additions and 12 deletions

View file

@ -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<number | null>(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<PoiCategoryKey, number>;
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<string>(() =>
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 && (
<p
style={{
fontSize: 12,
color: "#6b7280",
margin: "6px 0 0",
}}
>
Пересчёт
</p>
)}
</div>
{/* Error state */}

View file

@ -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<T>(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;
}