All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Successful in 46s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 46s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m30s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m14s
Deploy / build-frontend (push) Successful in 2m55s
Deploy / build-worker (push) Successful in 4m6s
Deploy / deploy (push) Successful in 1m40s
API отвергал ?horizon=24 (422), хотя ТЗ §12.1 называет 6/12/18/24, а движок
УЖЕ считает 24 на каждом ране: _DEFAULT_HORIZONS=(6,12,18,24) во всех 6 точках
стека (orchestrator/forecast-task/demand_supply_forecast/scenarios/
special_indices/report_assembler), PIPELINE_HORIZON_MONTHS=24.
_hidden_release_fraction клампит h/18→1.0 на 24 (без переполнения),
future_supply._horizon_weight расширяет окно чисто — скрытых ≤18 потолков нет.
Чистое расширение валидатора-enum, не новая математика.
Backend: _ALLOWED_FORECAST_HORIZONS → {6,12,18,24}, Query/docstring/error-msg.
Frontend: HorizonSelector HORIZONS=[6,12,18,24] (тип horizon=number, union не нужен;
прочие потребители data-driven через meta.horizons/forecasts_by_horizon).
Тесты: API принимает 24/отвергает 30; движок-тесты доказывают h=24 осмыслен
(поля посчитаны, demand(24)>demand(18), hidden созрел, индексы в диапазонах).
Closes #944 (Q1 горизонт 24)
85 lines
2.6 KiB
TypeScript
85 lines
2.6 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* HorizonSelector — segmented control «Горизонт прогноза» (958-B1 / #996).
|
||
*
|
||
* Controlled segmented control over the forecast horizons accepted by
|
||
* POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18 / 24; 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, 24] as const;
|
||
|
||
interface Props {
|
||
/** Currently selected horizon (one of 6 / 12 / 18 / 24). */
|
||
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>
|
||
);
|
||
}
|