gendesign/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
bot-backend d73b9198d5
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
fix(tradein/v2): визуальный аудит — иерархия/честность/микрокопи (#2266)
Живой визуальный аудит /trade-in/v2 нашёл HIGH/MEDIUM/LOW дефекты после
honest-hero раунда — фиксы:

- H1: честный fallback-state флагманской карточки при expected_sold_price=null
  (вместо четырёх прочерков)
- H3: две расходящиеся "медианы объявлений" на одном экране (рейка 14.23млн
  vs герой 8.99млн на тех же аналогах) — унифицированы на e.median_price_rub
- M1-M3: иерархия трёх цен (asking уменьшен, круговой гейдж → пилюля
  "-N% к цене объявления", дисклеймер разброса читаем)
- M4: PDF-отчёт primary после результата (без добавления lead-CTA —
  открытый продуктовый вопрос, не решался)
- M5: порядок меток оси X графика (2026 в начале, не терялась при years!=5) —
  geometry вынесена в общий export (phYearX) вместо дублирования в двух файлах
- M6: gate немонотонного "срок продажи" при n<5 аналогов
- M7: сброс scroll оверлея при смене секции
- M8 + LOW: унификация терминов (СТОИМОСТЬ→ЦЕНА, CV→"разброс цен",
  P25-P75 человекочитаемо, ДКП-расшифровка, скрыт литеральный
  "Обновлено: —", метро-разделители в адресе, убран внутренний
  "ПОВТОРНЫЕ АДРЕСА" label)
- Контраст accent-текста hero (40px headline) поднят с ~2.87:1 до ~4.2:1
  (accent → accentDeep, существующий токен той же палитры)

Мобильная раскладка (H2) вынесена в отдельный issue #2275, не в этом PR.
Lead-CTA "Оставить заявку" — открытый продуктовый вопрос, не решался.

Refs #2266, #2262
2026-07-03 23:31:50 +03:00

1981 lines
73 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port.
//
// These functions take the real trade-in API objects (AggregatedEstimate,
// StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture
// shapes the v2 components already consume (see ./types + ./fixtures). The Wire
// phase swaps each component's hardcoded fixture for a `data` prop fed by one of
// these mappers; the fixtures stay as the prop DEFAULT so unwired usage still
// renders. ALL money/format/geometry lives here — never copy fixture literals.
//
// Reconciliations / known FE-approximations (look for "TODO BE-N"):
// BE-1 #2043 shipped 2026-07-02: AggregatedEstimate now carries real
// cv/source_counts/created_at. mapSources' marketAds.kpi.cv (#2041)
// prefers estimate.cv and only falls back to the FE coefficient of
// variation (analog ₽/м²) for pre-#2043 cached estimates where cv is
// absent. mapReport/mapResultPanel/mapSummary/mapCache still use the
// pre-BE-1 approximations (created_at shift, FE source groupBy) — out
// of scope for #2040/#2041, left as-is.
// BE-2 target_address is a single string; street/city are split heuristically
// (parseAddress). Backend should return structured address components.
// BE-3 location coefficient / street-view caption / compass bearing are not in
// the API → surfaced as placeholders.
//
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
// repair : 'Без отделки' → enum 'needs_repair' (no-finish ≈ needs repair)
// See HOUSE_TYPE_RU / HOUSE_TYPE_FROM_RU / REPAIR_RU / REPAIR_FROM_RU below.
import type {
AggregatedEstimate,
AnalogLot,
ConfidenceLevel,
EstimateHistoryItem,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
HouseType,
PlacementHistoryItem,
RepairState,
SalesVsListingsResponse,
SellTimeSensitivityResponse,
StreetDealsResponse,
} from "@/types/trade-in";
import type {
AdRow,
Analytics,
AnalyticsKpi,
AxisTickX,
AxisTickY,
CacheKpi,
CacheRow,
DealRow,
DkpRow,
History,
HouseSaleRow,
MarketAds,
MarketDeals,
ObjectInfo,
PriceHistory,
RangeBar,
RangeMarker,
Ranges,
Report,
ResultCard,
ResultMeta,
ScatterDetail,
ScatterMini,
ScatterPoint,
SellTimeTier,
SourceCard,
Summary,
SummaryRow,
} from "./types";
import { tokens } from "./tokens";
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";
// Total number of source slots — meta "N / M". Single source of truth is
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
// ── Composite presentation shapes consumed by the wired components ──────────
// One `data` prop per component; each field keeps its exact ./types shape.
export interface ResultPanelData {
cards: ResultCard[];
meta: ResultMeta;
ranges: Ranges;
scatterMini: ScatterMini;
sources: SourceCard[];
}
export interface HeroBarData {
report: Report;
object: ObjectInfo;
}
export interface ObjectSummaryData {
object: ObjectInfo;
summary: Summary;
}
// ── Enum <-> RU maps ────────────────────────────────────────────────────────
// enum -> display label (mapObject). 'other' surfaces as "Блочный" so it matches
// a real dropdown option and round-trips via HOUSE_TYPE_FROM_RU.
export const HOUSE_TYPE_RU: Record<HouseType, string> = {
panel: "Панельный",
brick: "Кирпичный",
monolith: "Монолитный",
monolith_brick: "Монолитно-кирпичный",
other: "Блочный",
};
// enum -> display label (mapObject).
export const REPAIR_RU: Record<RepairState, string> = {
needs_repair: "Требует ремонта",
standard: "Удовлетворительный",
good: "Хороший",
excellent: "Отличный",
};
// enum -> RU lower-case (summary quality block; meta uses .toUpperCase()).
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
low: "низкая",
medium: "средняя",
high: "высокая",
};
// RU dropdown label -> enum (Wire-phase form submit). Reconciles the design's
// extra options that have no enum value (see header comment).
export const HOUSE_TYPE_FROM_RU: Record<string, HouseType> = {
Панельный: "panel",
Кирпичный: "brick",
Монолитный: "monolith",
"Монолитно-кирпичный": "monolith_brick",
Блочный: "other",
Другой: "other",
};
export const REPAIR_FROM_RU: Record<string, RepairState> = {
"Без отделки": "needs_repair",
"Требует ремонта": "needs_repair",
Удовлетворительный: "standard",
Хороший: "good",
Отличный: "excellent",
};
// ── Format helpers (ru) ─────────────────────────────────────────────────────
// Non-breaking space (U+00A0) — glues a number to its unit / pads range dashes so
// "6,56 млн ₽" / "1,2 3,4" never wrap mid-token.
const NBSP = " ";
/** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */
export function fmtMln(rub: number | null | undefined): string {
if (rub == null || !Number.isFinite(rub)) return "—";
return (rub / 1e6).toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/** ₽/м² -> "225 801 ₽/м²" (rounded, ru grouping). null/NaN -> "—". */
export function fmtPpm(ppm: number | null | undefined): string {
if (ppm == null || !Number.isFinite(ppm)) return "—";
return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`;
}
/** area -> "54,3 м²" (ru comma, ≤1dp, NBSP before unit). null/NaN -> "—". */
function fmtArea(area: number | null | undefined): string {
if (area == null || !Number.isFinite(area)) return "—";
return `${area.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}${NBSP}м²`;
}
/** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */
export function fmtDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
const RU_MONTHS = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
];
/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */
function fmtMonthYear(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return `${RU_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
/** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at24h). */
function fmtDateShift(iso: string | null | undefined, hours: number): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
d.setHours(d.getHours() + hours);
return d.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
/** Signed percent with proper ru minus. -3 -> "3%", 5 -> "+5%", 0 -> "0%". */
export function fmtPct(v: number | null | undefined, digits = 0): string {
if (v == null || !Number.isFinite(v)) return "—";
const rounded = digits > 0 ? Number(v.toFixed(digits)) : Math.round(v);
const sign = rounded < 0 ? "" : rounded > 0 ? "+" : "";
return `${sign}${Math.abs(rounded)}%`;
}
/** Russian plural picker. forms = [one, few, many]. */
export function pluralRu(n: number, forms: [string, string, string]): string {
const a = Math.abs(n) % 100;
const b = a % 10;
if (a > 10 && a < 20) return forms[2];
if (b > 1 && b < 5) return forms[1];
if (b === 1) return forms[0];
return forms[2];
}
// ── Numeric helpers ─────────────────────────────────────────────────────────
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function round1(v: number): number {
return Math.round(v * 10) / 10;
}
function median(values: number[]): number | null {
const clean = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
if (clean.length === 0) return null;
const mid = Math.floor(clean.length / 2);
return clean.length % 2 ? clean[mid] : (clean[mid - 1] + clean[mid]) / 2;
}
/** Coefficient of variation (%) of positive values; null if <2 samples. */
function coeffVar(values: number[]): number | null {
const clean = values.filter((v) => Number.isFinite(v) && v > 0);
if (clean.length < 2) return null;
const mean = clean.reduce((a, b) => a + b, 0) / clean.length;
if (mean === 0) return null;
const variance =
clean.reduce((a, b) => a + (b - mean) ** 2, 0) / clean.length;
return (Math.sqrt(variance) / mean) * 100;
}
// «Разброс цен» (§M8 / #1993): the внутренний term "CV" (coefficient of
// variation) is not surfaced to users anymore, and the витрина rounds it to a
// whole percent ("42%", not "42,2%") — the extra decimal implied a false
// precision for what is a rough spread indicator.
function cvStr(values: number[]): string {
const cv = coeffVar(values);
return cv != null
? `${cv.toLocaleString("ru-RU", {
maximumFractionDigits: 0,
})}%`
: "—";
}
/** Format a 0..1 coefficient-of-variation ratio the same way as cvStr's %. */
function cvRatioStr(cv: number): string {
return `${(cv * 100).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}%`;
}
/**
* Analog ₽/м² spread, used by #2041's marketAds.kpi.cv. Prefers the real
* backend estimate.cv (#2043/BE-1, 0..1 ratio) and only falls back to the FE
* coefficient of variation for pre-#2043 cached estimates where cv is absent
* (older persisted rows don't carry it — see the file-header TODO BE-1 note).
*/
function cvLabel(e: AggregatedEstimate | null): string {
if (e == null) return "—";
if (e.cv != null && Number.isFinite(e.cv)) return cvRatioStr(e.cv);
return cvStr(e.analogs.map((a) => a.price_per_m2));
}
/**
* Bin `values` into 8 buckets across [min,max]; return per-bucket height as
* count/maxCount*100 (the mini-histogram bar heights). Empty input -> [].
*/
export function bins8(values: number[]): number[] {
const clean = values.filter((v) => Number.isFinite(v) && v > 0);
if (clean.length === 0) return [];
const min = Math.min(...clean);
const max = Math.max(...clean);
if (max === min) {
// Degenerate: all equal -> single central bar.
const bars = new Array<number>(8).fill(0);
bars[3] = 100;
return bars;
}
const span = max - min;
const counts = new Array<number>(8).fill(0);
for (const v of clean) {
const idx = clamp(Math.floor(((v - min) / span) * 8), 0, 7);
counts[idx] += 1;
}
const maxCount = Math.max(...counts);
if (maxCount === 0) return [];
return counts.map((c) => Math.round((c / maxCount) * 100));
}
export interface ScatterDomain {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
}
export interface ScatterBox {
left: number;
right: number;
top: number; // smaller pixel y (high value)
bottom: number; // larger pixel y (low value)
}
/**
* Project data points {x,y} into the SVG pixel box. Y is inverted (high value ->
* top). Out-of-domain points are clamped into the box. Coords rounded to 1dp.
*/
export function scatterProject(
pts: ReadonlyArray<{ x: number; y: number }>,
domain: ScatterDomain,
box: ScatterBox,
r: number,
): ScatterPoint[] {
const xSpan = domain.xMax - domain.xMin || 1;
const ySpan = domain.yMax - domain.yMin || 1;
return pts.map((p) => {
const fx = clamp((p.x - domain.xMin) / xSpan, 0, 1);
const fy = clamp((p.y - domain.yMin) / ySpan, 0, 1);
return {
x: round1(box.left + fx * (box.right - box.left)),
y: round1(box.bottom - fy * (box.bottom - box.top)),
r,
};
});
}
// ── Range marker (CSS % offsets) ────────────────────────────────────────────
function pctStr(n: number): string {
return `${round1(n)}%`;
}
/**
* Filled-segment offsets + median dot position for a price range bar.
* Domain is [lo,hi] padded by `padFrac` on each side so the dot/segment have
* breathing room. Degenerate ranges fall back to a centred dot.
*/
function rangeMarker(
lo: number | null | undefined,
med: number | null | undefined,
hi: number | null | undefined,
padFrac = 0.08,
): RangeMarker {
if (
lo == null ||
med == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(med) ||
!Number.isFinite(hi) ||
hi <= lo
) {
return { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" };
}
const span = hi - lo;
const pad = span * padFrac;
const dMin = lo - pad;
const dMax = hi + pad;
const w = dMax - dMin;
return {
fillLeft: pctStr(((lo - dMin) / w) * 100),
fillRight: pctStr(((dMax - hi) / w) * 100),
dotLeft: pctStr(clamp(((med - dMin) / w) * 100, 0, 100)),
};
}
/** "{lo} — {hi} млн ₽" range line for a result card. Missing -> "—". */
function rangeLine(
lo: number | null | undefined,
hi: number | null | undefined,
): string {
if (
lo == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
return "—";
}
return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}${NBSP}млн${NBSP}`;
}
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
// NB: JS \b word boundaries do NOT work around Cyrillic (only ASCII \w), so the
// street keyword is delimited by start/space/dot/end explicitly, not \b.
const STREET_RE =
/(^|\s)(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)(\s|\.|$)/i;
const CITY_PREFIX_RE = /^(г|город|пгт|с|село|д|деревня|рп)\.?\s+/i;
// House number token ("14", "14/3", "14А", "14 к2", "14 стр1"); leading дом/зд
// prefix stripped by houseClean before the test.
const HOUSE_RE = /^\d+[а-яА-Я]?(\/\d+)?(\s?к\.?\s?\d+)?(\s?стр\.?\s?\d+)?$/i;
// Administrative anchor — the city token typically sits right before one of these.
const ADMIN_ANCHOR_RE =
/городск(ой|ая)|муниципальн|\bобласть\b|\bобл\b|\bкрай\b|\bреспублик/i;
// Explicit city token "г Екатеринбург" / "город …" / "пгт …" (capture name).
const CITY_TOKEN_RE = /^(?:г|город|пгт|гп)\.?\s+([А-Яа-яЁё].*)/;
/**
* Best-effort split of a single address string into {street+house, city}.
* Handles BOTH common geocoder orders (TODO BE-2 — structured address):
* OSM/Nominatim: "14/3, улица Яскина, …, Екатеринбург, городской округ …, область, …"
* DaData: "г Екатеринбург, ул Яскина, д 14/3"
*/
function parseAddress(full: string | null): { address: string; city: string } {
if (!full || !full.trim()) return { address: "—", city: "" };
const parts = full
.split(",")
.map((s) => s.trim())
.filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !/^Россия$/i.test(p));
if (parts.length === 0) return { address: full.trim(), city: "" };
// City: prefer an explicit г./город token; else the token right before the
// first administrative anchor (городской округ / область / край).
let city = "";
const tokenMatch = parts.map((p) => p.match(CITY_TOKEN_RE)).find(Boolean);
if (tokenMatch) {
city = tokenMatch[1].trim();
} else {
const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p));
if (anchorIdx > 0)
city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
}
// Address: street token + adjacent house number (house precedes the street in
// OSM order, follows it in DaData order). Fall back to the first part.
const houseClean = (p: string) =>
p.replace(/^(?:д|дом|зд|здание)\.?\s+/i, "").trim();
const streetIdx = parts.findIndex((p) => STREET_RE.test(p));
let address: string;
if (streetIdx >= 0) {
const street = parts[streetIdx];
const house = [parts[streetIdx - 1], parts[streetIdx + 1]]
.filter((p): p is string => p != null)
.map(houseClean)
.find((p) => HOUSE_RE.test(p));
address = house ? `${street}, ${house}` : street;
} else {
// No street keyword (prefix-less address, e.g. "Белинского, 86"). Don't
// silently drop the house number: pair a house token with its preceding
// street name; otherwise keep the full comma-joined string rather than
// only parts[0] (which would lose the house).
const houseIdx = parts.findIndex((p) => HOUSE_RE.test(houseClean(p)));
address =
houseIdx > 0
? `${parts[houseIdx - 1]}, ${houseClean(parts[houseIdx])}`
: parts.join(", ");
}
return { address, city };
}
function roomsLabel(rooms: number | null): string {
if (rooms == null) return "—";
if (rooms <= 0) return "Студия";
if (rooms >= 5) return "5+";
return String(rooms);
}
function numLabel(v: number | null): string {
return v == null || !Number.isFinite(v) ? "—" : String(v);
}
// ── Source slots (FE groupBy — TODO BE-1) ───────────────────────────────────
// Each design slot maps to one or more API source keys. count = number of
// analogs + actual_deals whose .source matches; active = key present in
// sources_used (or any lots found). 'АВИТО ОЦЕНКА' also covers the avito_imv
// anchor key (no lots of its own).
const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
{ name: "ЦИАН", keys: ["cian"] },
{ name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] },
{ name: "РОСРЕЕСТР", keys: ["rosreestr"] },
{ name: "АВИТО", keys: ["avito", "avito_imv"] },
{ name: "ДОМКЛИК", keys: ["domklik", "domclick"] },
];
// #2081 round2: N1.RU + RESTATE dropped — мёртвые (всегда «нет данных», не
// учитываем). N1 с #2204 выключен полностью (backend + расписания). Домклик оставлен
// (живой источник, реально даёт лоты). TOTAL_SOURCES = единый счётчик «N / M»,
// чтобы плитки, счётчик и текст не расходились.
const TOTAL_SOURCES = SOURCE_SLOTS.length;
function buildSources(e: AggregatedEstimate): SourceCard[] {
const counts = new Map<string, number>();
for (const lot of [...e.analogs, ...e.actual_deals]) {
const s = (lot.source ?? "").toLowerCase();
if (!s) continue;
counts.set(s, (counts.get(s) ?? 0) + 1);
}
const used = new Set(e.sources_used.map((s) => s.toLowerCase()));
return SOURCE_SLOTS.map((slot) => {
const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0);
const active = slot.keys.some((k) => used.has(k)) || count > 0;
if (!active) {
return {
name: slot.name,
count: "—",
label: "нет данных",
active: false,
};
}
return {
name: slot.name,
count: count > 0 ? String(count) : "—",
label:
count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
active: true,
};
});
}
/**
* Canonical "active sources" count = number of filled source TILES (one per
* design slot), so meta/quality/overlay all agree with what the user sees. NOT
* e.sources_used.length (which can double-count alias keys / anchor-only sources).
*/
function activeSourceCount(e: AggregatedEstimate): number {
return buildSources(e).filter((s) => s.active).length;
}
// ── Deal tier resolution (DKP / Росреестр) ──────────────────────────────────
interface DealTier {
medianRub: number;
loRub: number;
hiRub: number;
medianPpm: number;
count: number;
bars: number[];
}
/**
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
* own actual_deals. null when no deal data at all.
*/
function resolveDealTier(
e: AggregatedEstimate,
sd?: StreetDealsResponse | null,
): DealTier | null {
if (sd && sd.count > 0 && Number.isFinite(sd.median_price_rub)) {
return {
medianRub: sd.median_price_rub,
loRub: sd.range_low_rub,
hiRub: sd.range_high_rub,
medianPpm: sd.median_price_per_m2,
count: sd.count,
bars: bins8(sd.deals.map((d) => d.price_per_m2)),
};
}
const area = e.area_m2;
if (e.dkp_corridor && e.dkp_corridor.count > 0 && area && area > 0) {
const c = e.dkp_corridor;
return {
medianRub: c.median_ppm2 * area,
loRub: c.low_ppm2 * area,
hiRub: c.high_ppm2 * area,
medianPpm: c.median_ppm2,
count: c.count,
bars: bins8(e.actual_deals.map((d) => d.price_per_m2)),
};
}
if (e.actual_deals.length > 0) {
const prices = e.actual_deals.map((d) => d.price_rub);
const ppms = e.actual_deals.map((d) => d.price_per_m2);
const medRub = median(prices);
const medPpm = median(ppms);
if (medRub == null || medPpm == null) return null;
return {
medianRub: medRub,
loRub: Math.min(...prices),
hiRub: Math.max(...prices),
medianPpm: medPpm,
count: e.actual_deals.length,
bars: bins8(ppms),
};
}
return null;
}
// ── Scatter mini (ЦЕНА × СРОК ПРОДАЖИ) ──────────────────────────────────────
// Pixel box + x-tick positions taken from the design viewBox (168×140). X domain
// is a fixed 0180 days so the x-ticks stay valid; Y domain is data-driven and
// the y-tick LABELS are recomputed for the chosen pixel positions.
const SCATTER_BOX: ScatterBox = { left: 34, right: 158, top: 13, bottom: 112 };
const SCATTER_X_DOMAIN = { min: 0, max: 180 };
const SCATTER_X_TICKS: AxisTickX[] = [
{ label: "0", x: 34 },
{ label: "90", x: 96 },
{ label: "180", x: 158 },
];
const SCATTER_Y_TICK_PX = [13, 77, 112]; // top, mid, bottom
function mlnTick(rub: number): string {
return `${Math.round(rub / 1e6)}М`;
}
function buildScatterMini(
e: AggregatedEstimate,
sd?: StreetDealsResponse | null,
): ScatterMini {
// Exposure (x-axis). days_on_market is sparsely populated in prod, so for
// ACTIVE analogs fall back to "days listed so far" = today listing_date
// (a valid lower bound on time-to-sell). Deals keep requiring a real
// days_on_market — todaydeal-date is not exposure. TODO BE-1: populate days_on_market.
const daysListed = (l: AnalogLot): number | null => {
if (l.listing_date == null) return null;
const t = new Date(l.listing_date).getTime();
if (Number.isNaN(t)) return null;
const days = Math.round((Date.now() - t) / 86_400_000);
return days >= 0 && days < 3650 ? days : null;
};
const realDom = (l: AnalogLot): number | null =>
l.days_on_market != null && Number.isFinite(l.days_on_market)
? l.days_on_market
: null;
const toPts = (lots: AnalogLot[], dom: (l: AnalogLot) => number | null) =>
lots
.map((l) => ({ x: dom(l), y: l.price_rub }))
.filter(
(p): p is { x: number; y: number } =>
p.x != null && Number.isFinite(p.y),
);
const dealsRaw = toPts(
sd && sd.deals.length > 0 ? sd.deals : e.actual_deals,
realDom,
);
const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l));
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
const subjectDays = e.est_days_on_market;
const allY = [...dealsRaw, ...analogsRaw]
.map((p) => p.y)
.filter((y) => Number.isFinite(y));
if (Number.isFinite(subjectPrice)) allY.push(subjectPrice);
const fallbackTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((y) => ({
label: "—",
y,
}));
if (allY.length === 0) {
return {
deals: [],
analogs: [],
subject: { x: 96, y: 62, r: 3.6 },
yTicks: fallbackTicks,
xTicks: SCATTER_X_TICKS,
};
}
let yMin = Math.min(...allY);
let yMax = Math.max(...allY);
if (yMin === yMax) {
yMin *= 0.95;
yMax *= 1.05;
}
const yPad = (yMax - yMin) * 0.08;
const domain: ScatterDomain = {
xMin: SCATTER_X_DOMAIN.min,
xMax: SCATTER_X_DOMAIN.max,
yMin: yMin - yPad,
yMax: yMax + yPad,
};
const deals = scatterProject(dealsRaw, domain, SCATTER_BOX, 2.4);
const analogs = scatterProject(analogsRaw, domain, SCATTER_BOX, 3);
// Subject: real est_days if known, else mid-window (90 days).
const subject = Number.isFinite(subjectPrice)
? scatterProject(
[{ x: subjectDays ?? 90, y: subjectPrice }],
domain,
SCATTER_BOX,
3.6,
)[0]
: { x: 96, y: 62, r: 3.6 };
const yTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((py) => {
const frac =
(SCATTER_BOX.bottom - py) / (SCATTER_BOX.bottom - SCATTER_BOX.top);
const price = domain.yMin + frac * (domain.yMax - domain.yMin);
return { label: mlnTick(price), y: py };
});
return { deals, analogs, subject, yTicks, xTicks: SCATTER_X_TICKS };
}
// ── Public mappers ──────────────────────────────────────────────────────────
/** Report header (HeroBar / Footer). id = first 8 of UUID; date = expires24h. */
export function mapReport(e: AggregatedEstimate): Report {
return {
id: e.estimate_id.slice(0, 8),
date: fmtDateShift(e.expires_at, -24), // TODO BE-1: real created_at
validUntil: fmtDate(e.expires_at),
};
}
/** Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). */
export function mapObject(e: AggregatedEstimate): ObjectInfo {
const { address, city } = parseAddress(e.target_address);
return {
address,
city,
area: numLabel(e.area_m2),
rooms: roomsLabel(e.rooms),
floor: numLabel(e.floor),
totalFloors: numLabel(e.total_floors),
year: numLabel(e.year_built),
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
balcony: e.has_balcony ?? false,
locationCoef: "—", // TODO BE-3
streetView: "", // TODO BE-3
compass: "", // TODO BE-3
};
}
// ── ParamsPanel map markers (analog price pins) ─────────────────────────────
// Finding #2: the 01 map showed STATIC fixture price pins (12,7 / 7,6 / 22,4 млн)
// that never reflected the real estimate. We project each analog with a known
// {lat,lon} relative to the subject (target_lat/lon) into the map's 0100% box.
// There is no real basemap/zoom behind the SVG, so a fixed geographic half-span
// (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to
// the 496% margin. Finding #12: the closest pins overlapped on the small map, so
// a greedy non-overlap pass keeps at most KEEP_CAP pins that are ≥ MIN_PIN_DIST
// apart (% units). null / empty / un-geocoded estimate → [] (the map shows just
// the subject pin, never the fake fixture prices).
/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
export interface MapMarker {
left: string; // e.g. "62%"
top: string; // e.g. "31%"
label: string; // "12,70 млн ₽"
sub: string; // "54 м²"
}
// Half-span from the subject to a box edge (the 50% multiplier). ≈ 0.011° lat /
// 0.018° lon ≈ ~1.2 km at ЕКБ latitude (cos 56.8° ≈ 0.55). Tuneable: larger pulls
// analogs toward the centre, smaller pushes them to the clamped margins.
const MAP_LAT_HALF_SPAN = 0.011;
const MAP_LON_HALF_SPAN = 0.018;
const MAP_CLAMP_LO = 4;
const MAP_CLAMP_HI = 96;
// Greedy de-clutter (Finding #12): keep at most KEEP_CAP pins, each ≥ MIN_PIN_DIST
// from every already-kept pin (Euclidean in the 0100% map box) so close analogs
// don't stack into an unreadable blob on the small map.
const MIN_PIN_DIST = 22;
const KEEP_CAP = 3;
// M10: the subject projects to the map centre (lat==tLat, lon==tLon → 50%,50%),
// where ParamsPanel draws the subject pin. Reserve that spot so no analog price
// pin stacks under the subject's address caption (which made the subject look
// labelled with an analog's area/price).
const SUBJECT_POS = { left: 50, top: 50 };
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
if (e == null || e.target_lat == null || e.target_lon == null) return [];
const tLat = e.target_lat;
const tLon = e.target_lon;
const geo = e.analogs.filter(
(a): a is AnalogLot & { lat: number; lon: number } =>
a.lat != null &&
a.lon != null &&
Number.isFinite(a.lat) &&
Number.isFinite(a.lon),
);
if (geo.length === 0) return [];
// Closest first: real distance_m when known, else a degree-based estimate
// (lon scaled by cos≈0.55) so analogs without distance_m still order sensibly.
const ranked = geo
.map((a) => {
const fallbackM =
Math.hypot(a.lat - tLat, (a.lon - tLon) * 0.55) * 111_320;
const dist =
a.distance_m != null && Number.isFinite(a.distance_m)
? a.distance_m
: fallbackM;
return { a, dist };
})
.sort((x, y) => x.dist - y.dist);
// Greedy non-overlap pass over the closest-first list: project each candidate
// to numeric left/top %, keep it only when it sits ≥ MIN_PIN_DIST from every
// pin already kept. Stop at KEEP_CAP. Positions stay numeric until the final
// map, where they become the MapMarker "NN%" strings.
const kept: { left: number; top: number; a: AnalogLot }[] = [];
for (const { a } of ranked) {
if (kept.length >= KEEP_CAP) break;
const left = clamp(
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
MAP_CLAMP_LO,
MAP_CLAMP_HI,
);
const top = clamp(
50 - ((a.lat - tLat) / MAP_LAT_HALF_SPAN) * 50,
MAP_CLAMP_LO,
MAP_CLAMP_HI,
);
// Drop analogs that land on the subject pin (centre) — otherwise the closest
// analog's "21,2 млн / 80 м²" reads as the subject's caption (M10).
const onSubject =
Math.hypot(SUBJECT_POS.left - left, SUBJECT_POS.top - top) < MIN_PIN_DIST;
if (onSubject) continue;
const overlaps = kept.some(
(k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
);
if (overlaps) continue;
kept.push({ left, top, a });
}
return kept.map(({ left, top, a }) => ({
left: `${round1(left)}%`,
top: `${round1(top)}%`,
label: `${fmtMln(a.price_rub)}${NBSP}млн${NBSP}`,
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)}${NBSP}м²` : "—",
}));
}
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
export function mapResultPanel(
e: AggregatedEstimate,
streetDeals?: StreetDealsResponse | null,
): ResultPanelData {
const dealTier = resolveDealTier(e, streetDeals);
// H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of
// bare «—» when the model has no expected-sold prediction. When it is missing
// we emit an honest empty-state (emptyNote) and drop the value/range/ppm/delta;
// the view then moves the accent frame onto the «рекомендованная цена» card.
const hasExpected =
e.expected_sold_price_rub != null &&
Number.isFinite(e.expected_sold_price_rub);
const cards: ResultCard[] = [
{
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
value: fmtMln(e.median_price_rub),
unit: "млн ₽",
range: rangeLine(e.range_low_rub, e.range_high_rub),
ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
bars: bins8(e.analogs.map((a) => a.price_per_m2)),
nav: 2,
},
{
title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
value: hasExpected ? fmtMln(e.expected_sold_price_rub) : "—",
unit: "млн ₽",
range: hasExpected
? rangeLine(
e.expected_sold_range_low_rub,
e.expected_sold_range_high_rub,
)
: "",
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "",
delta:
hasExpected && e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
: undefined,
// §1.3 / M2: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка
// сервиса. Полная формулировка «к цене объявления» (единая с §M8) — вместо
// обрубка «К ОБЪЯВЛ.»; рендерится спокойной пилюлей, не круговым гейджем.
deltaLabel: "к цене объявления",
// H1 empty-state message (only when there is no expected-sold prediction).
emptyNote: hasExpected
? undefined
: "Недостаточно данных для прогноза цены сделки — ориентируйтесь на рекомендованную цену в объявлении",
note:
hasExpected &&
dealTier != null &&
e.expected_sold_price_rub != null &&
Number.isFinite(e.expected_sold_price_rub) &&
e.expected_sold_price_rub < dealTier.medianRub
? "Обычно ниже медианы ДКП — другая выборка и площади"
: undefined,
nav: 2,
},
{
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
value: dealTier ? fmtMln(dealTier.medianRub) : "—",
unit: "млн ₽",
range: dealTier
? rangeLine(dealTier.loRub, dealTier.hiRub)
: "нет данных",
ppm: dealTier
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
dealTier.count,
["сделка", "сделки", "сделок"],
)}`
: "—",
bars: dealTier ? dealTier.bars : [],
nav: 1,
},
];
// Data-driven footer provenance (replaces the view's hardcoded "ПО 6 АНАЛОГАМ
// И 10 СДЕЛКАМ"). No "ЗА N МЕСЯЦЕВ" — we have no honest period to show.
const builtOnDeals = dealTier?.count ?? 0;
const builtOn = `ПОСТРОЕНО ПО ${e.n_analogs} ${pluralRu(e.n_analogs, [
"АНАЛОГУ",
"АНАЛОГАМ",
"АНАЛОГАМ",
])} И ${builtOnDeals} ${pluralRu(builtOnDeals, [
"СДЕЛКЕ",
"СДЕЛКАМ",
"СДЕЛКАМ",
])}`;
const meta: ResultMeta = {
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
builtOn,
};
const adsBar: RangeBar = {
label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
median: `${fmtMln(e.median_price_rub)} млн ₽`,
lo: `${fmtMln(e.range_low_rub)} млн`,
hi: `${fmtMln(e.range_high_rub)} млн`,
marker: rangeMarker(e.range_low_rub, e.median_price_rub, e.range_high_rub),
};
const dealsBar: RangeBar = {
label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"],
median: dealTier ? `${fmtMln(dealTier.medianRub)} млн ₽` : "—",
lo: dealTier ? `${fmtMln(dealTier.loRub)} млн` : "—",
hi: dealTier ? `${fmtMln(dealTier.hiRub)} млн` : "—",
marker: dealTier
? rangeMarker(dealTier.loRub, dealTier.medianRub, dealTier.hiRub)
: { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" },
};
return {
cards,
meta,
ranges: { ads: adsBar, deals: dealsBar },
scatterMini: buildScatterMini(e, streetDeals),
sources: buildSources(e),
};
}
/** 03 СВОДКА ОБЪЕКТА: per-section totals + data-quality block. */
export function mapSummary(
e: AggregatedEstimate,
analytics?: HouseAnalyticsResponse | null,
streetDeals?: StreetDealsResponse | null,
): Summary {
const kpi = analytics?.kpi;
const houseSold = kpi?.sold_count ?? null;
const houseExp = kpi?.median_exposure_days ?? null;
const row1Value =
houseSold != null
? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}`
: "—";
// H3 — «медиана объявлений» in the СВОДКА rail MUST match the hero/02-РЕЗУЛЬТАТ
// headline (e.median_price_rub, the subject-area-normalised median). It used to
// show median(analogs.price_rub) — the RAW median of the analog LISTING totals
// across mixed areas — which for a small subject reads far higher (e.g. 14,23
// млн vs the hero's 8,99 млн over the same 37 analogs) and looked like two
// contradictory medians on one screen. Now both surfaces read the same field.
const row2Value =
e.n_analogs > 0
? `${e.n_analogs}${
Number.isFinite(e.median_price_rub)
? ` · ${fmtMln(e.median_price_rub)} млн`
: ""
}`
: "—";
const dealCount = streetDeals?.count ?? e.actual_deals.length;
const dealMedian =
streetDeals?.median_price_rub ??
median(e.actual_deals.map((d) => d.price_rub));
const row3Value =
dealCount > 0
? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
: "—";
const anaExp = kpi?.median_exposure_days ?? null;
const anaBargain = kpi?.median_bargain_pct ?? null;
const row4Parts = [
anaExp != null ? `${anaExp} дн` : null,
// Finding #10: negate the bargain so a price reduction reads "3%" (compact
// whole-percent here), not a misleading "+3%". See bargainSigned() rationale.
anaBargain != null ? fmtPct(-anaBargain) : null,
].filter((p): p is string => p != null);
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
const rows: SummaryRow[] = [
{
// Finding #9: fed by house-analytics radius data (район, radius_m up to
// 300 m), NOT strictly the exact house → honest label "Продажи рядом".
label: "Продажи рядом",
value: row1Value,
dot: houseSold && houseSold > 0 ? "accent" : "muted",
nav: 1,
},
{
label: "Аналоги",
value: row2Value,
dot: e.n_analogs > 0 ? "accent" : "muted",
nav: 2,
},
// 'Сделки' is the design's secondary (muted) row regardless of count.
{ label: "Сделки", value: row3Value, dot: "muted", nav: 2 },
{
label: "Аналитика",
value: row4Value,
dot: row4Parts.length > 0 ? "accent" : "muted",
nav: 3,
},
];
return {
rows,
quality: {
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence],
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
},
};
}
// ════════════════════════════════════════════════════════════════════════════
// OVERLAY MAPPERS (04 ПРОДАЖИ В ДОМЕ · 05 РЫНОК · 06 АНАЛИТИКА)
//
// Three pure mappers feeding the SectionOverlay views. Each returns a composite
// presentation shape whose fields keep the exact ./types contract the views
// already render, so the Wire phase only swaps each view's fixture import for a
// `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed
// here from the real API objects — never copied from fixtures. Graceful nulls
// degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay.
//
// NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/
// fmtMln): the ad/deal/DKP tables carry their unit in the COLUMN HEADER, so the
// row values are unit-less grouped integers (numRu) for ₽/м², full grouped rubles
// for prices, and млн only where the design shows млн (KPI medians, house-sale
// from→to). Using fmtPpm/fmtMln verbatim there would double the unit or collapse
// rubles to млн, breaking the pixel-perfect markup. Percent style also splits:
// market deltas are whole-percent (fmtPct), торг/bargain are 1-decimal (pct1) —
// matching the design.
// ════════════════════════════════════════════════════════════════════════════
// ── Overlay format helpers ──────────────────────────────────────────────────
/** Grouped integer, ru, no unit. 161351 -> "161 351". null/NaN -> "—". */
function numRu(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return Math.round(v).toLocaleString("ru-RU");
}
/** "{lo} {hi}" млн range, ru 2dp, NBSP-padded en-dash (dkpKpi / marketDeals). Missing -> "—". */
function mlnRangeSp(
lo: number | null | undefined,
hi: number | null | undefined,
): string {
if (
lo == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
return "—";
}
return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}`;
}
/** Same style as mlnRangeSp (marketAds P25P75), ru 2dp, NBSP en-dash. Missing -> "—". */
function mlnRangeTight(
lo: number | null | undefined,
hi: number | null | undefined,
): string {
if (
lo == null ||
hi == null ||
!Number.isFinite(lo) ||
!Number.isFinite(hi)
) {
return "—";
}
return `${fmtMln(lo)}${NBSP}${NBSP}${fmtMln(hi)}`;
}
/** Signed percent, always 1dp, ru comma separator + ru minus. -36 -> "36,0%". */
function pct1(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
const r = Number(v.toFixed(1));
const sign = r < 0 ? "" : r > 0 ? "+" : "";
return `${sign}${Math.abs(r).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}%`;
}
/**
* Finding #10 — "СРЕДНИЙ ТОРГ" rendering. The house-analytics KPI
* median_bargain_pct is the median over house_placement_history of
* (start_price last_price) / start_price × 100
* (see backend trade_in.py), so a POSITIVE value means the asking price was
* REDUCED from its initial to its final value (the haggle / discount room).
* Feeding that straight through pct1 renders a misleading "+3.3%" — the "+"
* reads as a price INCREASE. We instead show the SIGNED change from initial →
* final price, so a reduction reads "3.3%", matching the tile sub-label
* "от начальной до итоговой цены" (final price is 3.3% below initial). null → "—".
*/
function bargainSigned(pct: number | null | undefined): string {
if (pct == null || !Number.isFinite(pct)) return "—";
return pct1(-pct);
}
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
function fmtDist(m: number | null | undefined): string {
if (m == null || !Number.isFinite(m)) return "—";
if (m < 1000) return `${Math.round(m)}${NBSP}м`;
return `${(m / 1000).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}${NBSP}км`;
}
// Source key -> brand/RU display label (badges + link source). Delegates to the
// single source-registry roster (#2211) instead of a local hardcoded map — an
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
// via the registry's own fallback rather than inventing a label.
function sourceLabel(s: string | null | undefined): string {
if (!s) return "—";
return registrySourceLabel(s.toLowerCase());
}
/** Rosreestr deal tier -> RU caption next to the badge. */
function tierLabel(t: string | null | undefined): string {
if (!t) return "";
if (t.includes("house")) return "по дому";
if (t.includes("street")) return "по улице";
return "";
}
/** "2-к. квартира, 50 м², 6/16 эт." from placement-history fields. */
function composeLot(
rooms: number | null,
area: number | null,
floor: number | null,
totalFloors: number | null,
): string {
const parts: string[] = [];
if (rooms == null) parts.push("Квартира");
else if (rooms <= 0) parts.push("Студия");
else parts.push(`${rooms}-к. квартира`);
if (area != null && Number.isFinite(area)) parts.push(fmtArea(area));
if (floor != null) {
parts.push(
totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`,
);
}
return parts.join(", ");
}
/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
function adMeta(l: AnalogLot): string {
const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`);
if (l.floor != null) {
parts.push(
l.total_floors != null
? `этаж ${l.floor}/${l.total_floors}`
: `этаж ${l.floor}`,
);
}
return parts.join(" · ");
}
/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
function dealMeta(l: AnalogLot): string {
const parts: string[] = [];
if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}`);
return parts.join(" · ");
}
/**
* Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run two
* tokens together with no separator — either a word glued onto the house number
* ("…, 69/2Геологическая") OR two words glued together ("БольшаковаГеологическая").
* Insert a space wherever a digit OR a lowercase letter is immediately followed
* by the start of a Cyrillic WORD (capital + lowercase). NB: the trailing
* lowercase in [А-ЯЁ][а-яё] is deliberate — it means we do NOT split legitimate
* single-letter house litereras (14А, 5Б, д.5К — capital NOT followed by
* lowercase) and do NOT touch already-separated tokens ("ул.Белинского" — the
* capital is preceded by ".", not a glue char). Pure display cleanup; never
* touches the comma-splitting parseAddress.
*/
function deglueAddr(addr: string): string {
return (
addr
.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")
// §M8 — metro proximity glued straight into the address with no spaces
// ("Геологическая1115 мин.Чкаловская1620"): (a) split a Cyrillic word
// from a following digit ("Геологическая11" → "Геологическая 11"), and
// (b) separate a "мин." run from the next station name with " · "
// ("мин.Чкаловская" → "мин. · Чкаловская"). Display cleanup only.
.replace(/([а-яё])(\d)/g, "$1 $2")
.replace(/(мин\.)\s*([А-ЯЁ])/g, "$1 · $2")
);
}
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
export interface DkpRowData extends DkpRow {
addr?: string;
date?: string;
source?: string | null;
}
/** History composite + the per-street ДКП deal-vs-listing rows. */
export interface HistoryData extends History {
dkpRows: DkpRowData[];
}
/**
* House placement history + per-street ДКП corridor (street-deals) + paired
* deal↔listing rows (sales-vs-listings). Each source is independent: a null one
* leaves its block at "—"/empty without blanking the others.
*/
export function mapHistory(
placement: PlacementHistoryItem[] | null,
streetDeals: StreetDealsResponse | null,
salesVsListings: SalesVsListingsResponse | null,
): HistoryData {
const houseSales: HouseSaleRow[] = (placement ?? []).map((p) => ({
lot: composeLot(p.rooms, p.area_m2, p.floor, p.total_floors),
priceFrom: fmtMln(p.start_price),
priceTo: p.last_price != null ? `${fmtMln(p.last_price)} млн ₽` : "—",
date: fmtDate(p.last_price_date),
exposure: p.exposure_days != null ? `${p.exposure_days} дн.` : "—",
}));
const street = streetDeals?.street ?? null;
const title = street
? `ДКП-сделки на улице ${street}`
: "ДКП-сделки на вашей улице";
const period =
salesVsListings?.period_months != null
? `${salesVsListings.period_months} мес`
: "—";
const noteParts: string[] = [];
if (salesVsListings?.linkage_rate_pct != null) {
noteParts.push(
`${Math.round(salesVsListings.linkage_rate_pct)}% сделок имеют историческую цену в объявлении`,
);
}
if (salesVsListings?.median_discount_pct != null) {
noteParts.push(
`медианный торг ${pct1(salesVsListings.median_discount_pct)}`,
);
}
const note =
(noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
"Данные по улице, не по дому.";
const dkpKpi = {
count: streetDeals?.count != null ? String(streetDeals.count) : "—",
median:
streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2)
? numRu(streetDeals.median_price_per_m2)
: "—",
range:
streetDeals != null
? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub)
: "—",
};
const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => {
const ask =
pr.listing_price_rub != null
? `${numRu(pr.listing_price_rub)}${
pr.days_listing_to_deal != null && pr.days_listing_to_deal >= 0
? ` за ${pr.days_listing_to_deal} дн до`
: ""
}`
: "—";
const deltaColor =
pr.discount_pct == null
? tokens.muted
: pr.discount_pct < 0
? tokens.success
: tokens.danger;
return {
area: fmtArea(pr.deal_area_m2),
price: `${numRu(pr.deal_price_rub)}`,
ppm: numRu(pr.deal_price_per_m2),
ask,
delta: pct1(pr.discount_pct),
deltaColor,
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
date: fmtMonthYear(pr.deal_date),
source: pr.listing_source,
};
});
return { houseSales, dkpHeader: { title, period, note }, dkpKpi, dkpRows };
}
// ── 06 АНАЛИТИКА ДОМА (AnalyticsView) ───────────────────────────────────────
// Three KPI tiles (порядок: экспозиция · торг · доля снятых).
function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
const bargainColor =
k?.median_bargain_pct != null && k.median_bargain_pct < 0
? tokens.success
: undefined;
return [
{
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
value:
k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
unit: "дн.",
sub: "по архивным объявлениям",
},
{
label: "СРЕДНИЙ ТОРГ",
// Finding #10: negate so a price reduction reads "3.3%" not "+3.3%".
value: bargainSigned(k?.median_bargain_pct),
color: bargainColor,
sub: "от начальной до итоговой цены",
},
{
label: "ДОЛЯ СНЯТЫХ",
value: k != null ? `${Math.round(k.sold_rate_pct)}%` : "—",
sub: k != null ? `${k.sold_count} из ${k.total_lots}` : "—",
},
];
}
// price_premium_label -> tier caption + decorative variant.
const SELLTIME_META: Record<
string,
{ tier: string; variant: SellTimeTier["variant"] }
> = {
cheap: { tier: "5% от рынка", variant: "success" },
median: { tier: "По медиане", variant: "accent" },
plus5: { tier: "+5%", variant: "gold" },
plus10: { tier: "+10%", variant: "danger" },
};
// 4-column shell kept (with "—") when sell-time data is absent.
const SELLTIME_FALLBACK: SellTimeTier[] = [
{
tier: "5% от рынка",
days: "—",
range: "—",
count: "—",
variant: "success",
},
{ tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" },
{ tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" },
{ tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" },
];
function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
if (!s || s.buckets.length === 0) return SELLTIME_FALLBACK;
return s.buckets.map((b) => {
const meta =
SELLTIME_META[b.price_premium_label] ??
({
tier:
b.price_premium_pct === 0
? "По медиане"
: fmtPct(b.price_premium_pct),
variant: "accent",
} as { tier: string; variant: SellTimeTier["variant"] });
return {
tier: meta.tier,
days:
b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
range:
b.p25_days != null && b.p75_days != null
? `обычно ${b.p25_days}${b.p75_days} дн.`
: "—",
count: `${b.n_lots} ${pluralRu(b.n_lots, ["аналог", "аналога", "аналогов"])}`,
variant: meta.variant,
};
});
}
// Price-history polyline geometry. viewBox 0 0 900 220; the plot band runs
// y=20 (top) … y=195 (bottom) over 175px, x distributes the years evenly across
// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
// so a narrow series like 120160k fills the plot instead of flat-lining against
// a fixed 0200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
// PH_X_LEFT/PH_X_RIGHT/phYearX are exported — AnalyticsView's year-axis labels
// (M5) must share this EXACT geometry with the polyline vertices below, or a
// label drifts off its own dot. Single source of truth, no copy-pasted consts.
export const PH_X_LEFT = 58;
export const PH_X_RIGHT = 885;
const PH_Y_TOP = 20; // top gridline pixel (high value)
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom)
// Both polylines are drawn from these source keys; the Y zoom is computed over
// the union of their finite medians so the two series share one axis.
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];
export function phYearX(i: number, n: number): number {
if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
}
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
const empty: PriceHistory = {
note: "Нет данных по истории цен",
years: [],
avito: "",
yandex: "",
yTicks: [],
};
if (!a || a.price_history.length === 0) return empty;
const ph = a.price_history;
const yearsNum = Array.from(new Set(ph.map((p) => p.year))).sort(
(x, y) => x - y,
);
if (yearsNum.length === 0) return empty;
const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const));
const n = yearsNum.length;
// Data-driven Y zoom: collect every finite median ₽/м² that BOTH series draw
// so the polylines fill the plot band instead of squashing against 0200k.
const allPpm = ph
.filter(
(p) =>
PH_SERIES_SOURCES.includes(p.source) &&
Number.isFinite(p.median_price_per_m2),
)
.map((p) => p.median_price_per_m2);
if (allPpm.length === 0) return empty;
let yMin = Math.min(...allPpm);
let yMax = Math.max(...allPpm);
if (yMin === yMax) {
yMin = yMin * 0.97;
yMax = yMax * 1.03;
} else {
const pad = (yMax - yMin) * 0.08;
yMin -= pad;
yMax += pad;
}
yMin = Math.max(0, yMin);
const span = yMax - yMin || 1;
const priceY = (ppm: number): number =>
clamp(
PH_Y_BOTTOM - ((ppm - yMin) / span) * PH_Y_SPAN,
PH_Y_TOP,
PH_Y_BOTTOM,
);
const series = (src: string): string =>
ph
.filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
.sort((x, y) => x.year - y.year)
.map((p) => {
const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n));
const y = Math.round(priceY(p.median_price_per_m2));
return `${x},${y}`;
})
.join(" ");
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
const note = `Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (${totalLots} ${pluralRu(
totalLots,
["лот", "лота", "лотов"],
)})`;
const yTicks: AxisTickY[] = PH_GRID_Y.map((gy) => {
const v = yMin + ((PH_Y_BOTTOM - gy) / PH_Y_SPAN) * span;
return { label: `${Math.round(v / 1000)}k`, y: gy };
});
return {
note,
years: yearsNum.map(String),
avito: series("avito_imv"),
yandex: series("yandex_valuation"),
yTicks,
};
}
// Detail "ЦЕНА × СРОК ПРОДАЖИ" scatter (viewBox 0 0 900 300). Pixel box + x-ticks
// come from the design scaffold; y-tick LABELS are recomputed for the data domain
// (same approach as buildScatterMini). Exposure x reuses the listing_date
// "days listed so far" fallback for active analogs (deals require real DOM).
const DETAIL_BOX: ScatterBox = { left: 60, right: 880, top: 24, bottom: 252 };
const DETAIL_X_DOMAIN = { min: 0, max: 180 };
const DETAIL_X_TICKS: AxisTickX[] = [
{ label: "0", x: 60 },
{ label: "45", x: 265 },
{ label: "90", x: 470 },
{ label: "135", x: 675 },
{ label: "180", x: 880 },
];
const DETAIL_Y_TICK_PX = [24, 81, 138, 195, 252];
function detailDomReal(l: AnalogLot): number | null {
return l.days_on_market != null && Number.isFinite(l.days_on_market)
? l.days_on_market
: null;
}
function detailDomListed(l: AnalogLot): number | null {
if (l.listing_date == null) return null;
const t = new Date(l.listing_date).getTime();
if (Number.isNaN(t)) return null;
const days = Math.round((Date.now() - t) / 86_400_000);
return days >= 0 && days < 3650 ? days : null;
}
function detailExposurePts(
lots: AnalogLot[],
dom: (l: AnalogLot) => number | null,
): { x: number; y: number }[] {
return lots
.map((l) => ({ x: dom(l), y: l.price_rub }))
.filter(
(p): p is { x: number; y: number } => p.x != null && Number.isFinite(p.y),
);
}
function buildScatterDetail(
e: AggregatedEstimate | null,
radiusM: number | null,
): ScatterDetail {
const fallbackTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((y) => ({
label: "—",
y,
}));
const emptySubject: ScatterPoint = { x: 470, y: 138, r: 6 };
if (!e) {
return {
note: "—",
deals: [],
analogs: [],
subject: emptySubject,
yTicks: fallbackTicks,
xTicks: DETAIL_X_TICKS,
};
}
const dealsRaw = detailExposurePts(e.actual_deals, detailDomReal);
const analogsRaw = detailExposurePts(
e.analogs,
(l) => detailDomReal(l) ?? detailDomListed(l),
);
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
const subjectDays = e.est_days_on_market;
const nDeals = e.actual_deals.length;
const nAnalogs = e.analogs.length;
const note = `${nDeals} ${pluralRu(nDeals, [
"сделка",
"сделки",
"сделок",
])} и ${nAnalogs} ${pluralRu(nAnalogs, [
"аналог",
"аналога",
"аналогов",
])}${radiusM != null && radiusM > 0 ? ` в радиусе ${radiusM} м` : ""}`;
const allY = [...dealsRaw, ...analogsRaw]
.map((p) => p.y)
.filter((y) => Number.isFinite(y));
if (Number.isFinite(subjectPrice)) allY.push(subjectPrice);
if (allY.length === 0) {
return {
note,
deals: [],
analogs: [],
subject: emptySubject,
yTicks: fallbackTicks,
xTicks: DETAIL_X_TICKS,
};
}
let yMin = Math.min(...allY);
let yMax = Math.max(...allY);
if (yMin === yMax) {
yMin *= 0.95;
yMax *= 1.05;
}
const yPad = (yMax - yMin) * 0.08;
const domain: ScatterDomain = {
xMin: DETAIL_X_DOMAIN.min,
xMax: DETAIL_X_DOMAIN.max,
yMin: yMin - yPad,
yMax: yMax + yPad,
};
const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5);
const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.5);
const subject = Number.isFinite(subjectPrice)
? scatterProject(
[{ x: subjectDays ?? 90, y: subjectPrice }],
domain,
DETAIL_BOX,
6,
)[0]
: emptySubject;
const yTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((py) => {
const frac =
(DETAIL_BOX.bottom - py) / (DETAIL_BOX.bottom - DETAIL_BOX.top);
const price = domain.yMin + frac * (domain.yMax - domain.yMin);
return { label: mlnTick(price), y: py };
});
return { note, deals, analogs, subject, yTicks, xTicks: DETAIL_X_TICKS };
}
/**
* 06 АНАЛИТИКА ДОМА: header + 3 KPI tiles + sell-time tiers + price-history
* polyline + detail price×days scatter. analytics / sellTime degrade per-section;
* the scatter is driven by the estimate's analogs + actual_deals.
*/
export function mapAnalytics(
analytics: HouseAnalyticsResponse | null,
sellTime: SellTimeSensitivityResponse | null,
estimate: AggregatedEstimate | null,
): Analytics {
const total = analytics?.kpi.total_lots ?? null;
const ads =
total != null
? `${total} ${pluralRu(total, ["объявление", "объявления", "объявлений"])}`
: "—";
const radiusM = analytics?.radius_m ?? null;
const radius =
radiusM == null
? "—"
: radiusM > 0
? `в радиусе ${radiusM} м`
: "точно по этому дому";
const sellTgt = sellTime?.target_median_price_per_m2 ?? null;
const sellTimeNote = `Медиана экспозиции по архивным лотам${
sellTgt != null ? ` · опорная цена ${fmtPpm(sellTgt)}` : ""
}`;
return {
header: { ads, radius },
kpis: buildAnalyticsKpis(analytics?.kpi ?? null),
sellTime: buildSellTime(sellTime),
sellTimeNote,
priceHistory: buildPriceHistory(analytics),
scatterDetail: buildScatterDetail(estimate, radiusM),
};
}
// ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ───────────────────────────────
// Real per-row source/distance/url + tier/date carried alongside the existing
// display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid
// as the component DEFAULT; the mapper always populates them.
export interface AdRowData extends AdRow {
/** Source brand label for the badge (was hardcoded "Avito"). */
source?: string;
/** Distance to the subject (was hardcoded "—"). */
dist?: string;
/** Original listing URL (was a hardcoded "Росреестр ↗" link). */
url?: string | null;
}
export interface DealRowData extends DealRow {
/** Source brand label for the badge (was hardcoded "Росреестр"). */
source?: string;
/** Tier caption next to the badge (was hardcoded "по улице"). */
tier?: string;
/** Deal/listing date (was hardcoded "01.01.26"). */
date?: string;
}
export interface SourcesData {
adRows: AdRowData[];
dealRows: DealRowData[];
marketAds: MarketAds;
marketDeals: MarketDeals;
/** Canonical active-source counter for the overlay header, e.g. "4 / 7". */
sourceCount?: string;
}
// Filter chips: distance corridor + rooms + analog area span (design order).
function buildAdFilters(e: AggregatedEstimate | null): string[] {
if (!e) return [];
const filters: string[] = [];
const dists = e.analogs
.map((a) => a.distance_m)
.filter((d): d is number => d != null && Number.isFinite(d));
if (dists.length > 0) {
const maxKm = Math.max(...dists) / 1000;
const km =
maxKm < 1
? maxKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })
: String(Math.ceil(maxKm));
filters.push(`Расстояние ≤ ${km}${NBSP}км`);
}
// Planning chip — derived from the rooms ACTUALLY present in the rendered
// analog set (same source as the distance/area chips), NOT from the subject's
// e.rooms. Analogs are selected by area band + geo proximity, NOT by room
// count, so the set may span several planning types. Only claim a concrete
// "N-к" filter when the analogs are genuinely homogeneous; otherwise stay
// neutral ("все планировки") so the chip never asserts a filter that was not
// applied to the data.
const analogRooms = Array.from(
new Set(
e.analogs
.map((a) => a.rooms)
.filter((r): r is number => r != null && Number.isFinite(r)),
),
);
if (analogRooms.length === 1) {
const r = analogRooms[0];
filters.push(`Планировка ${r <= 0 ? "студия" : `${r}`}`);
} else if (analogRooms.length > 1) {
filters.push("Все планировки");
}
const areas = e.analogs
.map((a) => a.area_m2)
.filter((x) => Number.isFinite(x));
if (areas.length > 0) {
const lo = Math.min(...areas).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
const hi = Math.max(...areas).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
filters.push(`Площадь ${lo}${NBSP}${NBSP}${hi}${NBSP}м²`);
}
return filters;
}
/**
* 05 РЫНОК: analog ad rows + actual-deal rows + the two market KPI bands. Deal
* aggregates prefer the per-street ДКП corridor (streetDeals), falling back to
* the estimate's own actual_deals.
*/
export function mapSources(
estimate: AggregatedEstimate | null,
streetDeals: StreetDealsResponse | null,
): SourcesData {
const e = estimate;
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
addr: deglueAddr(l.address || "—"),
meta: adMeta(l),
ppm: numRu(l.price_per_m2),
price: numRu(l.price_rub),
source: sourceLabel(l.source),
dist: fmtDist(l.distance_m),
url: l.source_url ?? null,
}));
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
addr: deglueAddr(l.address || "—"),
meta: dealMeta(l),
ppm: numRu(l.price_per_m2),
price: numRu(l.price_rub),
source: sourceLabel(l.source),
tier: tierLabel(l.tier),
date: fmtMonthYear(l.listing_date),
}));
const marketAds: MarketAds = {
kpi: {
count: e != null ? String(e.n_analogs) : "—",
median: e != null ? fmtMln(e.median_price_rub) : "—",
ppm:
e != null && Number.isFinite(e.median_price_per_m2)
? numRu(e.median_price_per_m2)
: "—",
range: e != null ? mlnRangeTight(e.range_low_rub, e.range_high_rub) : "—",
cv: cvLabel(e),
},
filters: buildAdFilters(e),
};
// Deal aggregates: street DKP corridor first, else the estimate's actual_deals.
const dealPrices = e?.actual_deals.map((d) => d.price_rub) ?? [];
const dealCount = streetDeals?.count ?? dealPrices.length;
const dealMedianRub = streetDeals?.median_price_rub ?? median(dealPrices);
const dealLo =
streetDeals?.range_low_rub ??
(dealPrices.length > 0 ? Math.min(...dealPrices) : null);
const dealHi =
streetDeals?.range_high_rub ??
(dealPrices.length > 0 ? Math.max(...dealPrices) : null);
const asking = e?.median_price_rub ?? null;
const deltaPct =
dealMedianRub != null && asking != null && asking > 0
? (dealMedianRub / asking - 1) * 100
: null;
const marketDeals: MarketDeals = {
kpi: {
count: dealCount > 0 ? String(dealCount) : "—",
median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—",
// §M8 — единая формулировка дельт: всё считается «относительно цен
// объявлений» (deltaPct = медиана сделок / медиана объявлений 1).
delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—",
range: mlnRangeSp(dealLo, dealHi),
},
};
return {
adRows,
dealRows,
marketAds,
marketDeals,
sourceCount: estimate
? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
: "—",
};
}
// ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ─────────────────────────────────────────
// FE-derived ИСКЛЮЧИТЕЛЬНО из per-user списка /history (НЕ из /cache-stats — тот
// глобальный/админский: estimates_total считает ВСЕХ юзеров). Несколько полей,
// которых нет в /history, аппроксимируются на фронте (см. TODO BE-1):
// • «ИСТОЧНИКОВ» (N / 7) требует sources_used — в /history его нет → "—".
// • Статус свежести строится от 24-часового TTL кэша (ParamsPanel:
// «КЭШ ПО АДРЕСУ — 24 Ч»): <18ч свежий · 1824ч устаревает · >24ч устарел.
export interface CacheData {
rows: CacheRow[];
kpis: CacheKpi[];
}
const CACHE_TTL_HOURS = 24;
/**
* created_at -> компактное ru-время для таблицы (как в дизайне):
* сегодня -> "HH:MM:SS"; вчера -> "вчера HH:MM"; раньше -> "DD.MM.YYYY".
*/
function fmtCacheTime(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const t = d.getTime();
if (t >= startOfToday) {
return d.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
if (t >= startOfToday - 86_400_000) {
return `вчера ${d.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}`;
}
return fmtDate(iso);
}
/** Свежесть оценки vs 24ч TTL кэша → подпись + token-цвет точки/текста. */
function cacheStatus(iso: string | null | undefined): {
status: string;
statusColor: string;
} {
if (!iso) return { status: "—", statusColor: tokens.muted };
const d = new Date(iso);
if (Number.isNaN(d.getTime()))
return { status: "—", statusColor: tokens.muted };
const ageH = (Date.now() - d.getTime()) / 3_600_000;
if (ageH <= CACHE_TTL_HOURS * 0.75) {
return { status: "свежий", statusColor: tokens.success };
}
if (ageH <= CACHE_TTL_HOURS) {
return { status: "устаревает", statusColor: tokens.warn };
}
return { status: "устарел", statusColor: tokens.danger };
}
/**
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
* фронте из per-user /history (null → пустая таблица + KPI с "—").
* rows — addr/время/статус по каждой строке (src всегда "—", TODO BE-1).
* kpis — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн).
* «ПОВТОРНЫЕ АДРЕСА» убран (§M8 — внутренняя ops-метрика).
*/
export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
const list = history ?? [];
const rows: CacheRow[] = list.map((h) => {
const { status, statusColor } = cacheStatus(h.created_at);
return {
// §M8 — de-glue metro/address runs the same way as the analog/deal rows
// (these are the user's own past-estimate addresses, often geocoded with
// metro proximity jammed on with no separators).
addr: h.address ? deglueAddr(h.address) : "—",
time: fmtCacheTime(h.created_at),
src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
status,
statusColor,
};
});
// KPI 1 — всего оценок (длина per-user списка, capped /history limit).
const total = list.length;
// KPI 2 — средняя цена по строкам с положительной медианой.
const prices = list
.map((h) => h.median_price)
.filter((p): p is number => p != null && Number.isFinite(p) && p > 0);
const avg =
prices.length > 0
? prices.reduce((a, b) => a + b, 0) / prices.length
: null;
// §M8 — «ПОВТОРНЫЕ АДРЕСА» (доля переоценок) was an internal ops metric with no
// meaning for the end user, so it is dropped from this витрина. Only the two
// user-relevant KPIs remain.
const kpis: CacheKpi[] = [
{
label: "ВСЕГО ОЦЕНОК",
value: String(total),
sub: "по вашим оценкам",
},
{
label: "СРЕДНЯЯ ЦЕНА",
value: avg != null ? fmtMln(avg) : "—",
unit: avg != null ? "млн ₽" : undefined,
sub: "по проведённым оценкам",
},
];
return { rows, kpis };
}