gendesign/frontend/src/components/site-finder/analysis/ForecastMetaLine.tsx
Light1YT 7756ed4631
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 6s
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 2m58s
Deploy / deploy (push) Successful in 1m2s
feat(forecast): show compute date / horizons / schema in Section 6 footer
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.
2026-06-07 16:08:31 +05:00

146 lines
5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}