chore(tradein/v2): #2899 — убрать шум prettier из предыдущего коммита (у tradein-frontend нет prettier-конвенции), оставить только правки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
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 / frontend-checks (pull_request) Successful in 1m17s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
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 / frontend-checks (pull_request) Successful in 1m17s
This commit is contained in:
parent
03af7386e9
commit
b6ab582211
3 changed files with 353 additions and 377 deletions
|
|
@ -64,7 +64,13 @@ function DeltaPill({
|
|||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: font.mono, fontSize: 12, fontWeight: 600 }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{delta}
|
||||
</span>
|
||||
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>{deltaLabel}</span>
|
||||
|
|
@ -240,198 +246,250 @@ export default function ResultPanel({
|
|||
const isHeadline = !isEmptyCard && ci === headlineIndex;
|
||||
const isAsking = ci === 0;
|
||||
return (
|
||||
<div
|
||||
key={ci}
|
||||
style={{
|
||||
position: "relative",
|
||||
background: isHeadline
|
||||
? infoSoftBg
|
||||
: isRegistry
|
||||
? surface.w40
|
||||
: surface.w55,
|
||||
backdropFilter: "blur(6px)",
|
||||
border: isHeadline
|
||||
? `1.5px solid ${accent}`
|
||||
: isRegistry
|
||||
? `1px solid ${lineSoft}`
|
||||
: `1px solid ${line2}`,
|
||||
borderRadius: 8,
|
||||
padding: "15px 16px 13px",
|
||||
boxShadow: isHeadline
|
||||
? "0 6px 22px rgba(46,139,255,.16)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={ci}
|
||||
style={{
|
||||
position: "relative",
|
||||
background: isHeadline
|
||||
? infoSoftBg
|
||||
: isRegistry
|
||||
? surface.w40
|
||||
: surface.w55,
|
||||
backdropFilter: "blur(6px)",
|
||||
border: isHeadline
|
||||
? `1.5px solid ${accent}`
|
||||
: isRegistry
|
||||
? `1px solid ${lineSoft}`
|
||||
: `1px solid ${line2}`,
|
||||
borderRadius: 8,
|
||||
padding: "15px 16px 13px",
|
||||
boxShadow: isHeadline
|
||||
? "0 6px 22px rgba(46,139,255,.16)"
|
||||
: undefined,
|
||||
position: "absolute",
|
||||
left: -1,
|
||||
top: -1,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
|
||||
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -1,
|
||||
top: -1,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
|
||||
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
|
||||
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
|
||||
// even the AA "large text" floor (3:1). accentDeep is the same
|
||||
// brand hue, darkened (existing token, not a one-off hex) — it
|
||||
// clears ~4.2:1 here, so the label reads reliably too.
|
||||
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2,
|
||||
fontWeight: isHeadline ? 600 : undefined,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{lines(card.title)}
|
||||
</div>
|
||||
{/* #1991 — explicit "this is the figure that goes into the offer"
|
||||
mark on the headline card, so the two dominant prices are not
|
||||
just visually bigger but literally labelled which one to quote. */}
|
||||
{isHeadline && !isEmptyCard && (
|
||||
<span
|
||||
style={{
|
||||
flex: "0 0 auto",
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
color: onAccent,
|
||||
background: accentDeep,
|
||||
borderRadius: 3,
|
||||
padding: "3px 6px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
В ОФФЕР
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isRegistry && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 8,
|
||||
letterSpacing: 1.2,
|
||||
color: muted3,
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
|
||||
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
|
||||
</div>
|
||||
)}
|
||||
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an
|
||||
honest note pointing at the recommended asking price. */}
|
||||
{isEmptyCard ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
color: body,
|
||||
maxWidth: "94%",
|
||||
}}
|
||||
>
|
||||
{card.emptyNote}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
|
||||
// yet clearly below the accent headline (40).
|
||||
fontSize: isHeadline ? 40 : isRegistry ? 28 : 30,
|
||||
fontWeight: isHeadline ? 600 : 400,
|
||||
letterSpacing: -1,
|
||||
// Contrast follow-up (audit #2266) — accentDeep, not
|
||||
// accent, for the same reason as the title label above.
|
||||
color: isHeadline
|
||||
? accentDeep
|
||||
: isRegistry
|
||||
? muted
|
||||
: isAsking
|
||||
? ink
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{card.value}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: muted,
|
||||
letterSpacing: 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{card.unit}
|
||||
</span>
|
||||
</div>
|
||||
{card.range && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 11,
|
||||
color: muted,
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
{card.range}
|
||||
</div>
|
||||
)}
|
||||
{card.ppm && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 10,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{card.ppm}
|
||||
</div>
|
||||
)}
|
||||
{card.note && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "82%",
|
||||
}}
|
||||
>
|
||||
{card.note}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEmptyCard ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => onNavigate(card.nav)}
|
||||
aria-label={`Подробнее — ${card.title.join(" ")}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.3,
|
||||
color: accent,
|
||||
width: "100%",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
marginTop: 16,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</button>
|
||||
) : card.bars ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
|
||||
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
|
||||
// even the AA "large text" floor (3:1). accentDeep is the same
|
||||
// brand hue, darkened (existing token, not a one-off hex) — it
|
||||
// clears ~4.2:1 here, so the label reads reliably too.
|
||||
color: isHeadline
|
||||
? accentDeep
|
||||
: isRegistry
|
||||
? muted2
|
||||
: body2,
|
||||
fontWeight: isHeadline ? 600 : undefined,
|
||||
lineHeight: 1.6,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
height: 20,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{lines(card.title)}
|
||||
</div>
|
||||
{/* #1991 — explicit "this is the figure that goes into the offer"
|
||||
mark on the headline card, so the two dominant prices are not
|
||||
just visually bigger but literally labelled which one to quote. */}
|
||||
{isHeadline && !isEmptyCard && (
|
||||
<span
|
||||
style={{
|
||||
flex: "0 0 auto",
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
color: onAccent,
|
||||
background: accentDeep,
|
||||
borderRadius: 3,
|
||||
padding: "3px 6px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
В ОФФЕР
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isRegistry && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 8,
|
||||
letterSpacing: 1.2,
|
||||
color: muted3,
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
|
||||
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
|
||||
</div>
|
||||
)}
|
||||
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an
|
||||
honest note pointing at the recommended asking price. */}
|
||||
{isEmptyCard ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
color: body,
|
||||
maxWidth: "94%",
|
||||
}}
|
||||
>
|
||||
{card.emptyNote}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{card.bars.map((h, bi) => (
|
||||
<span
|
||||
key={bi}
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
|
||||
// yet clearly below the accent headline (40).
|
||||
fontSize: isHeadline ? 40 : isRegistry ? 28 : 30,
|
||||
fontWeight: isHeadline ? 600 : 400,
|
||||
letterSpacing: -1,
|
||||
// Contrast follow-up (audit #2266) — accentDeep, not
|
||||
// accent, for the same reason as the title label above.
|
||||
color: isHeadline
|
||||
? accentDeep
|
||||
: isRegistry
|
||||
? muted
|
||||
: isAsking
|
||||
? ink
|
||||
: undefined,
|
||||
flex: 1,
|
||||
height: `${h}%`,
|
||||
background: barColor(bi, card.bars as number[]),
|
||||
}}
|
||||
>
|
||||
{card.value}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: muted,
|
||||
letterSpacing: 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{card.unit}
|
||||
</span>
|
||||
</div>
|
||||
{card.range && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 11,
|
||||
color: muted,
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
{card.range}
|
||||
</div>
|
||||
)}
|
||||
{card.ppm && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 10,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{card.ppm}
|
||||
</div>
|
||||
)}
|
||||
{card.note && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "82%",
|
||||
}}
|
||||
>
|
||||
{card.note}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEmptyCard ? (
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
|
|
@ -440,151 +498,89 @@ export default function ResultPanel({
|
|||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.3,
|
||||
color: accent,
|
||||
width: "100%",
|
||||
whiteSpace: "nowrap",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
marginTop: 16,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</button>
|
||||
) : card.bars ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
height: 20,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{card.bars.map((h, bi) => (
|
||||
<span
|
||||
key={bi}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: `${h}%`,
|
||||
background: barColor(bi, card.bars as number[]),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => onNavigate(card.nav)}
|
||||
aria-label={`Подробнее — ${card.title.join(" ")}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.3,
|
||||
color: accent,
|
||||
whiteSpace: "nowrap",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й
|
||||
</div>
|
||||
{/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й
|
||||
перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */}
|
||||
{card.delta ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<DeltaPill
|
||||
delta={card.delta}
|
||||
deltaLabel={card.deltaLabel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
// M2 — calm delta pill (was a 51px circular gauge that read like a
|
||||
// tech "занижение" indicator). Full text «−18% к цене объявления»,
|
||||
// neutral accentDeep on a soft-blue chip, tabular-nums.
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
marginTop: 14,
|
||||
}}
|
||||
>
|
||||
{card.delta ? (
|
||||
<DeltaPill
|
||||
delta={card.delta}
|
||||
deltaLabel={card.deltaLabel}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => onNavigate(card.nav)}
|
||||
aria-label={`Подробнее — ${card.title.join(" ")}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.3,
|
||||
color: accent,
|
||||
whiteSpace: "nowrap",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
{card.exposureNote && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "82%",
|
||||
}}
|
||||
>
|
||||
{card.exposureNote}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{card.delta ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<DeltaPill delta={card.delta} deltaLabel={card.deltaLabel} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
// M2 — calm delta pill (was a 51px circular gauge that read like a
|
||||
// tech "занижение" indicator). Full text «−18% к цене объявления»,
|
||||
// neutral accentDeep on a soft-blue chip, tabular-nums.
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
marginTop: 14,
|
||||
}}
|
||||
>
|
||||
{card.delta ? (
|
||||
<DeltaPill delta={card.delta} deltaLabel={card.deltaLabel} />
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rp-more"
|
||||
onClick={() => onNavigate(card.nav)}
|
||||
aria-label={`Подробнее — ${card.title.join(" ")}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
fontSize: 9,
|
||||
letterSpacing: 0.3,
|
||||
color: accent,
|
||||
whiteSpace: "nowrap",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Подробнее <span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
{card.exposureNote && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: muted2,
|
||||
marginTop: 4,
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "82%",
|
||||
}}
|
||||
>
|
||||
{card.exposureNote}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -610,8 +606,8 @@ export default function ResultPanel({
|
|||
marginTop: -6,
|
||||
}}
|
||||
>
|
||||
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а
|
||||
не погрешность оценки.
|
||||
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке,
|
||||
а не погрешность оценки.
|
||||
</div>
|
||||
|
||||
{/* ranges + radar */}
|
||||
|
|
|
|||
|
|
@ -605,16 +605,10 @@ interface DealTier {
|
|||
}
|
||||
|
||||
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
|
||||
function newestLot(
|
||||
lots: AnalogLot[],
|
||||
): [string | null, "day" | "quarter" | null] {
|
||||
function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] {
|
||||
let best: AnalogLot | null = null;
|
||||
for (const l of lots) {
|
||||
if (
|
||||
l.listing_date &&
|
||||
(!best?.listing_date || l.listing_date > best.listing_date)
|
||||
)
|
||||
best = l;
|
||||
if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l;
|
||||
}
|
||||
return [best?.listing_date ?? null, best?.date_precision ?? null];
|
||||
}
|
||||
|
|
@ -642,9 +636,7 @@ function resolveDealTier(
|
|||
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
|
||||
// same way the ads card does — otherwise a single mis-scraped lot bins
|
||||
// over [min,max] and crushes the real deals into the left buckets.
|
||||
bars: bins8(
|
||||
guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean,
|
||||
),
|
||||
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
|
||||
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
|
||||
// так что максимум по ним = максимум по всей выборке, не по показанным.
|
||||
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
|
||||
|
|
@ -833,13 +825,11 @@ export function mapReport(e: AggregatedEstimate): Report {
|
|||
* - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg)
|
||||
* - "insufficient_data" → "мало данных" (too few comparable listings)
|
||||
*/
|
||||
function locationIndexBadge(li: LocationIndexResponse | null | undefined): {
|
||||
label: string;
|
||||
ok: boolean;
|
||||
} {
|
||||
function locationIndexBadge(
|
||||
li: LocationIndexResponse | null | undefined,
|
||||
): { label: string; ok: boolean } {
|
||||
if (li == null) return { label: "нет данных", ok: false };
|
||||
if (li.status === "ok")
|
||||
return { label: fmtPct(li.location_index_pct), ok: true };
|
||||
if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
|
||||
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
|
||||
return { label: "мало данных", ok: false }; // status === "insufficient_data"
|
||||
}
|
||||
|
|
@ -1086,7 +1076,8 @@ export function marketPositionPill(
|
|||
if (pct == null || !Number.isFinite(pct)) return undefined;
|
||||
const delta = pct <= 33 ? "Низ рынка" : pct >= 67 ? "Верх рынка" : "В рынке";
|
||||
const n = e.n_analogs;
|
||||
const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, ["аналога", "аналогов", "аналогов"])}`;
|
||||
const forms: [string, string, string] = ["аналога", "аналогов", "аналогов"];
|
||||
const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, forms)}`;
|
||||
return { delta, deltaLabel };
|
||||
}
|
||||
|
||||
|
|
@ -1135,9 +1126,7 @@ export function mapResultPanel(
|
|||
// 872k выброс this card already reports as "исключён", squashing the real
|
||||
// analogs into the left bins while the number says the outlier is dropped.
|
||||
// dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn).
|
||||
bars: bins8(
|
||||
guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean,
|
||||
),
|
||||
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
|
||||
// #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная
|
||||
// плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null).
|
||||
...(marketPositionPill(e) ?? {}),
|
||||
|
|
@ -1153,9 +1142,7 @@ export function mapResultPanel(
|
|||
e.expected_sold_range_high_rub,
|
||||
)
|
||||
: "",
|
||||
ppm: hasExpected
|
||||
? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`
|
||||
: "",
|
||||
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "",
|
||||
delta:
|
||||
hasExpected && e.asking_to_sold_ratio != null
|
||||
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
||||
|
|
@ -2182,10 +2169,7 @@ export function mapSources(
|
|||
// sample still shown in the table right below this KPI tile) — a bare
|
||||
// "0" here would directly contradict visible rows. Fall back to the
|
||||
// actual displayed population (same fix as ListingsCard's count-strip).
|
||||
count:
|
||||
e != null
|
||||
? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length)
|
||||
: "—",
|
||||
count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—",
|
||||
median: e != null ? fmtMln(e.median_price_rub) : "—",
|
||||
ppm:
|
||||
e != null && Number.isFinite(e.median_price_per_m2)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ export interface QuotaStatus {
|
|||
}
|
||||
|
||||
export type HouseType =
|
||||
"panel" | "brick" | "monolith" | "monolith_brick" | "other";
|
||||
| "panel"
|
||||
| "brick"
|
||||
| "monolith"
|
||||
| "monolith_brick"
|
||||
| "other";
|
||||
|
||||
export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
|
||||
|
||||
|
|
@ -29,19 +33,11 @@ export const REPAIR_STATES: readonly RepairState[] = [
|
|||
"good",
|
||||
"excellent",
|
||||
];
|
||||
export function asHouseType(
|
||||
v: string | null | undefined,
|
||||
): HouseType | undefined {
|
||||
return v && (HOUSE_TYPES as readonly string[]).includes(v)
|
||||
? (v as HouseType)
|
||||
: undefined;
|
||||
export function asHouseType(v: string | null | undefined): HouseType | undefined {
|
||||
return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined;
|
||||
}
|
||||
export function asRepairState(
|
||||
v: string | null | undefined,
|
||||
): RepairState | undefined {
|
||||
return v && (REPAIR_STATES as readonly string[]).includes(v)
|
||||
? (v as RepairState)
|
||||
: undefined;
|
||||
export function asRepairState(v: string | null | undefined): RepairState | undefined {
|
||||
return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined;
|
||||
}
|
||||
|
||||
export type ConfidenceLevel = "low" | "medium" | "high";
|
||||
|
|
@ -107,9 +103,9 @@ export interface AnalogLot {
|
|||
days_on_market: number | null;
|
||||
photo_url: string | null;
|
||||
// ── Слой 5.2 — clickable links + distance ──
|
||||
source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr'
|
||||
source_url: string | null; // ссылка на оригинал
|
||||
distance_m: number | null; // расстояние до целевой квартиры в метрах
|
||||
source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr'
|
||||
source_url: string | null; // ссылка на оригинал
|
||||
distance_m: number | null; // расстояние до целевой квартиры в метрах
|
||||
// ── PR M (#564 Phase 3) — confidence tier для rosreestr deals ──
|
||||
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
|
||||
// 'T1_per_street' — street-level only (default for all rosreestr deals)
|
||||
|
|
@ -129,7 +125,7 @@ export interface AnalogLot {
|
|||
// month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району.
|
||||
export interface PriceTrendPoint {
|
||||
month: string; // "YYYY-MM"
|
||||
ppm2: number; // ₽/м²
|
||||
ppm2: number; // ₽/м²
|
||||
}
|
||||
|
||||
export interface CianValuationSummary {
|
||||
|
|
@ -137,7 +133,7 @@ export interface CianValuationSummary {
|
|||
rent_price_rub: number | null;
|
||||
chart: Array<{ date: string; price: number }>;
|
||||
chart_change_pct: number | null;
|
||||
chart_change_direction: "increase" | "decrease" | "neutral" | null;
|
||||
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
|
||||
}
|
||||
|
||||
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
|
||||
|
|
@ -148,20 +144,20 @@ export type RatioBasis = "per_rooms" | "global_fallback";
|
|||
// blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи.
|
||||
export interface AvitoImvSummary {
|
||||
recommended_price: number | null; // рекомендованная цена Avito, ₽
|
||||
lower_price: number | null; // нижняя граница IMV-коридора, ₽
|
||||
higher_price: number | null; // верхняя граница IMV-коридора, ₽
|
||||
market_count: number | null; // объём рынка, на котором построена оценка
|
||||
lower_price: number | null; // нижняя граница IMV-коридора, ₽
|
||||
higher_price: number | null; // верхняя граница IMV-коридора, ₽
|
||||
market_count: number | null; // объём рынка, на котором построена оценка
|
||||
}
|
||||
|
||||
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──
|
||||
// Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице
|
||||
// нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект).
|
||||
export interface DkpCorridor {
|
||||
count: number; // число ДКП-сделок в выборке
|
||||
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
|
||||
median_ppm2: number; // медиана ₽/м²
|
||||
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
|
||||
period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
|
||||
count: number; // число ДКП-сделок в выборке
|
||||
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
|
||||
median_ppm2: number; // медиана ₽/м²
|
||||
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
|
||||
period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
|
||||
// #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность
|
||||
// — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому
|
||||
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
|
||||
|
|
@ -183,7 +179,7 @@ export interface AggregatedEstimate {
|
|||
expected_sold_range_high_rub?: number | null;
|
||||
expected_sold_per_m2?: number | null;
|
||||
asking_to_sold_ratio?: number | null; // sold / asking (≈0.82)
|
||||
ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback'
|
||||
ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback'
|
||||
confidence: ConfidenceLevel;
|
||||
confidence_explanation: string | null;
|
||||
n_analogs: number;
|
||||
|
|
@ -218,16 +214,16 @@ export interface AggregatedEstimate {
|
|||
// Ephemeral (не персистится в БД, только для текущего ответа) — optional,
|
||||
// т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат.
|
||||
target_city_ambiguous?: boolean;
|
||||
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
||||
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
||||
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
|
||||
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые
|
||||
// кешированные оценки их не содержат → UI graceful fallback.
|
||||
cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен
|
||||
source_counts?: Record<string, number>; // {'avito': 12, 'cian': 5}
|
||||
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
||||
cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен
|
||||
source_counts?: Record<string, number>; // {'avito': 12, 'cian': 5}
|
||||
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
||||
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
||||
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||
est_days_on_market: number | null; // прогноз срока продажи
|
||||
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||
est_days_on_market: number | null; // прогноз срока продажи
|
||||
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
||||
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
||||
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
||||
|
|
@ -391,15 +387,15 @@ export interface HouseAnalyticsResponse {
|
|||
// ── Street deals (endpoint: GET /trade-in/street-deals) ──
|
||||
|
||||
export interface StreetDealsResponse {
|
||||
street: string | null; // "Космонавтов" / null если не извлёкся
|
||||
period_from: string; // ISO date "2025-05-29"
|
||||
street: string | null; // "Космонавтов" / null если не извлёкся
|
||||
period_from: string; // ISO date "2025-05-29"
|
||||
period_to: string;
|
||||
count: number; // all matching, не топ-10
|
||||
count: number; // all matching, не топ-10
|
||||
median_price_rub: number;
|
||||
median_price_per_m2: number;
|
||||
range_low_rub: number;
|
||||
range_high_rub: number;
|
||||
deals: AnalogLot[]; // top-10 по deal_date DESC
|
||||
deals: AnalogLot[]; // top-10 по deal_date DESC
|
||||
}
|
||||
|
||||
// ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ──
|
||||
|
|
@ -408,7 +404,7 @@ export interface StreetDealsResponse {
|
|||
|
||||
export interface SalesListingPair {
|
||||
deal_id: number;
|
||||
deal_date: string; // ISO date
|
||||
deal_date: string; // ISO date
|
||||
deal_price_rub: number;
|
||||
deal_price_per_m2: number;
|
||||
deal_area_m2: number;
|
||||
|
|
@ -417,9 +413,9 @@ export interface SalesListingPair {
|
|||
deal_address: string;
|
||||
|
||||
listing_id: number | null;
|
||||
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники)
|
||||
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники)
|
||||
listing_source_url: string | null;
|
||||
listing_date: string | null; // ISO date
|
||||
listing_date: string | null; // ISO date
|
||||
listing_price_rub: number | null;
|
||||
listing_price_per_m2: number | null;
|
||||
listing_area_m2: number | null;
|
||||
|
|
@ -434,9 +430,9 @@ export interface SalesListingPair {
|
|||
|
||||
export interface SalesVsListingsResponse {
|
||||
street: string | null;
|
||||
period_months: number; // 24 default
|
||||
window_days: number; // 180 default
|
||||
area_tolerance: number; // 0.15 default
|
||||
period_months: number; // 24 default
|
||||
window_days: number; // 180 default
|
||||
area_tolerance: number; // 0.15 default
|
||||
total_deals: number;
|
||||
deals_with_listings: number;
|
||||
linkage_rate_pct: number;
|
||||
|
|
@ -448,14 +444,14 @@ export interface SalesVsListingsResponse {
|
|||
// Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть
|
||||
// сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок.
|
||||
data_quality: "house_linked" | "street_only" | "no_data";
|
||||
pairs: SalesListingPair[]; // все sorted by deal_date DESC
|
||||
pairs: SalesListingPair[]; // все sorted by deal_date DESC
|
||||
}
|
||||
|
||||
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
|
||||
|
||||
export interface SellTimeBucket {
|
||||
price_premium_label: string; // 'cheap' | 'median' | 'plus5' | 'plus10'
|
||||
price_premium_pct: number; // -5, 0, 5, 10
|
||||
price_premium_pct: number; // -5, 0, 5, 10
|
||||
median_exposure_days: number | null;
|
||||
p25_days: number | null;
|
||||
p75_days: number | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue