From 7756ed463115392f9dddc573421494c2bff4a724 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 7 Jun 2026 16:08:31 +0500 Subject: [PATCH] feat(forecast): show compute date / horizons / schema in Section 6 footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface report.meta provenance as a compact muted caption above the advisory disclaimer: «Прогноз рассчитан: <дата> · горизонты N / M мес · схема X». The freshness date sources from the run's persist time (envelope created_at, always set) with meta.generated_at as fallback — the deterministic assembler leaves generated_at null, but created_at is the real compute timestamp. Until now this provenance lived only in exports; horizons appeared only via the chart axis. New ForecastMetaLine.tsx: three independently-guarded segments (RU long date with optional non-midnight time, horizons, most-muted schema); graceful null when meta empty / 202-pending. Advisory disclaimer stays last. Part of #958. --- .../site-finder/analysis/ForecastMetaLine.tsx | 146 ++++++++++++++++++ .../site-finder/analysis/Section6Forecast.tsx | 32 +++- 2 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/site-finder/analysis/ForecastMetaLine.tsx diff --git a/frontend/src/components/site-finder/analysis/ForecastMetaLine.tsx b/frontend/src/components/site-finder/analysis/ForecastMetaLine.tsx new file mode 100644 index 00000000..de79ce5b --- /dev/null +++ b/frontend/src/components/site-finder/analysis/ForecastMetaLine.tsx @@ -0,0 +1,146 @@ +"use client"; + +/** + * ForecastMetaLine — compact freshness / traceability footer for Section 6 (§22). + * + * Surfaces the report `meta` provenance that, until now, lived only in the + * exports («Сформировано» row): WHEN the forecast was computed, over WHICH + * horizons, and the schema version. For a forecasting tool «прогноз рассчитан + * <дата>» is a trust/staleness signal — without it the horizons appear only + * implicitly via the chart axis and the compute date is invisible. + * + * Three independently-guarded segments joined by a middot, caption typography, + * muted tokens (schema is the MOST muted — traceability, not headline): + * + * Прогноз рассчитан: 7 июня 2026 · горизонты 6 / 12 / 18 / 24 мес · схема 1.0 + * + * Date source: the run's persist time (`runCreatedAt` — the forecast envelope's + * `created_at`, always set for a persisted run) is preferred; `meta.generated_at` + * is the fallback. The PURE assembler / orchestrator deliberately do NOT stamp + * `generated_at` (determinism), so it is frequently null in real runs — but the + * persisted run always carries `created_at`, which is the real «когда рассчитан». + * + * GRACEFUL: renders null when every segment is empty (e.g. 202-pending or a thin + * report). When neither date source resolves, only that segment is skipped while + * horizons + schema still render. An invalid/unparseable date is treated as + * missing. A date-only ISO string («2026-06-03») renders date-only; a datetime + * with a meaningful (non-midnight) time appends «, ЧЧ:ММ» — a real run timestamp + * carries a genuine time, so it shows «7 июня 2026, 14:20» (precise compute time). + */ + +import type { ReportMeta } from "@/types/forecast"; + +/** «6 / 12 / 18 / 24 мес» — horizons joined; "" when the list is empty. */ +function formatHorizons(horizons: number[]): string { + if (horizons.length === 0) return ""; + return `${horizons.join(" / ")} мес`; +} + +/** + * «7 июня 2026» (date-only) or «7 июня 2026, 10:30» (meaningful time) — RU long + * date from an ISO 8601 string. Returns "" for null/empty/unparseable input. + * + * A bare date («2026-06-03») parses to local midnight, so a 00:00 time is treated + * as «no time» to avoid a misleading «, 00:00». A datetime carrying a real time + * (and an explicit time component in the source string) appends «, ЧЧ:ММ». + */ +function formatComputedAt(computedAt: string | null): string { + if (computedAt == null || computedAt.trim() === "") return ""; + const ts = Date.parse(computedAt); + if (Number.isNaN(ts)) return ""; + + const date = new Date(ts); + const datePart = date.toLocaleDateString("ru", { + day: "numeric", + month: "long", + year: "numeric", + }); + + const hasTimeComponent = computedAt.includes("T"); + const isMidnight = date.getHours() === 0 && date.getMinutes() === 0; + if (!hasTimeComponent || isMidnight) return datePart; + + const timePart = date.toLocaleTimeString("ru", { + hour: "2-digit", + minute: "2-digit", + }); + return `${datePart}, ${timePart}`; +} + +const SEP_STYLE: React.CSSProperties = { + color: "var(--fg-tertiary)", + // hair spaces around the middot for RU typographic breathing room. + padding: "0 2px", +}; + +export function ForecastMetaLine({ + meta, + runCreatedAt, +}: { + meta: ReportMeta; + /** + * The forecast envelope's `created_at` (the run's persist time) — the real + * «когда рассчитан» signal, always set for a persisted run. Preferred over the + * frequently-null `meta.generated_at`. + */ + runCreatedAt?: string | null; +}) { + // Run persist time wins; the (often null) deterministic meta stamp is fallback. + const computedAt = formatComputedAt(runCreatedAt ?? meta.generated_at); + const horizons = formatHorizons(meta.horizons); + const schemaVersion = meta.schema_version?.trim() ?? ""; + + const segments: React.ReactNode[] = []; + if (computedAt) { + segments.push( + + Прогноз рассчитан: {computedAt} + , + ); + } + if (horizons) { + segments.push( + + горизонты {horizons} + , + ); + } + if (schemaVersion) { + segments.push( + + схема {schemaVersion} + , + ); + } + + if (segments.length === 0) return null; + + return ( +

+ {segments.map((segment, i) => ( + + {i > 0 && ( + + · + + )} + {segment} + + ))} +

+ ); +} diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index 7f182491..79e2f1fa 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -35,6 +35,7 @@ import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock"; import { ForecastProductTzBlock } from "./ForecastProductTzBlock"; import { ForecastScoringBlock } from "./ForecastScoringBlock"; import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock"; +import { ForecastMetaLine } from "./ForecastMetaLine"; import { ForecastExportButtons } from "./ForecastExportButtons"; import { CONFIDENCE_RU, @@ -161,6 +162,7 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) { ); @@ -171,10 +173,13 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) { function ForecastReady({ cad, report, + runCreatedAt, selectedHorizon, }: { cad: string; report: ForecastReport; + /** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */ + runCreatedAt?: string | null; selectedHorizon?: number; }) { const exec = report.exec_summary; @@ -394,19 +399,30 @@ function ForecastReady({ )} - {/* Advisory disclaimer */} -

- {ADVISORY_DISCLAIMER} -

+ + + {/* Advisory disclaimer (всегда последним) */} +

+ {ADVISORY_DISCLAIMER} +

+ ); -- 2.45.3