feat(tradein/cian): surface valuation chart + rent + change-pct on /trade-in #530
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#530
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/cian-valuation-surface"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Makes the Cian Valuation Calculator data visible to users. Stage 9 of the estimator already calls Cian Calculator and persists chart_data, rent_price_rub, chart_change_pct, chart_change_direction to
external_valuations— but onlysale_price_rubwas consumed downstream. This PR plumbs the rest through to the response and renders them in a new card.Backend
tradein-mvp/backend/app/schemas/trade_in.py— newCianValuationSummaryPydantic model (sale, rent, chart, change-pct, change-direction Literal). Addedcian_valuation: CianValuationSummary | None = Nonefield toAggregatedEstimate.tradein-mvp/backend/app/services/estimator.py— primaryAggregatedEstimate(~L873) populates the new field fromcian_val;_empty_estimatefallback (~L1617) setsNone. Defensive coerce: unknownchart_change_directionvalues →None(guards against unmapped Cian strings; Pydantic Literal would otherwise raise).Frontend
tradein-mvp/frontend/src/types/trade-in.ts— exportedCianValuationSummaryinterface matching Pydantic shape.tradein-mvp/frontend/src/components/trade-in/CianValuationCard.tsx— new card: sale (большая цифра) + rent (поменьше) + ChangeBadge with arrow colored by direction + inline SVG Sparkline (pure SVG, no recharts/chart.js dep). Hides itself when data is null or chart is empty.tradein-mvp/frontend/src/app/page.tsx— renders<CianValuationCard data={resultData.estimate.cian_valuation} />after IMVBenchmark.tradein-mvp/frontend/src/components/trade-in/trade-in.css—.cian-valuation*BEM block, no conflicts with existing classes.Why now
Predecessors PR #523 + #525 fixed persistence and added admin triggers. Data is being written for every /estimate call but invisible. This PR completes the user-visible loop.
Follow-up: PR 4 — Celery backfill task for historical fill via PR2 endpoints.
Test plan
3f0a49d(defensive coerce)Deep Code Review — verdict
Summary
3f0a49d🔴 Critical (BLOCK)
1. Chart key contract mismatch — Sparkline crashes at runtime
Backend stores chart entries with key
month_date, frontend readsdate. The whole new feature is broken on any populated chart.tradein-mvp/backend/app/services/scrapers/cian_valuation.py:302-306(existing, unchanged in this PR):Frontend
CianValuationCard.tsx:25-26:Result:
TypeError: Cannot read properties of undefined (reading 'slice')whenever the chart has ≥2 points (the normal case — Cian Calculator returns 7 monthly points). Pre-push smoke clearly wasn't executed against a Cian-populated address.Also the TS type at
frontend/src/types/trade-in.ts:51declares the array asArray<{ date: string; price: number }>, which lies about the wire shape — Pydantic schema islist[dict[str, Any]]so the bad shape passes through unvalidated.Fix — pick one:
(A) Backend-side rename in
estimator.pymapper (preferred — frontend contract stable, cached old rows handled too via DB path_row_to_result):(B) Frontend-side:
const dateKey = (points[0] as any).month_date ?? points[0].date;and update the TS type tomonth_date. Less clean — leaks scraper internals to UI.Plus tighten the Pydantic schema (replace
list[dict[str, Any]]withlist[ChartPoint]whereChartPoint = BaseModel(date: str, price: float)) so the next mismatch is a 500, not a silent client crash.🟡 Medium
2. Sparkline doesn't filter null-price points
_parse_numincian_valuation.pyreturnsNoneon parse failures, and chart entries are pushed unconditionally. Frontend computesMath.min(...prices), Math.max(...prices)— a singlenullpropagates asNaNthroughrangeand everyyvalue → invalid SVG path, sparkline disappears or renders broken (no error, just silent visual breakage).CianValuationCard.tsx:18-20:Fix in the same backend mapping shown above:
if p.get("price") is not None.3. No regression test for the new contract
Both schema additions and the new component ship without a test asserting
chart[*]hasdate/pricekeys. A 3-line pytest againstestimate_quality(mockingcian_val) or a snapshot test of<CianValuationCard data={fixture} />would have caught #1 instantly. Test plan checkboxManual smoke after mergeis unchecked — that's the wrong tense; smoke should precede merge.🟢 Low / Positive
chart_change_directioncoerce against unmapped Cian strings (estimator.py:879-883) — good catch given Pydantic Literal would otherwise raise.dangerouslySetInnerHTML, SVGdbuilt from server numbers,aria-hiddenon the chart witharia-labelon the section wrapper."use client"directive present (Next 15 App Router requirement forIntl.NumberFormat+ interactive component pattern)..cian-valuation*(new namespace)._empty_estimatefallback explicitly setscian_valuation=None— no Pydantic ValidationError on the no-data path.!data || data.chart.length === 0) prevents 0/1-point rendering.formatRubreturns—for null but the metric block already conditionally renders rent (data.rent_price_rub != null && ...).formatRubnull-branch is dead for that call site, but used forsale_price_rubwhich has no conditional. If sale is null but chart is non-empty, you'd renderПродажа —which is fine UX. Keep.Cross-file impact
cian_valuation.py(untouched) is the source of truth for chart shape — anyone changing it must also update this card. Add a comment nearresult.chart.append(...)linking toCianValuationCard.tsxto make the coupling visible.external_valuationscached rows persistchartas JSONB withmonth_datekey (see_save_to_cacheand_row_to_result). Backend-side mapping (Fix A) handles both fresh and cached payloads uniformly. Frontend-side fix would not — old cached rows would still surface asmonth_date.Recommended next steps
estimator.py— rename + null-filter in the dict comprehension.CianValuationSummary.chartPydantic type to a typedChartPointmodel.estimate_qualitypopulatingcian_valuation.chart[0].date./estimateagainst an EKB address that hits Cian Calculator (not no-cookies path), verify sparkline renders.Complexity / blast radius
Re-review verdict: CHANGES REQUESTED
Head SHA:
e73f947· prior critical issue still present.Status of prior critical issues
month_datevsdate)ChartPointPydantic schemaCritical (BLOCK)
Backend still emits
month_date, frontend still readsdate— runtime crash on first chart render.tradein-mvp/backend/app/services/scrapers/cian_valuation.py:302-305— chart points persisted as{"month_date": date_str, "price": ..., "price_formatted": ...}(JSONB column, keys round-trip verbatim from DB cache loader at line 383:chart=row["chart"] if isinstance(row["chart"], list) else []).tradein-mvp/backend/app/services/estimator.py:877—chart=list(cian_val.chart or [])passes raw dicts through without remapping or null filter.tradein-mvp/backend/app/schemas/trade_in.py:58—chart: list[dict[str, Any]] = Field(default_factory=list)accepts arbitrary keys; nomodel_serializer/@field_validatorto remapmonth_date->dateor strip null-price points.tradein-mvp/frontend/src/components/trade-in/CianValuationCard.tsx:25-26—points[0].date.slice(0, 7)will throwTypeError: Cannot read properties of undefined (reading 'slice')(keydateisundefined; backend dict hasmonth_date)..datewere defined:points.map((p) => p.price)includes None-valued points ->Math.min(...[..., NaN, ...]) === NaN-> SVGpathdattribute becomesM..,NaN L..,NaN ...-> sparkline renders as empty/broken.Required fixes (re-iterating prior recommendation)
estimator.py(after line 876):trade_in.py: This enforces the contract at API boundary; if backend ever emitsmonth_dateagain, the response fails fast (422) instead of silently corrupting the frontend.tradein-mvp/backend/tests/test_estimator_cian_chart_contract.py): assert thatestimate_qualityoutput'scian_valuation.chart[0]has keys{"date", "price"}and excludes null-price points. Without this, the same bug will regress on the next scraper refactor.Positive (kept)
chart_change_directionwhitelist guard inestimator.py:879-883correctly enforces theLiteral["increase", "decrease", "neutral"]schema before assignment (defensive — good).return nullwhenpoints.length < 2(good).ChangeBadgecorrectly handlesnullpct/direction(good).Verdict: changes
Merge blocked — same contract bug as
3f0a49d. The single backend edit in this fixup addresses a different concern (direction enum), not the chart key/null issue called out in comment id 3218. Please apply items 1+2+3 above and re-request review.Re-review verdict: APPROVE
Head SHA:
8cdc784· 3 of 4 prior critical issues resolved; #4 (regression test) downgraded to follow-up since the contract is now defended on both API ends.Status of prior critical issues
8cdc784month_datevsdate)estimator.py:876-881mapsp.get('month_date') or p.get('date') or ''-> emitsdateto APIif p.get('price') is not NoneChartPointPydantic schemaschemas/trade_in.py:49-54addsCianChartPoint(date: str, price: float);chart: list[CianChartPoint]Why approve despite missing test
CianChartPoint(date: str, price: float)now validates the wire contract at the API boundary. Any future regression that re-introducesmonth_datewould raise422 ValidationErrorat FastAPI response serialization — fail-fast at integration boundary, not silent UI crash.Sparklinealready guardspoints.length < 2andMath.min/maxoverprices: float[]is now safe because backend filters nulls before serialization.p.get('month_date') or p.get('date')fallback transparently handles both fresh-scraped chart dicts and any hypothetical cached future-shape rows.chart_change_directionwhitelist guard correctly defends theLiteralconstraint against unmapped Cian enum strings.Cross-file impact verified
cian_valuation.py:302-306(unchanged) emits{month_date, price, price_formatted}; estimator mapper atestimator.py:876-881consumes correctly with fallback._row_to_resultcache loader (cian_valuation.py:383) round-trips JSONBchartverbatim — backend-side remap correctly handles both fresh and cached payloads.Anyimport becomes dead (still used atschemas/trade_in.py:144,157).types/trade-in.ts:53declareschart: Array<{date: string; price: number}>— matches Pydantic output now.Follow-up (non-blocking)
tradein-mvp/backend/tests/test_estimator_cian_chart_contract.py: Defends against future scraper refactors silently renaming the key.cian_valuation.py:302linking toCianValuationCard.tsxto flag the coupling.Positive
list[dict[str, Any]]is gone.aria-hidden/aria-labelset correctly..cian-valuation*is new — no collision.Complexity / blast radius