feat(forecast): forecast compute-date/horizons/schema meta footer (#958) #1128
2 changed files with 170 additions and 8 deletions
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||||||
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||||||
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
||||||
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
||||||
|
import { ForecastMetaLine } from "./ForecastMetaLine";
|
||||||
import { ForecastExportButtons } from "./ForecastExportButtons";
|
import { ForecastExportButtons } from "./ForecastExportButtons";
|
||||||
import {
|
import {
|
||||||
CONFIDENCE_RU,
|
CONFIDENCE_RU,
|
||||||
|
|
@ -161,6 +162,7 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
||||||
<ForecastReady
|
<ForecastReady
|
||||||
cad={cad}
|
cad={cad}
|
||||||
report={data.report}
|
report={data.report}
|
||||||
|
runCreatedAt={data.created_at}
|
||||||
selectedHorizon={selectedHorizon}
|
selectedHorizon={selectedHorizon}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -171,10 +173,13 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
||||||
function ForecastReady({
|
function ForecastReady({
|
||||||
cad,
|
cad,
|
||||||
report,
|
report,
|
||||||
|
runCreatedAt,
|
||||||
selectedHorizon,
|
selectedHorizon,
|
||||||
}: {
|
}: {
|
||||||
cad: string;
|
cad: string;
|
||||||
report: ForecastReport;
|
report: ForecastReport;
|
||||||
|
/** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */
|
||||||
|
runCreatedAt?: string | null;
|
||||||
selectedHorizon?: number;
|
selectedHorizon?: number;
|
||||||
}) {
|
}) {
|
||||||
const exec = report.exec_summary;
|
const exec = report.exec_summary;
|
||||||
|
|
@ -394,19 +399,30 @@ function ForecastReady({
|
||||||
</SubBlock>
|
</SubBlock>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Advisory disclaimer */}
|
{/* Freshness / traceability: когда рассчитан + горизонты + версия схемы */}
|
||||||
<p
|
<div
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
display: "flex",
|
||||||
fontSize: 12,
|
flexDirection: "column",
|
||||||
lineHeight: 1.5,
|
gap: 6,
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
paddingTop: 12,
|
paddingTop: 12,
|
||||||
borderTop: "1px solid var(--border-soft)",
|
borderTop: "1px solid var(--border-soft)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ADVISORY_DISCLAIMER}
|
<ForecastMetaLine meta={report.meta} runCreatedAt={runCreatedAt} />
|
||||||
</p>
|
|
||||||
|
{/* Advisory disclaimer (всегда последним) */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ADVISORY_DISCLAIMER}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue