gendesign/tradein-mvp/frontend/src/app/history/page.tsx
Light1YT 41e9c820c9
All checks were successful
CI / changes (pull_request) Successful in 7s
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
fix(tradein/estimator): ghost-anchor invariant guard — n_analogs=0 forces low (audit #1871 P1.2)
Audit #1871 P1.2: estimate 5fcc1e99 показывал confidence='high' при
n_analogs=0 (median 38.45M ₽, sources=['yandex_valuation']) при реальной
истории листингов дома 4.2-6.5M. По prod-DB это ЕДИНСТВЕННАЯ legacy-строка
от 2026-05-30, давно expired (TTL оценок = 1 день, все 66 high/medium-c-n<5
строк expired). Текущий mainline уже честен. Фикс — defensive invariant,
чтобы ghost не мог родиться снова из будущих external-valuation-blend /
rehydrate / migration путей.

Backend (estimator.py):
- helper _enforce_zero_analog_low(confidence, n_analogs, explanation, ...)
  форсит 'low' + caveat когда n_analogs==0 and confidence!='low'.
  Идемпотентен (low+0 → low без дубля caveat), не трогает high+N>0, реальный
  median внешнего сервиса сохраняется (fake-коэффициенты не вводятся),
  logger.warning для observability.
- Вызов перед сборкой AggregatedEstimate в estimate_quality (единственный
  уязвимый return-путь; _empty_estimate хардкодит low — структурно безопасен).
- +5 unit-тестов (high+0→low+caveat, medium+0→low, low+0 идемпотент,
  high+N unchanged, None-explanation).

Frontend (HeroSummary, HeroTransparency, history):
- effectiveConfidence = n_analogs===0 ? 'low' : confidence — defensive depth
  на случай legacy-rehydrate / контракт-drift. Caveat-текст из backend
  confidence_explanation (не хардкод). Type-safe (ConfidenceLevel).

Refs #1871
2026-06-23 11:05:34 +05:00

90 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
/* История оценок (#399). */
import { useEffect, useState } from "react";
import "@/components/trade-in/trade-in.css";
import { API_BASE_URL } from "@/lib/api";
interface HistoryRow {
id: string;
address: string;
rooms: number;
area_m2: number;
median_price: number;
confidence: string;
n_analogs: number;
created_at: string;
}
export default function HistoryPage() {
const [rows, setRows] = useState<HistoryRow[]>([]);
const [loading, setLoading] = useState(true);
// #770 finding #25: не маскировать ошибку пустым списком — иначе «нет оценок»
// показывается и при реальном сбое загрузки.
const [error, setError] = useState(false);
useEffect(() => {
fetch(`${API_BASE_URL}/api/v1/trade-in/history?limit=100`)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((d) => setRows(d as HistoryRow[]))
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
return (
<main className="page" style={{ padding: 24, maxWidth: 1000, margin: "0 auto" }}>
<p style={{ marginBottom: 12 }}>
<a href={`${API_BASE_URL}/`}> К оценке</a>
</p>
<h1 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>История оценок</h1>
{loading ? (
<p style={{ color: "var(--muted)" }}>Загрузка</p>
) : error ? (
<p style={{ color: "var(--danger, #b3261e)" }}>
Не удалось загрузить историю оценок. Обновите страницу или попробуйте позже.
</p>
) : rows.length === 0 ? (
<p style={{ color: "var(--muted)" }}>Оценок пока нет.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "2px solid var(--border, #ddd)" }}>
<th style={{ padding: 8 }}>Дата</th>
<th style={{ padding: 8 }}>Адрес</th>
<th style={{ padding: 8 }}>Комн.</th>
<th style={{ padding: 8 }}>Площадь</th>
<th style={{ padding: 8, textAlign: "right" }}>Медиана, </th>
<th style={{ padding: 8 }}>Точность</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} style={{ borderBottom: "1px solid var(--border, #eee)" }}>
<td style={{ padding: 8, whiteSpace: "nowrap" }}>
{new Date(r.created_at).toLocaleDateString("ru-RU")}
</td>
<td style={{ padding: 8 }}>
<a href={`${API_BASE_URL}/?id=${r.id}`}>{r.address}</a>
</td>
<td style={{ padding: 8 }}>{r.rooms}</td>
<td style={{ padding: 8 }}>{r.area_m2} м²</td>
<td style={{ padding: 8, textAlign: "right" }}>
{Number(r.median_price).toLocaleString("ru-RU")}
</td>
<td style={{ padding: 8 }}>
{/* #1871 P1.2 — ghost-anchor defensive depth: n_analogs=0 → low */}
{r.n_analogs === 0 ? "low" : r.confidence}
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}