feat(forecast): forecast compute-date/horizons/schema meta footer (#958) #1128

Merged
bot-backend merged 1 commit from feat/forecast-meta-footer into main 2026-06-07 11:09:05 +00:00
2 changed files with 170 additions and 8 deletions

View file

@ -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(
<span key="generated" style={{ color: "var(--fg-secondary)" }}>
Прогноз рассчитан: {computedAt}
</span>,
);
}
if (horizons) {
segments.push(
<span key="horizons" style={{ color: "var(--fg-secondary)" }}>
горизонты {horizons}
</span>,
);
}
if (schemaVersion) {
segments.push(
<span key="schema" style={{ color: "var(--fg-tertiary)" }}>
схема {schemaVersion}
</span>,
);
}
if (segments.length === 0) return null;
return (
<p
style={{
margin: 0,
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
rowGap: 4,
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-secondary)",
}}
>
{segments.map((segment, i) => (
<span
key={i}
style={{ display: "inline-flex", alignItems: "baseline" }}
>
{i > 0 && (
<span aria-hidden style={SEP_STYLE}>
·
</span>
)}
{segment}
</span>
))}
</p>
);
}

View file

@ -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) {
<ForecastReady
cad={cad}
report={data.report}
runCreatedAt={data.created_at}
selectedHorizon={selectedHorizon}
/>
);
@ -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({
</SubBlock>
)}
{/* Advisory disclaimer */}
<p
{/* Freshness / traceability: когда рассчитан + горизонты + версия схемы */}
<div
style={{
margin: 0,
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
display: "flex",
flexDirection: "column",
gap: 6,
paddingTop: 12,
borderTop: "1px solid var(--border-soft)",
}}
>
{ADVISORY_DISCLAIMER}
</p>
<ForecastMetaLine meta={report.meta} runCreatedAt={runCreatedAt} />
{/* Advisory disclaimer (всегда последним) */}
<p
style={{
margin: 0,
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
}}
>
{ADVISORY_DISCLAIMER}
</p>
</div>
</div>
</section>
);