From 740210d9ea5ec7bc21df81fa3522a832bb42e9c3 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 5 Jun 2026 15:42:58 +0500 Subject: [PATCH] feat(site-finder): horizon selector (6/12/18) on analysis screen (#996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segmented control «Горизонт прогноза» driving the analyze query (?horizon=) and Section 6's target highlight. Default 12. - HorizonSelector: radiogroup + a11y radios, tokens-only, tabular-nums, disabled while a re-analyze is in flight - useParcelAnalyzeQuery(cad, horizon=12): horizon in queryKey + ?horizon=; keepPreviousData so switching horizon doesn't blank the page to loading - AnalysisPageContent: horizon state + forecast-poll invalidation on change - Section6Forecast/ForecastHorizonsBlock: selectedHorizon → target row («Целевой») Part of EPIC #958. --- .../analysis/[cad]/AnalysisPageContent.tsx | 39 ++++++++- .../site-finder/HorizonSelector.tsx | 85 +++++++++++++++++++ .../analysis/ForecastHorizonsBlock.tsx | 36 +++++++- .../site-finder/analysis/Section6Forecast.tsx | 30 +++++-- frontend/src/lib/site-finder-api.ts | 15 ++-- 5 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/site-finder/HorizonSelector.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 5a8471af..b8213853 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -1,7 +1,10 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; +import { useQueryClient } from "@tanstack/react-query"; +import { HorizonSelector } from "@/components/site-finder/HorizonSelector"; import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo"; import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities"; import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors"; @@ -20,7 +23,23 @@ interface Props { // ── Page Content (client — needs TanStack Query) ─────────────────────────────── export function AnalysisPageContent({ cad }: Props) { - const { data, isLoading, error } = useParcelAnalyzeQuery(cad); + const [horizon, setHorizon] = useState(12); + const queryClient = useQueryClient(); + const { data, isLoading, isFetching, error } = useParcelAnalyzeQuery( + cad, + horizon, + ); + + // Changing the horizon re-runs /analyze (expensive ~10-30s), which re-enqueues + // the async §22 forecast (recompute ~30-180s). Invalidating ["parcel-forecast", + // cad] forces Section 6's poll to re-fetch; until the new run is ready it may + // still show the previous run's emphasis. The selectedHorizon prop passed to + // Section6Forecast gives an instant client-side target highlight regardless — + // that's intended. + function handleHorizonChange(h: number) { + setHorizon(h); + void queryClient.invalidateQueries({ queryKey: ["parcel-forecast", cad] }); + } // ── Loading ──────────────────────────────────────────────────────────────── @@ -98,6 +117,22 @@ export function AnalysisPageContent({ cad }: Props) { {/* Breadcrumb */} + {/* Controls row — horizon selector drives the analyze + forecast queries */} +
+ +
+ {/* Page layout: sidebar (240px) + main scroll */}
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */} - +
diff --git a/frontend/src/components/site-finder/HorizonSelector.tsx b/frontend/src/components/site-finder/HorizonSelector.tsx new file mode 100644 index 00000000..d0c64aa6 --- /dev/null +++ b/frontend/src/components/site-finder/HorizonSelector.tsx @@ -0,0 +1,85 @@ +"use client"; + +/** + * HorizonSelector — segmented control «Горизонт прогноза» (958-B1 / #996). + * + * Controlled segmented control over the three forecast horizons accepted by + * POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18; default 12). + * Drives useParcelAnalyzeQuery on the Site Finder analysis screen. + * + * A11y: role="radiogroup" wrapper + role="radio"/aria-checked per + ); + })} + + + ); +} diff --git a/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx index a8ea991a..e6592bfd 100644 --- a/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx @@ -23,6 +23,8 @@ import { interface Props { forecasts: DemandSupplyForecast[]; + /** Horizon (мес) to emphasise as the current target — highlights its row. */ + targetHorizon?: number; } const TH_STYLE: React.CSSProperties = { @@ -48,7 +50,7 @@ const TD_STYLE: React.CSSProperties = { const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" }; -export function ForecastHorizonsBlock({ forecasts }: Props) { +export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { if (forecasts.length === 0) { return (

@@ -89,9 +91,37 @@ export function ForecastHorizonsBlock({ forecasts }: Props) { {rows.map((f) => { const di = f.deficit_index; const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS; + const isTarget = f.horizon_months === targetHorizon; return ( - - {f.horizon_months} мес. + + + {f.horizon_months} мес. + {isTarget && ( + + Целевой + + )} + {di > 0 ? "+" : ""} diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index 12773718..8c693fe1 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -31,6 +31,13 @@ import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers"; interface Props { cad: string; + /** + * Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18). When + * set it takes priority over the report-derived horizon so the user's choice + * instantly drives which horizon is treated as the target (client-side), even + * before the recomputed forecast finishes (see AnalysisPageContent comment). + */ + selectedHorizon?: number; } const ADVISORY_DISCLAIMER = @@ -48,7 +55,7 @@ function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" { // ── Section6Forecast ───────────────────────────────────────────────────────── -export function Section6Forecast({ cad }: Props) { +export function Section6Forecast({ cad, selectedHorizon }: Props) { const { data, isLoading, error } = useParcelForecastQuery(cad); const isPending = @@ -136,18 +143,26 @@ export function Section6Forecast({ cad }: Props) { ); } - return ; + return ; } // ── ForecastReady (report present) ─────────────────────────────────────────── -function ForecastReady({ report }: { report: ForecastReport }) { +function ForecastReady({ + report, + selectedHorizon, +}: { + report: ForecastReport; + selectedHorizon?: number; +}) { const exec = report.exec_summary; const fm = report.future_market; - // Target horizon: prefer future_supply.horizon_months, else median of meta - // horizons, else 12. + // Target horizon: the user's HorizonSelector choice wins (instant client-side + // highlight), else future_supply.horizon_months, else median of meta horizons, + // else 12. const targetHorizon = + selectedHorizon ?? fm.future_supply?.horizon_months ?? medianHorizon(report.meta.horizons) ?? 12; @@ -267,7 +282,10 @@ function ForecastReady({ report }: { report: ForecastReport }) { {/* 6.1 Прогноз по горизонтам */} {hasHorizons && ( - + )} diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index 5903e53b..cb44db49 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -8,7 +8,7 @@ * MOCK_POI_SCORE — uses poi-score.json fixture (B6) */ -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { apiFetch, apiFetchWithStatus } from "@/lib/api"; import { MOCK_PARCELS_BBOX, @@ -326,21 +326,26 @@ export interface PoiScoreResponse { // ── Hook: useParcelAnalyzeQuery (B5) ───────────────────────────────────────── -export function useParcelAnalyzeQuery(cad: string) { +export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { return useQuery({ - queryKey: ["parcel-analyze", cad], + queryKey: ["parcel-analyze", cad, horizon], queryFn: async (): Promise => { if (MOCK_ANALYZE) { - // Return fixture data regardless of cad — for dev only + // Return fixture data regardless of cad/horizon — for dev only return fixtureAnalyze as ParcelAnalyzeResponse; } + // Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995). return apiFetch( - `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, + `/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=${horizon}`, { method: "POST" }, ); }, staleTime: 5 * 60_000, // 5 min — analyze is expensive retry: 1, + // Horizon is in the queryKey, so switching it is a fresh cache entry. Keep + // the previous analysis on screen while the new horizon re-fetches instead + // of blanking the whole page to the loading state on every selector change. + placeholderData: keepPreviousData, }); }