gendesign/frontend/src/hooks/useSiteAnalysis.ts
lekss361 038e39e2ec feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1)
Critical UX fix для #114 — user-drag слайдеры в WeightProfilePanel
теперь применяются immediately к scoring, без обязательного profile save.

Backend (parcels.py + schemas/parcel.py + tests):
- POST /analyze принимает optional AnalyzeRequest { weights: dict[str,float] | None }
- Priority: inline → profile_id → user_default → system defaults
- Validate against ALLOWED_CATEGORIES + [MIN_WEIGHT, MAX_WEIGHT] → 422 на violation
- Partial override semantics
- 5 mock tests

Frontend (useSiteAnalysis.ts + page.tsx):
- weights param в analyze mutation
- handleAnalyze всегда передаёт currentWeights когда activeProfileId=null
- handleWeightsChange re-trigger analyze immediately если parcel loaded

Phase 2 (debounce) + Phase 3 (Edit/Delete UI) — follow-up.
2026-05-16 13:39:14 +03:00

205 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useRef, useState } from "react";
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
import type { ParcelAnalysis } from "@/types/site-finder";
// #93 — on-demand cadastre fetch flow.
// Backend returns 202 Accepted when geometry needs fetching; frontend polls
// /fetch-status every 2s, re-triggers analyze когда status=ready.
export type FetchStatus =
| "ready"
| "fetching"
| "failed"
| "not_in_nspd"
| "invalid_format";
export interface FetchStatusResponse {
status: FetchStatus;
job_id: number | null;
error_msg: string | null;
eta_seconds: number | null;
}
export interface AnalyzeAcceptedResponse {
status: "fetching";
cad_num: string;
job_id: number;
eta_seconds: number;
message: string;
}
export type AnalyzeResult =
| { kind: "ready"; data: ParcelAnalysis }
| { kind: "fetching"; jobId: number; etaSeconds: number };
const POLL_INTERVAL_MS = 2000;
const POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
export interface AnalyzeOptions {
/** If set, backend uses this saved profile for weights. */
profileId?: number;
/** If set together with no profileId, backend uses user's default profile. */
profileUserId?: string;
/**
* Inline POI weights override — sent as request body.
* Priority: inline → profileId → profileUserId default → system.
*/
weights?: Record<string, number> | null;
}
/**
* Custom hook для analyze flow с graceful on-demand fetch fallback.
*
* Состояния:
* - idle: ничего не делали
* - pending (mutation): первичный POST /analyze в полёте
* - fetching: получили 202, идёт polling /fetch-status
* - data set: ParcelAnalysis отрисовываем
* - error: not_in_nspd / invalid_format / failed
*/
export function useSiteAnalysis() {
const [fetchingState, setFetchingState] = useState<{
cadNum: string;
jobId: number;
etaSeconds: number;
} | null>(null);
// ref для cancel polling — invalidates in-flight iteration
const cancelledRef = useRef(false);
const mutation = useMutation({
mutationFn: async ({
cad,
options,
}: {
cad: string;
options?: AnalyzeOptions;
}): Promise<ParcelAnalysis> => {
cancelledRef.current = false;
setFetchingState(null);
// Build optional query string for weight profile params.
const qs = new URLSearchParams();
if (options?.profileId != null) {
qs.set("profile_id", String(options.profileId));
}
if (options?.profileUserId) {
qs.set("profile_user_id", options.profileUserId);
}
const qsStr = qs.toString();
const analyzeUrl = (cadNum: string) => {
const base = `/api/v1/parcels/${encodeURIComponent(cadNum)}/analyze`;
return qsStr ? `${base}?${qsStr}` : base;
};
// Build optional JSON body for inline weights (#201).
const bodyPayload =
options?.weights != null
? JSON.stringify({ weights: options.weights })
: undefined;
// First request — POST /analyze
const first = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), {
method: "POST",
...(bodyPayload
? {
body: bodyPayload,
headers: { "Content-Type": "application/json" },
}
: {}),
});
if (first.status === 200) {
return first.body as ParcelAnalysis;
}
// 202 Accepted — entering polling mode
if (first.status === 202) {
const accepted = first.body as AnalyzeAcceptedResponse;
setFetchingState({
cadNum: cad,
jobId: accepted.job_id,
etaSeconds: accepted.eta_seconds,
});
// Poll loop
for (let i = 0; i < POLL_MAX_ITERATIONS; i++) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
if (cancelledRef.current) {
throw new Error("Загрузка отменена пользователем");
}
const status = await apiFetch<FetchStatusResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
);
if (status.status === "ready") {
// Re-trigger analyze — should be 200 now.
// setFetchingState(null) ПОСЛЕ await чтобы не было flicker:
// если очистить до await, mutation isPending=true но
// fetchingState=null → пустой экран ~1 RTT. После await
// mutation сразу резолвится с data — render skipped.
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
method: "POST",
...(bodyPayload
? {
body: bodyPayload,
headers: { "Content-Type": "application/json" },
}
: {}),
});
setFetchingState(null);
return second;
}
if (status.status === "not_in_nspd") {
setFetchingState(null);
throw new HTTPError(
404,
status,
status.error_msg ?? "Кадастровый номер не найден в НСПД",
);
}
if (status.status === "failed") {
setFetchingState(null);
throw new HTTPError(
503,
status,
status.error_msg ?? "НСПД временно недоступен",
);
}
if (status.status === "invalid_format") {
setFetchingState(null);
throw new HTTPError(
400,
status,
status.error_msg ?? "Неверный формат кадастрового номера",
);
}
// status === "fetching" → continue polling
}
// Polling exhausted (2 min)
setFetchingState(null);
throw new Error(
"Загрузка длится слишком долго (>2 мин). Попробуйте позже.",
);
}
throw new Error(`Unexpected response status: ${first.status}`);
},
});
const cancel = useCallback(() => {
cancelledRef.current = true;
setFetchingState(null);
mutation.reset();
}, [mutation]);
return {
...mutation,
fetchingState,
cancel,
};
}