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"; "use client";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; 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 { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { import {
POI_DEFAULT_WEIGHTS, POI_DEFAULT_WEIGHTS,
type PoiCategoryKey, type PoiCategoryKey,
@ -104,6 +105,18 @@ function SiteFinderContent() {
>(() => ({ ...POI_DEFAULT_WEIGHTS })); >(() => ({ ...POI_DEFAULT_WEIGHTS }));
// Profile selection for the analyze call (null = system defaults). // Profile selection for the analyze call (null = system defaults).
const [activeProfileId, setActiveProfileId] = useState<number | null>(null); 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>(() => const [profileUserId, setProfileUserId] = useState<string>(() =>
typeof window === "undefined" typeof window === "undefined"
? "" ? ""
@ -144,6 +157,40 @@ function SiteFinderContent() {
setTabState(isTabId(urlTab) ? urlTab : "overview"); setTabState(isTabId(urlTab) ? urlTab : "overview");
}, [searchParams]); }, [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) { function handleAnalyze(cadNum: string) {
setIsochrones(undefined); setIsochrones(undefined);
setTab("overview"); setTab("overview");
@ -168,18 +215,10 @@ function SiteFinderContent() {
) { ) {
setCurrentWeights(weights); setCurrentWeights(weights);
setActiveProfileId(profileId); 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) { if (data?.cad_num) {
setIsochrones(undefined); setPendingWeightsChange({ weights, profileId });
mutate({
cad: data.cad_num,
options:
profileId != null
? { profileId }
: profileUserId
? { profileUserId, weights }
: { weights },
});
} }
} }
@ -304,6 +343,19 @@ function SiteFinderContent() {
userId={profileUserId || undefined} userId={profileUserId || undefined}
adminToken={adminToken || 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> </div>
{/* Error state */} {/* 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;
}