feat(site-finder): debounce weight re-analyze (#201 Phase 2) #215
2 changed files with 83 additions and 12 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
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