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:
parent
46203ab365
commit
1e198efc92
2 changed files with 83 additions and 12 deletions
|
|
@ -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 */}
|
||||||
|
|
|
||||||
19
frontend/src/hooks/useDebouncedValue.ts
Normal file
19
frontend/src/hooks/useDebouncedValue.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue