feat(site-finder): horizon selector (6/12/18) on analysis screen (#996)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m49s
Deploy / deploy (push) Successful in 1m0s

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.
This commit is contained in:
Light1YT 2026-06-05 15:42:58 +05:00
parent 4ec762a202
commit 740210d9ea
5 changed files with 189 additions and 16 deletions

View file

@ -1,7 +1,10 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; 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 { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities"; import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors"; import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
@ -20,7 +23,23 @@ interface Props {
// ── Page Content (client — needs TanStack Query) ─────────────────────────────── // ── Page Content (client — needs TanStack Query) ───────────────────────────────
export function AnalysisPageContent({ cad }: Props) { export function AnalysisPageContent({ cad }: Props) {
const { data, isLoading, error } = useParcelAnalyzeQuery(cad); const [horizon, setHorizon] = useState<number>(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 ──────────────────────────────────────────────────────────────── // ── Loading ────────────────────────────────────────────────────────────────
@ -98,6 +117,22 @@ export function AnalysisPageContent({ cad }: Props) {
{/* Breadcrumb */} {/* Breadcrumb */}
<Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} /> <Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} />
{/* Controls row — horizon selector drives the analyze + forecast queries */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginTop: 16,
}}
>
<HorizonSelector
value={horizon}
onChange={handleHorizonChange}
disabled={isFetching}
/>
</div>
{/* Page layout: sidebar (240px) + main scroll */} {/* Page layout: sidebar (240px) + main scroll */}
<div <div
style={{ style={{
@ -137,7 +172,7 @@ export function AnalysisPageContent({ cad }: Props) {
<Section5Atmosphere cad={cad} /> <Section5Atmosphere cad={cad} />
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */} {/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
<Section6Forecast cad={cad} /> <Section6Forecast cad={cad} selectedHorizon={horizon} />
</div> </div>
</div> </div>
</main> </main>

View file

@ -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 <button>
* segment (real buttons native keyboard focus; visible focus ring kept).
*/
const HORIZONS = [6, 12, 18] as const;
interface Props {
/** Currently selected horizon (one of 6 / 12 / 18). */
value: number;
/** Called with the newly selected horizon. */
onChange: (horizon: number) => void;
/** Disable the control while a re-analyze is in flight (prevents spamming). */
disabled?: boolean;
}
export function HorizonSelector({ value, onChange, disabled = false }: Props) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span
style={{
fontSize: 12,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
whiteSpace: "nowrap",
}}
>
Горизонт прогноза
</span>
<div
role="radiogroup"
aria-label="Горизонт прогноза"
style={{
display: "inline-flex",
borderRadius: 8,
border: "1px solid var(--border-card)",
background: "var(--bg-card)",
padding: 2,
gap: 2,
opacity: disabled ? 0.6 : 1,
}}
>
{HORIZONS.map((h) => {
const selected = h === value;
return (
<button
key={h}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => onChange(h)}
style={{
appearance: "none",
cursor: disabled ? "not-allowed" : "pointer",
padding: "6px 16px",
borderRadius: 6,
border: "none",
background: selected ? "var(--accent)" : "transparent",
color: selected ? "var(--fg-on-dark)" : "var(--fg-secondary)",
fontSize: 13,
fontWeight: selected ? 600 : 500,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
transition: "background 0.12s, color 0.12s",
}}
>
{h} мес
</button>
);
})}
</div>
</div>
);
}

View file

@ -23,6 +23,8 @@ import {
interface Props { interface Props {
forecasts: DemandSupplyForecast[]; forecasts: DemandSupplyForecast[];
/** Horizon (мес) to emphasise as the current target — highlights its row. */
targetHorizon?: number;
} }
const TH_STYLE: React.CSSProperties = { const TH_STYLE: React.CSSProperties = {
@ -48,7 +50,7 @@ const TD_STYLE: React.CSSProperties = {
const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" }; const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" };
export function ForecastHorizonsBlock({ forecasts }: Props) { export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
if (forecasts.length === 0) { if (forecasts.length === 0) {
return ( return (
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}> <p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
@ -89,9 +91,37 @@ export function ForecastHorizonsBlock({ forecasts }: Props) {
{rows.map((f) => { {rows.map((f) => {
const di = f.deficit_index; const di = f.deficit_index;
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS; const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
const isTarget = f.horizon_months === targetHorizon;
return ( return (
<tr key={f.horizon_months}> <tr
<td style={TD_STYLE}>{f.horizon_months} мес.</td> key={f.horizon_months}
style={
isTarget ? { background: "var(--accent-soft)" } : undefined
}
>
<td
style={
isTarget
? { ...TD_STYLE, fontWeight: 600, color: "var(--accent)" }
: TD_STYLE
}
>
{f.horizon_months} мес.
{isTarget && (
<span
style={{
marginLeft: 8,
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--accent)",
}}
>
Целевой
</span>
)}
</td>
<td style={TD_STYLE}> <td style={TD_STYLE}>
<Badge variant={deficitVariant(di)} size="sm"> <Badge variant={deficitVariant(di)} size="sm">
{di > 0 ? "+" : ""} {di > 0 ? "+" : ""}

View file

@ -31,6 +31,13 @@ import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
interface Props { interface Props {
cad: string; 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 = const ADVISORY_DISCLAIMER =
@ -48,7 +55,7 @@ function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" {
// ── Section6Forecast ───────────────────────────────────────────────────────── // ── Section6Forecast ─────────────────────────────────────────────────────────
export function Section6Forecast({ cad }: Props) { export function Section6Forecast({ cad, selectedHorizon }: Props) {
const { data, isLoading, error } = useParcelForecastQuery(cad); const { data, isLoading, error } = useParcelForecastQuery(cad);
const isPending = const isPending =
@ -136,18 +143,26 @@ export function Section6Forecast({ cad }: Props) {
); );
} }
return <ForecastReady report={data.report} />; return <ForecastReady report={data.report} selectedHorizon={selectedHorizon} />;
} }
// ── ForecastReady (report present) ─────────────────────────────────────────── // ── ForecastReady (report present) ───────────────────────────────────────────
function ForecastReady({ report }: { report: ForecastReport }) { function ForecastReady({
report,
selectedHorizon,
}: {
report: ForecastReport;
selectedHorizon?: number;
}) {
const exec = report.exec_summary; const exec = report.exec_summary;
const fm = report.future_market; const fm = report.future_market;
// Target horizon: prefer future_supply.horizon_months, else median of meta // Target horizon: the user's HorizonSelector choice wins (instant client-side
// horizons, else 12. // highlight), else future_supply.horizon_months, else median of meta horizons,
// else 12.
const targetHorizon = const targetHorizon =
selectedHorizon ??
fm.future_supply?.horizon_months ?? fm.future_supply?.horizon_months ??
medianHorizon(report.meta.horizons) ?? medianHorizon(report.meta.horizons) ??
12; 12;
@ -267,7 +282,10 @@ function ForecastReady({ report }: { report: ForecastReport }) {
{/* 6.1 Прогноз по горизонтам */} {/* 6.1 Прогноз по горизонтам */}
{hasHorizons && ( {hasHorizons && (
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам"> <SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
<ForecastHorizonsBlock forecasts={fm.forecasts_by_horizon} /> <ForecastHorizonsBlock
forecasts={fm.forecasts_by_horizon}
targetHorizon={targetHorizon}
/>
</SubBlock> </SubBlock>
)} )}

View file

@ -8,7 +8,7 @@
* MOCK_POI_SCORE uses poi-score.json fixture (B6) * 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 { apiFetch, apiFetchWithStatus } from "@/lib/api";
import { import {
MOCK_PARCELS_BBOX, MOCK_PARCELS_BBOX,
@ -326,21 +326,26 @@ export interface PoiScoreResponse {
// ── Hook: useParcelAnalyzeQuery (B5) ───────────────────────────────────────── // ── Hook: useParcelAnalyzeQuery (B5) ─────────────────────────────────────────
export function useParcelAnalyzeQuery(cad: string) { export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
return useQuery({ return useQuery({
queryKey: ["parcel-analyze", cad], queryKey: ["parcel-analyze", cad, horizon],
queryFn: async (): Promise<ParcelAnalyzeResponse> => { queryFn: async (): Promise<ParcelAnalyzeResponse> => {
if (MOCK_ANALYZE) { 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; return fixtureAnalyze as ParcelAnalyzeResponse;
} }
// Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995).
return apiFetch<ParcelAnalyzeResponse>( return apiFetch<ParcelAnalyzeResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, `/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=${horizon}`,
{ method: "POST" }, { method: "POST" },
); );
}, },
staleTime: 5 * 60_000, // 5 min — analyze is expensive staleTime: 5 * 60_000, // 5 min — analyze is expensive
retry: 1, 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,
}); });
} }